feat(serve_tls): NodeLayAcceptor + serve_tls_with_nodelay (deploy-C §5.4)

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<TcpStream, S> 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 <himself@adlee.work>
This commit is contained in:
2026-07-06 01:26:19 -04:00
parent 5d14215053
commit 55065c569d
7 changed files with 472 additions and 0 deletions

105
Cargo.lock generated
View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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`: <https://docs.rs/axum-server/0.8.0/axum_server/handle/struct.Handle.html>
//! - axum-server `RustlsConfig`: <https://docs.rs/axum-server/0.8.0/axum_server/tls_rustls/struct.RustlsConfig.html>
//! - axum-server rustls example: <https://github.com/programatik29/axum-server/blob/v0.8.0/examples/rustls_session.rs>
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<F>(
addr: SocketAddr,
rustls_config: RustlsConfig,
app: Router,
shutdown: F,
) -> std::io::Result<()>
where
F: Future<Output = ()> + 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
}

View File

@@ -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 <https://docs.rs/axum-server/0.8.0/axum_server/>).
//! 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<TcpStream, S>`
//! 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<TcpStream, S>` 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<A = DefaultAcceptor>` 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<A = DefaultAcceptor>(RustlsAcceptor<A>);
impl<A> NodeLayAcceptor<A> {
/// Wrap a `RustlsAcceptor`. Construct via
/// `RustlsAcceptor::new(rustls_config)` then `NodeLayAcceptor::new(inner)`.
pub fn new(inner: RustlsAcceptor<A>) -> 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<A, S> Accept<TcpStream, S> for NodeLayAcceptor<A>
where
S: Clone + Send + Sync + 'static,
RustlsAcceptor<A>: Accept<TcpStream, S>,
{
type Stream = <RustlsAcceptor<A> as Accept<TcpStream, S>>::Stream;
type Service = <RustlsAcceptor<A> as Accept<TcpStream, S>>::Service;
type Future = <RustlsAcceptor<A> as Accept<TcpStream, S>>::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)
}
}

View File

@@ -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<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
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<ClientConfig> {
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<std::net::SocketAddr> {
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);
}