Skip to content

Commit 45636f2

Browse files
committed
Run the QUIC example pair as an integration test
The examples are part of the crate's contract, and running them caught a real bug (the server panicking when a client departed), so pin them with an end-to-end integration test: spawn server_quic on [::1]:0, read the announced address, run client_quic twice, and assert each client's stdin comes back complete on stdout and that the server outlives its clients. server_quic now announces its bound address on stdout: with port 0 that is the only way any caller, interactive or test, can learn the OS-assigned port. Cargo provides no CARGO_BIN_EXE_<name> for examples, so the test locates the binaries relative to the test executable; every wait is bounded so a wedged process fails the suite rather than hanging it.
1 parent 13971db commit 45636f2

2 files changed

Lines changed: 194 additions & 0 deletions

File tree

tls/examples/server_quic.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ async fn main() {
106106
// runtime.
107107
let server = Server::new(server_config, listen_addr, log.clone()).unwrap();
108108

109+
// Announce the actual bound address on stdout (the logs go to stderr):
110+
// with port 0 this is how a caller — interactive or the example-pair
111+
// integration test — learns the OS-assigned port.
112+
println!("listening on {}", server.listen_addr().unwrap());
113+
109114
loop {
110115
let (conn, _) = server
111116
.accept(args.corpus.clone())

tls/tests/quic_examples.rs

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
//! Runs the QUIC example pair (`server_quic` / `client_quic`) against each
6+
//! other end to end, as separate processes over loopback — the examples are
7+
//! themselves part of the crate's contract, and this pins them working.
8+
//!
9+
//! Cargo builds examples as part of `cargo test`, but provides no
10+
//! `CARGO_BIN_EXE_<name>` for them (that exists only for `[[bin]]` targets),
11+
//! so the binaries are located relative to the test executable:
12+
//! `target/<profile>/examples/`. The test PKI comes from `OUT_DIR`, where
13+
//! `build.rs` generates it (the `unittest` feature is always enabled for test
14+
//! builds via the crate's self-dev-dependency).
15+
16+
#![cfg(feature = "quic")]
17+
18+
use std::io::{BufRead, BufReader, Read, Write};
19+
use std::net::SocketAddrV6;
20+
use std::path::{Path, PathBuf};
21+
use std::process::{Child, Command, ExitStatus, Stdio};
22+
use std::time::{Duration, Instant};
23+
24+
/// Bound on every wait in this test, so a wedged process fails the test
25+
/// instead of hanging the suite.
26+
const TIMEOUT: Duration = Duration::from_secs(60);
27+
28+
fn example_bin(name: &str) -> PathBuf {
29+
// The test executable lives in target/<profile>/deps/; examples are
30+
// siblings of deps/ in target/<profile>/examples/.
31+
let mut path = std::env::current_exe().unwrap();
32+
path.pop();
33+
path.pop();
34+
path.push("examples");
35+
path.push(name);
36+
assert!(
37+
path.exists(),
38+
"example binary {path:?} not found; \
39+
it is built by `cargo test --features quic`"
40+
);
41+
path
42+
}
43+
44+
fn pki(file: &str) -> PathBuf {
45+
PathBuf::from(env!("OUT_DIR")).join(file)
46+
}
47+
48+
/// The five positional paths of the examples' `local` subcommand, for test
49+
/// identity `n`.
50+
fn identity_args(n: usize) -> Vec<PathBuf> {
51+
vec![
52+
pki(&format!("test-sprockets-auth-{n}.key.pem")),
53+
pki(&format!("test-sprockets-auth-{n}.certlist.pem")),
54+
pki(&format!("test-alias-{n}.key.pem")),
55+
pki(&format!("test-alias-{n}.certlist.pem")),
56+
pki("log.bin"),
57+
]
58+
}
59+
60+
fn base_cmd(bin: &Path) -> Command {
61+
let mut cmd = Command::new(bin);
62+
cmd.arg("--roots")
63+
.arg(pki("test-root-a.cert.pem"))
64+
.arg("--corpus")
65+
.arg(pki("corim-rot.cbor"))
66+
.arg("--corpus")
67+
.arg(pki("corim-sp.cbor"))
68+
.arg("--enforce");
69+
cmd
70+
}
71+
72+
/// Kills the wrapped child on scope exit, so a failing assertion never leaks
73+
/// a listening server process.
74+
struct KillOnDrop(Child);
75+
76+
impl Drop for KillOnDrop {
77+
fn drop(&mut self) {
78+
let _ = self.0.kill();
79+
let _ = self.0.wait();
80+
}
81+
}
82+
83+
fn wait_bounded(child: &mut Child, what: &str) -> ExitStatus {
84+
let deadline = Instant::now() + TIMEOUT;
85+
loop {
86+
if let Some(status) = child.try_wait().unwrap() {
87+
return status;
88+
}
89+
assert!(
90+
Instant::now() < deadline,
91+
"{what} did not exit within {TIMEOUT:?}"
92+
);
93+
std::thread::sleep(Duration::from_millis(20));
94+
}
95+
}
96+
97+
/// Runs `client_quic` against `addr`, feeding `msg` on stdin, and returns
98+
/// what the client wrote to stdout (the echo; the client logs to stderr).
99+
fn run_client(bin: &Path, addr: &str, msg: &str) -> String {
100+
let mut child = base_cmd(bin)
101+
.arg("--addr")
102+
.arg(addr)
103+
.arg("local")
104+
.args(identity_args(2))
105+
.stdin(Stdio::piped())
106+
.stdout(Stdio::piped())
107+
.stderr(Stdio::null())
108+
.spawn()
109+
.unwrap();
110+
111+
// Write the message, then drop the handle: the resulting stdin EOF is
112+
// what moves the client to shutdown-and-drain.
113+
child
114+
.stdin
115+
.take()
116+
.unwrap()
117+
.write_all(msg.as_bytes())
118+
.unwrap();
119+
120+
let status = wait_bounded(&mut child, "client_quic");
121+
assert!(status.success(), "client_quic exited with {status}");
122+
123+
// The echo is far smaller than the pipe buffer, so reading after exit
124+
// cannot have blocked the child.
125+
let mut echoed = String::new();
126+
child
127+
.stdout
128+
.take()
129+
.unwrap()
130+
.read_to_string(&mut echoed)
131+
.unwrap();
132+
echoed
133+
}
134+
135+
/// The example pair round-trips messages over an attested QUIC connection:
136+
/// the server started on port 0 announces its real address, each client's
137+
/// stdin comes back complete on its stdout, and the server survives clients
138+
/// departing (it used to panic when a finished client closed its
139+
/// connection).
140+
#[test]
141+
fn example_pair_round_trips() {
142+
let mut server = KillOnDrop(
143+
base_cmd(&example_bin("server_quic"))
144+
.arg("--addr")
145+
.arg("[::1]:0")
146+
.arg("local")
147+
.args(identity_args(1))
148+
.stdin(Stdio::null())
149+
.stdout(Stdio::piped())
150+
.stderr(Stdio::null())
151+
.spawn()
152+
.unwrap(),
153+
);
154+
155+
// The server's first stdout line announces the OS-assigned address. Read
156+
// it via a thread + channel so a silent server trips TIMEOUT rather than
157+
// blocking the test forever; the thread then keeps draining stdout so
158+
// the server can never block on a full pipe.
159+
let stdout = server.0.stdout.take().unwrap();
160+
let (tx, rx) = std::sync::mpsc::channel();
161+
std::thread::spawn(move || {
162+
let mut lines = BufReader::new(stdout).lines();
163+
if let Some(Ok(line)) = lines.next() {
164+
let _ = tx.send(line);
165+
}
166+
for _ in lines {}
167+
});
168+
let line = rx
169+
.recv_timeout(TIMEOUT)
170+
.expect("server_quic announced its listen address");
171+
let addr = line
172+
.strip_prefix("listening on ")
173+
.unwrap_or_else(|| panic!("unexpected announce line: {line:?}"));
174+
let addr: SocketAddrV6 = addr.parse().unwrap();
175+
let addr = addr.to_string();
176+
177+
let client_bin = example_bin("client_quic");
178+
179+
const FIRST: &str = "hello over quic\n";
180+
assert_eq!(run_client(&client_bin, &addr, FIRST), FIRST);
181+
182+
const SECOND: &str = "second client\n";
183+
assert_eq!(run_client(&client_bin, &addr, SECOND), SECOND);
184+
185+
assert!(
186+
server.0.try_wait().unwrap().is_none(),
187+
"server_quic must outlive its clients"
188+
);
189+
}

0 commit comments

Comments
 (0)