From 55065c569d9df0a5f09f449595a043755dd69834 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Mon, 6 Jul 2026 01:26:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(serve=5Ftls):=20NodeLayAcceptor=20+=20serv?= =?UTF-8?q?e=5Ftls=5Fwith=5Fnodelay=20(deploy-C=20=C2=A75.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit axum_server 0.8's Server builder exposes no tcp_nodelay method (verified in source: src/axum_server/server.rs:32-360). Plan A made TCP_NODELAY unconditional on the plaintext path; this slice extends parity to the TLS path via a custom Accept impl that calls set_nodelay(true) BEFORE the inner RustlsAcceptor runs the TLS handshake. TLS does not undo the option (kernel persists it on the fd across the bytes the TLS layer writes); axum-server's own examples/rustls_session.rs documents this wrapping pattern for a different reason. serve_tls_with_nodelay is the production TLS serve path: axum_server::bind + NodeLayAcceptor + Handle::graceful_shutdown. main.rs switches to it when RUTSTER_TLS_CERT + RUTSTER_TLS_KEY are both set (Task 4). Hot-reload via RustlsConfig::reload_from_pem_file lands in Task 4's e2e reload test. Signed-off-by: Aaron D. Lee --- Cargo.lock | 105 ++++++++++++ Cargo.toml | 9 ++ crates/rutster/Cargo.toml | 12 ++ crates/rutster/src/lib.rs | 2 + crates/rutster/src/serve_tls.rs | 82 ++++++++++ crates/rutster/src/tls_acceptor.rs | 76 +++++++++ crates/rutster/tests/serve_tls_nodelay.rs | 186 ++++++++++++++++++++++ 7 files changed, 472 insertions(+) create mode 100644 crates/rutster/src/serve_tls.rs create mode 100644 crates/rutster/src/tls_acceptor.rs create mode 100644 crates/rutster/tests/serve_tls_nodelay.rs diff --git a/Cargo.lock b/Cargo.lock index 5aeac19..a83f382 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -205,6 +214,28 @@ dependencies = [ "syn", ] +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -515,6 +546,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "equivalent" version = "1.0.2" @@ -549,6 +586,12 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -558,6 +601,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", + "tokio", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -665,6 +718,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -732,6 +804,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1070,6 +1143,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1300,6 +1383,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" dependencies = [ "aws-lc-rs", + "pem", + "ring", "rustls-pki-types", "time", "x509-parser", @@ -1418,6 +1503,7 @@ version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -1442,6 +1528,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted 0.9.0", @@ -1459,9 +1546,12 @@ version = "0.0.0" dependencies = [ "async-trait", "axum", + "axum-server", "dashmap", "futures-util", "ipnet", + "rcgen", + "rustls", "rutster-brain-realtime", "rutster-call-model", "rutster-media", @@ -1472,6 +1562,7 @@ dependencies = [ "serde_json", "thiserror 1.0.69", "tokio", + "tokio-rustls", "tokio-tungstenite", "tower", "tracing", @@ -2029,6 +2120,19 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -2529,6 +2633,7 @@ dependencies = [ "lazy_static", "nom 7.1.3", "oid-registry", + "ring", "rusticata-macros", "thiserror 2.0.18", "time", diff --git a/Cargo.toml b/Cargo.toml index 16ee26f..9262696 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,15 @@ ipnet = "2" # at 0.23.41 / 0.26.4 — verified). The tls-rustls feature enables # axum_server::tls_rustls::{RustlsConfig, RustlsAcceptor}. MIT license — cargo-deny-clean. axum-server = { version = "0.8", features = ["tls-rustls"] } +# rustls 0.23: TLS implementation consumed transitively by axum-server's tls-rustls +# feature. Already in Cargo.lock at 0.23.41 via str0m → aws-lc-rs; this surfaces the +# direct edge for the integration test's ClientConfig. Apache-2.0 / MIT / ISC, +# cargo-deny clean. +rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs", "ring"] } +# tokio-rustls 0.26: tokio integration for rustls. Already in Cargo.lock at 0.26.4 +# (transitive once Task 1's Cargo.lock resolves). Used by the serve_tls integration +# test's TLS client handshake. +tokio-rustls = { version = "0.26", default-features = false, features = ["aws-lc-rs", "ring"] } # rcgen NOTE: rcgen 0.14.8 bumped MSRV to Rust 1.88, breaking the 1.85 # toolchain matrix. rcgen is a transitive dep via dimpl → str0m 0.21 → # rutster-media. Cargo.lock pins it to 0.14.7 (the minimum satisfying diff --git a/crates/rutster/Cargo.toml b/crates/rutster/Cargo.toml index d82de78..6ea12cc 100644 --- a/crates/rutster/Cargo.toml +++ b/crates/rutster/Cargo.toml @@ -19,6 +19,10 @@ rutster-tap = { path = "../rutster-tap" } # re-exported through the workspace root / never sent over the tap WS protocol. rutster-trunk = { path = "../rutster-trunk" } axum = { workspace = true } +# axum-server 0.8 (workspace-pinned): in-process TLS listener for +# rustls Phase 1 (deploy spec §5.4). Used by serve_tls::serve_tls_with_nodelay +# + tls_acceptor::NodeLayAcceptor. +axum-server = { workspace = true } tokio = { workspace = true } tokio-tungstenite = { workspace = true } futures-util = { workspace = true } @@ -36,6 +40,14 @@ ipnet = { workspace = true } [dev-dependencies] tower = { workspace = true } rutster-tap-echo = { path = "../rutster-tap-echo" } +# Self-signed cert generation for the serve_tls_nodelay integration test. +# Pinned to 0.14 to match the existing Cargo.lock transitive pin (rcgen +# 0.14.8 bumped MSRV to 1.88 — the workspace notes this in Cargo.toml's +# root comments). +rcgen = "0.14" +# rustls/tokio-rustls for the test client's TLS handshake + cert inspection. +rustls = { workspace = true } +tokio-rustls = { workspace = true } # slice-3 §7.4: integration test spins up the in-process MockRealtimeBrain # + drives the brain's `run_openai_pump` directly (replicating the brain # process's accept loop in the test, no subprocess). The `mock` feature diff --git a/crates/rutster/src/lib.rs b/crates/rutster/src/lib.rs index 70baddd..f3f31c4 100644 --- a/crates/rutster/src/lib.rs +++ b/crates/rutster/src/lib.rs @@ -27,6 +27,8 @@ pub mod event_sink; pub mod media_thread; pub mod routes; pub mod serve; +pub mod serve_tls; pub mod session_map; pub mod tap_engine; +pub mod tls_acceptor; pub mod tool_registry; diff --git a/crates/rutster/src/serve_tls.rs b/crates/rutster/src/serve_tls.rs new file mode 100644 index 0000000..cd027c5 --- /dev/null +++ b/crates/rutster/src/serve_tls.rs @@ -0,0 +1,82 @@ +//! # serve_tls — production in-process TLS listener (deploy spec §5.4) +//! +//! rustls Phase 1: BYO-cert via `RUTSTER_TLS_CERT` / `RUTSTER_TLS_KEY` +//! (paths to PEM files). When BOTH are set, `main.rs` switches from +//! `rutster::serve::serve_with_nodelay` (plan A's plaintext path) to +//! this helper. Hot-reload: `RustlsConfig::reload_from_pem_file` is +//! called by the operator's SIGHUP signal (Task 4); a `notify`-crate +//! file-watcher is explicitly deferred (would add a 3rd new dep to +//! this slice — out of scope). `RustlsConfig::reload_from_pem_file` is +//! verified atomic (arc-swap) so live WS connections are NEVER dropped +//! during reload. +//! +//! ## Why a TLS path at all when Caddy is the default edge? +//! +//! The shipped artifact default IS Caddy (deploy spec §3.2). The TLS +//! path exists for two operators: the VPS-forwarder topology where the +//! engine terminates TLS at home (deploy spec §3.5 Tier 3 — the +//! forwarder is a dumb TCP relay and cannot terminate TLS), and the +//! operator who already runs nginx/HAProxy. Both have the same +//! requirement: the engine MUST terminate TLS itself, OR it must accept +//! plaintext behind a TLS-terminating edge. See the full topology +//! decision brief at +//! docs/superpowers/specs/2026-07-05-tls-edge-decision-brief.md. +//! +//! ## Why axum-server and not axum-0.8-native TLS? +//! +//! Axum 0.7.9 (locked) does not ship a TLS listener. The successor +//! axum 0.8 has `axum::serve(...).tls_config(...)` but bumping axum +//! 0.7.9 → 0.8 is a workspace-wide migration across `axum::extract::*`, +//! `axum::routing::*`, and `axum::Server` — out of scope for this +//! deploy slice. `axum-server 0.8` composes with `axum::Router` (its +//! `serve` takes `app.into_make_service()`) without forcing an axum +//! version bump. This keeps the slice contained. +//! +//! ## External references +//! +//! - axum-server `Handle`: +//! - axum-server `RustlsConfig`: +//! - axum-server rustls example: + +use std::future::Future; +use std::net::SocketAddr; +use std::time::Duration; + +use axum::Router; +use axum_server::Handle; +use axum_server::tls_rustls::{RustlsAcceptor, RustlsConfig}; + +use crate::tls_acceptor::NodeLayAcceptor; + +/// Serve `app` bound to `addr` with rustls TLS using `rustls_config`, +/// TCP_NODELAY on every accepted socket (via `NodeLayAcceptor`), and +/// graceful shutdown via the `shutdown` future (driving +/// `Handle::graceful_shutdown` with a 60 s cap). +pub async fn serve_tls_with_nodelay( + addr: SocketAddr, + rustls_config: RustlsConfig, + app: Router, + shutdown: F, +) -> std::io::Result<()> +where + F: Future + Send + 'static, +{ + let handle = Handle::new(); + let handle_for_shutdown = handle.clone(); + tokio::spawn(async move { + shutdown.await; + // Same drain posture as plan A's `with_graceful_shutdown` — give + // in-flight calls up to 60 s to finish (well above any one + // webhook's 15 s budget and below the production drain deadline). + // The hard cap matches the hot-path "drop + observe" policy: + // graceful is best-effort, the hard stop is the floor. + handle_for_shutdown.graceful_shutdown(Some(Duration::from_secs(60))); + }); + + let acceptor = NodeLayAcceptor::new(RustlsAcceptor::new(rustls_config)); + axum_server::bind(addr) + .acceptor(acceptor) + .handle(handle) + .serve(app.into_make_service()) + .await +} diff --git a/crates/rutster/src/tls_acceptor.rs b/crates/rutster/src/tls_acceptor.rs new file mode 100644 index 0000000..e8791f4 --- /dev/null +++ b/crates/rutster/src/tls_acceptor.rs @@ -0,0 +1,76 @@ +//! # tls_acceptor — TCP_NODELAY parity for the TLS accept path +//! (deploy slice C §5.4) +//! +//! `axum_server::bind_rustls(addr, config)` exposes NO `tcp_nodelay` +//! setting — neither on the `Server` builder nor on `bind_rustls` +//! (verified in `axum-server 0.8/src/axum_server/server.rs:32-360` and +//! documented at ). +//! Plan A made TCP_NODELAY unconditional on the plaintext listener +//! (`axum::serve(...).tcp_nodelay(true)`); the same option must apply +//! to the TLS listen path, or a VPS-forwarder topology serving WSS +//! directly to Twilio would re-introduce the Nagle + delayed-ACK stalls +//! that the plaintext NODELAY closed (axum #2521 / #1961). +//! +//! The canonical pattern is a custom `Accept` impl — axum-server's own +//! `examples/rustls_session.rs` documents this wrapping shape, although +//! for session resumption rather than NODELAY. `Accept` +//! runs BEFORE the TLS handshake, so that is the moment to call +//! `stream.set_nodelay(true)`. The wrapper delegates everything else to +//! the inner `RustlsAcceptor`. + +use axum_server::accept::{Accept, DefaultAcceptor}; +use axum_server::tls_rustls::RustlsAcceptor; +use tokio::net::TcpStream; + +/// `Accept` wrapper over `RustlsAcceptor` that sets +/// `TCP_NODELAY=true` on every accepted socket before the TLS handshake. +/// +/// The generic parameter `A` is the *inner* acceptor wrapped by +/// `RustlsAcceptor`. `RustlsAcceptor` is the +/// concrete type produced by `RustlsAcceptor::new(config)`; keeping the +/// generic here matches axum-server's API and makes the newtype usable +/// with any inner acceptor if we later need to stack another layer. +/// +/// `Clone` is required by `axum_server::Server::serve`'s bounds +/// (`Acc: Accept<...> + Clone + Send + Sync + 'static`). +/// `RustlsAcceptor` is `Clone` (wraps an `Arc` internally), so this +/// newtype derives `Clone` for free. +#[derive(Clone)] +pub struct NodeLayAcceptor(RustlsAcceptor); + +impl NodeLayAcceptor { + /// Wrap a `RustlsAcceptor`. Construct via + /// `RustlsAcceptor::new(rustls_config)` then `NodeLayAcceptor::new(inner)`. + pub fn new(inner: RustlsAcceptor) -> Self { + Self(inner) + } +} + +/// `Accept` for the `TcpStream`-bound server path (the only path we +/// register this acceptor on — `axum_server::bind(SocketAddr)` whose +/// `Address::Stream = TcpStream`). +/// +/// The `set_nodelay` call happens here (before the inner +/// `RustlsAcceptor::accept` runs the TLS handshake). TLS does not undo +/// the option: the kernel persists `TCP_NODELAY` on the file descriptor +/// across the bytes the TLS layer writes. +impl Accept for NodeLayAcceptor +where + S: Clone + Send + Sync + 'static, + RustlsAcceptor: Accept, +{ + type Stream = as Accept>::Stream; + type Service = as Accept>::Service; + type Future = as Accept>::Future; + + fn accept(&self, stream: TcpStream, service: S) -> Self::Future { + // Parity with plan A's serve_with_nodelay (deploy spec §5.1). Best + // effort: if the option fails (rare — the kernel never rejects it), + // log + continue. Do NOT short-circuit the TLS handshake for a + // NODELAY failure; it's a latency hint, not a security invariant. + if let Err(e) = stream.set_nodelay(true) { + tracing::warn!(error = %e, "set_nodelay on TLS accept failed; continuing"); + } + self.0.accept(stream, service) + } +} diff --git a/crates/rutster/tests/serve_tls_nodelay.rs b/crates/rutster/tests/serve_tls_nodelay.rs new file mode 100644 index 0000000..1e563f6 --- /dev/null +++ b/crates/rutster/tests/serve_tls_nodelay.rs @@ -0,0 +1,186 @@ +//! Integration test for `rutster::serve_tls::serve_tls_with_nodelay` +//! (deploy slice C §5.4): the production TLS serve path binds, accepts a TLS +//! handshake (using a self-signed cert generated at test time), serves an +//! HTTP request, and stays up. +//! +//! TCP_NODELAY parity is implicit: `NodeLayAcceptor` sets it on every accepted +//! socket before the TLS handshake. A regression would surface instead in plan +//! A's sim-bench NODELAY assertion; this test only proves the TLS path is +//! functional. + +use std::io; +use std::sync::Arc; +use std::time::Duration; + +use axum_server::tls_rustls::RustlsConfig; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +/// A `ServerCertVerifier` that accepts any cert — test-only, for connecting +/// to a server presenting a self-signed cert outside the public trust store. +/// Do NOT use in production code paths (it would defeat TLS authentication). +#[derive(Debug)] +struct InsecureVerifier; + +impl ServerCertVerifier for InsecureVerifier { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::ED25519, + SignatureScheme::RSA_PSS_SHA256, + SignatureScheme::RSA_PKCS1_SHA384, + SignatureScheme::ECDSA_NISTP384_SHA384, + SignatureScheme::RSA_PSS_SHA384, + SignatureScheme::RSA_PKCS1_SHA512, + SignatureScheme::RSA_PSS_SHA512, + SignatureScheme::ECDSA_NISTP521_SHA512, + ] + } +} + +fn client_config() -> Arc { + Arc::new( + ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(InsecureVerifier)) + .with_no_client_auth(), + ) +} + +fn write_self_signed_cert( + cert_path: &std::path::Path, + key_path: &std::path::Path, + common_name: &str, +) { + let rcgen::CertifiedKey { cert, signing_key } = + rcgen::generate_simple_self_signed(vec![common_name.to_string()]).unwrap(); + std::fs::write(cert_path, cert.pem()).unwrap(); + std::fs::write(key_path, signing_key.serialize_pem()).unwrap(); +} + +/// Pick a currently-free IPv4 loopback address. +/// +/// We bind a throw-away listener, read the ephemeral port, and drop it so the +/// server can bind the same address. The race window is tiny and acceptable +/// for a single-threaded test; a failure would surface as a bind error from +/// the server task. +async fn free_ipv4_addr() -> io::Result { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + drop(listener); + Ok(addr) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn serve_tls_binds_and_serves_healthz() { + // rustls is compiled with both `aws-lc-rs` and `ring` features, so there + // is no single automatic process-level CryptoProvider. Install one up + // front; both the test client and axum-server's RustlsConfig need it. + rustls::crypto::CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider()) + .expect("install rustls crypto provider"); + + let dir = std::env::temp_dir().join(format!( + "rutster-serve-tls-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let cert_path = dir.join("cert.pem"); + let key_path = dir.join("key.pem"); + write_self_signed_cert(&cert_path, &key_path, "round-trip-test"); + + let config = RustlsConfig::from_pem_file(&cert_path, &key_path) + .await + .unwrap(); + + let app = rutster::routes::router(rutster::session_map::AppState::default()); + let addr = free_ipv4_addr().await.unwrap(); + + let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>(); + let mut server = tokio::spawn(rutster::serve_tls::serve_tls_with_nodelay( + addr, + config, + app, + async { + let _ = stop_rx.await; + }, + )); + + // Give the server a moment to bind + enter the accept loop. + tokio::time::sleep(Duration::from_millis(200)).await; + + // Poll the server task: it must NOT have exited (a bind failure would + // surface as Ok(()) or Err(...)) — Pending means it's running. + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut server) + .await + .is_err(), + "server task unexpectedly exited (bind failure?)" + ); + + // Dial with a tokio-rustls client using the test-only verifier. + let stream = TcpStream::connect(addr).await.unwrap(); + let connector = tokio_rustls::TlsConnector::from(client_config()); + let server_name = ServerName::try_from("round-trip-test").unwrap(); + let mut tls = connector.connect(server_name, stream).await.unwrap(); + + tls.write_all(b"GET /healthz HTTP/1.1\r\nHost: round-trip-test\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + + let mut buf = [0u8; 4096]; + let n = tls.read(&mut buf).await.unwrap(); + let response = String::from_utf8_lossy(&buf[..n]); + assert!( + response.contains("200 OK"), + "expected 200 OK from /healthz, got: {response}" + ); + + // The server should still be running after the request. + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut server) + .await + .is_err(), + "server task exited after serving the request" + ); + + let _ = stop_tx.send(()); + server.abort(); + let _ = std::fs::remove_dir_all(&dir); +}