plecto_server/listener.rs
1//! The fast-path listener: bind, spawn the health supervisor + the HTTP/3 endpoint, and run the
2//! TCP accept loop. Each connection is HTTP/1.1, or HTTP/2 when TLS-ALPN negotiates `h2` (ADR
3//! 000015); the per-request handling (route → chain → forward) is shared with all transports via
4//! `proxy_core`. Graceful shutdown (ADR 000039 / 000059): when the caller's shutdown future
5//! resolves, `/readyz` flips not-ready and the readiness grace elapses (accepts continue, so a
6//! front LB can take the replica out of rotation first); then the accept loops stop and every
7//! connection is told to finish its in-flight work and close (HTTP/1.1 stops keep-alive, HTTP/2
8//! and HTTP/3 send GOAWAY), and connections still open at the drain window are cut.
9
10use std::future::Future;
11use std::net::SocketAddr;
12use std::sync::Arc;
13use std::time::Duration;
14
15use hyper::header::HeaderValue;
16use hyper::service::service_fn;
17use hyper_util::rt::{TokioExecutor, TokioIo};
18use plecto_control::Control;
19use tokio::net::TcpListener;
20use tokio::sync::{Semaphore, watch};
21use tokio::task::JoinSet;
22use tokio_rustls::TlsAcceptor;
23
24use crate::body::MAX_INFLIGHT_BODY_BUFFERS;
25use crate::dispatch::handle;
26use crate::error::ServerError;
27use crate::h3::{build_h3_endpoint, serve_h3};
28use crate::health::serve_health_checks;
29use crate::metrics::ServerMetrics;
30use crate::upstream_client::UpstreamClients;
31use crate::{MAX_CONCURRENT_STREAMS, MAX_CONNECTIONS, ServerState, admin};
32
33/// Explicit cap on inbound request header lines. hyper's http1 default (~100) is documented
34/// as not API-stable, so pin it — as `MAX_CONCURRENT_STREAMS` already does for h2.
35const MAX_HEADERS: usize = 100;
36/// How long a connection may take to send its request headers before it is dropped (slowloris on
37/// headers). hyper enforces a header-read timeout ONLY when a timer is configured, so the
38/// server sets both the timer and this value rather than relying on the (timer-less, inert) default.
39const INBOUND_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(15);
40
41/// HTTP/2 keep-alive ping cadence / response deadline: detects and closes connections whose peer
42/// is gone (dead NAT entry, vanished client) so they release their `MAX_CONNECTIONS` permits —
43/// h1 gets the same effect from `header_read_timeout` between requests; h3 from quinn's idle
44/// timeout. An idle-but-responsive h2 client keeps its connection (normal keep-alive behaviour).
45const H2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(20);
46const H2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(20);
47
48/// Default drain window for graceful shutdown (ADR 000039), used when `[listen.drain] window_ms`
49/// is not declared (ADR 000059): generous enough for normal in-flight requests (the default
50/// per-try upstream timeout is 30 s too) and aligned with the common 30 s termination grace of
51/// process supervisors.
52pub const DEFAULT_DRAIN_DEADLINE: Duration = Duration::from_secs(30);
53
54/// Serve the fast path on an already-bound `listener` until it errors unrecoverably. Each accepted
55/// connection is handled on its own task; the protocol is HTTP/1.1, or HTTP/2 when TLS-ALPN
56/// negotiates `h2` (ADR 000015). A per-connection error is logged, not fatal. Bind with
57/// `TcpListener::bind` (the caller picks the addr, so a test can use an ephemeral `127.0.0.1:0`
58/// and read `local_addr`).
59///
60/// Public boundary stays `anyhow::Result` (bp-rust: typed errors are a library-internal
61/// convention, not a public-API commitment) — the internal `ServerError` is `pub(crate)`, so a
62/// caller in another crate could not even name it. `serve_inner` does the typed work.
63pub async fn serve(control: Arc<Control>, listener: TcpListener) -> anyhow::Result<()> {
64 serve_inner(control, listener, std::future::pending::<()>())
65 .await
66 .map_err(Into::into)
67}
68
69/// Serve like [`serve`], but run the graceful-shutdown sequence when `shutdown` resolves
70/// (ADR 000039 / 000059): `/readyz` flips not-ready, accepts continue for the readiness grace
71/// (`[listen.drain] readiness_grace_ms`, default zero), then the drain starts and in-flight
72/// connections get the drain window (`[listen.drain] window_ms`, default 30 s) before the
73/// stragglers are cut.
74pub async fn serve_with_shutdown(
75 control: Arc<Control>,
76 listener: TcpListener,
77 shutdown: impl Future<Output = ()> + Send,
78) -> anyhow::Result<()> {
79 serve_inner(control, listener, shutdown)
80 .await
81 .map_err(Into::into)
82}
83
84async fn serve_inner(
85 control: Arc<Control>,
86 listener: TcpListener,
87 shutdown: impl Future<Output = ()> + Send,
88) -> Result<(), ServerError> {
89 let tcp_addr = listener.local_addr().map_err(ServerError::Bind)?;
90
91 // HTTP/3 (ADR 000016): when QUIC TLS is configured (i.e. there is `[[tls]]`), bind an
92 // independent QUIC/UDP listener on the SAME port number as the TCP one and advertise it via
93 // `Alt-Svc` on TCP responses. No TLS → no h3 (QUIC requires TLS), and no `Alt-Svc`. The
94 // advertised port is `[listen] advertised_port` when set (container port mapping — the
95 // PUBLISHED port, field report §3.4), else the bound port.
96 let quic_cfg = control.quic_tls_config();
97 let advertised_port = control.advertised_port().unwrap_or_else(|| tcp_addr.port());
98 let alt_svc = quic_cfg
99 .as_ref()
100 .and_then(|_| HeaderValue::from_str(&format!("h3=\":{advertised_port}\"; ma=86400")).ok());
101
102 // OTLP export wiring (ADR 000040): grab the buffer + endpoint before `control` moves into
103 // the state; the pump task itself is spawned below, once the drain channel exists.
104 let otlp_export = control
105 .otlp_endpoint()
106 .map(str::to_string)
107 .zip(control.otlp_buffer());
108
109 // The drain flag (ADR 000039): flipped to `true` exactly once, at shutdown. Every connection
110 // task holds a receiver and gracefully closes its connection when it flips; the h3 loops send
111 // GOAWAY and stop accepting; spawned upgrade tunnels (ADR 000048) close. `false` for the
112 // serving lifetime.
113 let (drain_tx, drain_rx) = watch::channel(false);
114 // The readiness flag (ADR 000059): flipped to `false` at the shutdown signal, BEFORE the
115 // drain — `/readyz` goes 503 while accepts continue, so a front LB can take the replica out
116 // of rotation during the readiness grace.
117 let (ready_tx, ready_rx) = watch::channel(true);
118
119 let state = Arc::new(ServerState {
120 control,
121 clients: UpstreamClients::new(),
122 alt_svc,
123 conn_limit: Arc::new(Semaphore::new(MAX_CONNECTIONS)),
124 body_buffer_limit: Arc::new(Semaphore::new(MAX_INFLIGHT_BODY_BUFFERS)),
125 metrics: Arc::new(ServerMetrics::new()),
126 otlp: otlp_export.as_ref().map(|(_, buffer)| buffer.clone()),
127 drain: drain_rx.clone(),
128 ready: ready_rx,
129 });
130
131 // Drain settings (ADR 000059), captured once like the rest of `[listen]` (a reload does not
132 // change them): one window shared by every drain path — TCP in-flight, h3 GOAWAY, tunnels.
133 let drain_window = state
134 .control
135 .drain_window()
136 .unwrap_or(DEFAULT_DRAIN_DEADLINE);
137 let readiness_grace = state.control.readiness_grace();
138
139 // Admin endpoint (Stage A observability, ADR 000009): a SEPARATE listener for `/metrics` +
140 // liveness/readiness, bound only when `[observability] admin_addr` is set. A bad address disables
141 // it (logged) without affecting the data plane — observability never fails serving closed.
142 if let Some(admin_addr) = state.control.admin_addr() {
143 match admin_addr.parse::<SocketAddr>() {
144 Ok(addr) => {
145 tokio::spawn(admin::serve_admin(state.clone(), addr, drain_rx.clone()));
146 }
147 Err(e) => {
148 tracing::error!(addr = admin_addr, error = %e, "invalid observability.admin_addr; admin endpoint disabled");
149 }
150 }
151 }
152
153 // Active health checks (ADR 000017): a background supervisor probes each upstream instance and
154 // flips its healthy/unhealthy state, so the round-robin in `proxy_core` only ever picks live
155 // instances. Spawned like the reload loop — the server owns the task, Control owns the state.
156 // Every supervisor observes the drain flag: in the binary the process exits anyway, but an
157 // embedder calling `serve_with_shutdown` must not be left with orphan tasks probing upstreams
158 // and holding `Control` alive after serve returns.
159 tokio::spawn(serve_health_checks(state.control.clone(), drain_rx.clone()));
160
161 // Periodic DNS re-resolution of hostname upstreams (`resolve_interval_ms`): a second
162 // supervisor beside the health checks, swapping each resolving group's endpoint set in place
163 // (nginx `resolve` / Envoy STRICT_DNS shape). Idles cheaply when no upstream opts in.
164 tokio::spawn(crate::dns::serve_dns_refresh(
165 state.control.clone(),
166 drain_rx.clone(),
167 ));
168
169 // OTLP export pump (ADR 000040): drains the span buffer to the collector. The handle is kept
170 // (unlike the fire-and-forget tasks above) so shutdown can await its final flush.
171 let otlp_pump = otlp_export.map(|(endpoint, buffer)| {
172 tokio::spawn(crate::otlp::serve_otlp_export(
173 buffer,
174 endpoint,
175 drain_rx.clone(),
176 ))
177 });
178
179 // The handle is kept (like `otlp_pump`) so shutdown can await the h3 drain — otherwise the
180 // closing CONNECTION_CLOSE flush can be cut by process exit and peers idle out instead of
181 // learning of the close.
182 let h3_task = quic_cfg.and_then(|cfg| match build_h3_endpoint(cfg, tcp_addr) {
183 Ok(endpoint) => {
184 tracing::info!(port = tcp_addr.port(), "HTTP/3 (QUIC) listener bound");
185 Some(tokio::spawn(serve_h3(
186 state.clone(),
187 endpoint,
188 drain_rx.clone(),
189 drain_window,
190 )))
191 }
192 // a QUIC bind failure must not take down the TCP fast path; log and serve TCP only.
193 Err(e) => {
194 tracing::error!(error = %e, "failed to bind HTTP/3 listener; serving TCP only");
195 None
196 }
197 });
198
199 // PROXY protocol v2 (ADR 000057): read once at startup, like the bind itself — `[listen]`
200 // is fixed for the process lifetime, a reload does not change it. `Some` = every trusted
201 // peer must present a v2 header (and only trusted peers may), `None` = feature off.
202 // `Arc` so the per-connection capture below is a refcount bump, not a CIDR-list clone.
203 let proxy_trust = state.control.proxy_protocol_trust().map(Arc::new);
204 if proxy_trust.is_some() {
205 tracing::info!(
206 "PROXY protocol v2 enabled on the TCP listener (the h3/UDP listener is not covered — ADR 000057)"
207 );
208 }
209
210 // The readiness contract (ADR 000059): the caller's shutdown signal first flips `/readyz`
211 // to not-ready, then — while the accept loops keep serving — waits the readiness grace so
212 // the front LB stops routing here, and only then lets the drain begin. Zero grace (the
213 // default) collapses to "not-ready and drain in the same instant".
214 let shutdown = async move {
215 shutdown.await;
216 let _ = ready_tx.send(false);
217 if !readiness_grace.is_zero() {
218 tracing::info!(
219 grace_ms = readiness_grace.as_millis() as u64,
220 "shutdown signal: /readyz is not-ready; accepting through the readiness grace"
221 );
222 tokio::time::sleep(readiness_grace).await;
223 }
224 };
225
226 // Connection tasks live in a JoinSet so the drain deadline can cut the stragglers
227 // (`abort_all`). Finished tasks are reaped opportunistically in the accept loop below.
228 let mut conns = JoinSet::new();
229 tokio::pin!(shutdown);
230 loop {
231 // Acquire a connection permit BEFORE accepting: at saturation we stop pulling
232 // connections off the backlog (backpressure) rather than spawning tasks without bound. The
233 // permit is moved into the connection task and released when it ends.
234 let permit = tokio::select! {
235 _ = &mut shutdown => break,
236 // reap finished connection tasks so the JoinSet does not grow unboundedly
237 Some(_) = conns.join_next() => continue,
238 permit = state.conn_limit.clone().acquire_owned() => match permit {
239 Ok(p) => p,
240 Err(_) => return Ok(()), // semaphore closed → stop serving
241 },
242 };
243 let (mut stream, peer) = tokio::select! {
244 _ = &mut shutdown => break,
245 Some(_) = conns.join_next() => continue, // the permit is re-acquired next iteration
246 accepted = listener.accept() => match accepted {
247 Ok(pair) => pair,
248 // a transient accept error (e.g. fd exhaustion) must not kill the listener.
249 Err(e) => {
250 tracing::warn!(error = %e, "accept failed");
251 continue;
252 }
253 },
254 };
255 // Disable Nagle downstream, symmetric with `upstream_connector`: a streamed response
256 // relayed to the client in several writes must not stall on the peer's delayed-ACK timer.
257 // A failure is survivable but operationally meaningful (Nagle staying on reproduces the
258 // documented ~40 ms p99 cliff), so it is logged instead of discarded.
259 if let Err(e) = stream.set_nodelay(true) {
260 tracing::debug!(error = %e, "set_nodelay failed; Nagle stays on for this connection");
261 }
262 let state = state.clone();
263 // The TLS config is read PER accept (ADR 000014): a reload's new certs apply to new
264 // connections, while in-flight ones keep the cert they negotiated with. `None` → plain.
265 let tls = state.control.tls_config();
266 let drain = drain_rx.clone();
267 let proxy_trust = proxy_trust.clone();
268 conns.spawn(async move {
269 let _permit = permit; // released when this connection task ends
270 // PROXY v2 (ADR 000057) sits below TLS: consume (trusted) or peek (untrusted) the
271 // header BEFORE the handshake, and let the restored peer replace `peer` for every
272 // downstream consumer (rate-limit key, X-Forwarded-*, Maglev SourceIp, access log).
273 // Any receipt-rule violation cuts the connection (fail-closed), with the fault code.
274 let peer = match &proxy_trust {
275 Some(trust) => {
276 match crate::proxy_protocol::resolve_peer(
277 &mut stream,
278 peer,
279 trust,
280 INBOUND_HEADER_READ_TIMEOUT,
281 )
282 .await
283 {
284 Ok(resolved) => resolved,
285 Err(fault) => {
286 tracing::warn!(peer = %peer, fault = %fault, "proxy-protocol: connection rejected");
287 return;
288 }
289 }
290 }
291 None => peer,
292 };
293 match tls {
294 // The handshake is bounded like the h1 header read (pre-TLS slowloris): a client
295 // that connects and never completes its ClientHello would otherwise hold this
296 // task — and its `MAX_CONNECTIONS` permit — forever.
297 Some(cfg) => match tokio::time::timeout(
298 INBOUND_HEADER_READ_TIMEOUT,
299 TlsAcceptor::from(cfg).accept(stream),
300 )
301 .await
302 {
303 Err(_elapsed) => {
304 tracing::debug!(peer = %peer, "TLS handshake timed out");
305 }
306 Ok(Ok(tls_stream)) => {
307 // ALPN picks the protocol: `h2` → HTTP/2, anything else (`http/1.1`, or no
308 // ALPN) → HTTP/1.1 (ADR 000015 — h2 over TLS+ALPN only). The connection
309 // terminated TLS, so the chain sees `https`.
310 let h2 = tls_stream.get_ref().1.alpn_protocol() == Some(b"h2".as_ref());
311 serve_conn(state, TokioIo::new(tls_stream), "https", h2, peer, drain).await;
312 }
313 // a failed TLS handshake (incl. ALPN mismatch) just drops the connection
314 // (fail-closed; nothing is forwarded), it is not a server error.
315 Ok(Err(e)) => tracing::debug!(error = %e, "TLS handshake failed"),
316 },
317 // plaintext: HTTP/1.1 only — no h2c / prior-knowledge (ADR 000015). `http` scheme.
318 None => serve_conn(state, TokioIo::new(stream), "http", false, peer, drain).await,
319 }
320 });
321 }
322
323 // Graceful shutdown (ADR 000039): stop accepting (drop the listener), flip the drain flag so
324 // every connection finishes its in-flight work and closes, then wait for ALL connection
325 // permits (TCP + QUIC share `conn_limit`) to come home — bounded by the drain deadline, after
326 // which the stragglers are cut.
327 drop(listener);
328 let _ = drain_tx.send(true);
329 let all_drained = state.conn_limit.acquire_many(MAX_CONNECTIONS as u32);
330 if tokio::time::timeout(drain_window, all_drained)
331 .await
332 .is_ok()
333 {
334 tracing::info!("graceful shutdown: all connections drained");
335 } else {
336 tracing::warn!(
337 deadline_ms = drain_window.as_millis() as u64,
338 "graceful shutdown: drain window expired; cutting remaining connections"
339 );
340 conns.abort_all();
341 }
342 // Await the h3 drain (bounded by its own window + a beat): serve_h3 closes its endpoint and
343 // flushes CONNECTION_CLOSE frames; returning before that flush completes would cut it in the
344 // binary (process exit) and leave h3 peers idling out instead of learning of the close.
345 if let Some(h3) = h3_task {
346 let _ = tokio::time::timeout(drain_window + Duration::from_secs(1), h3).await;
347 }
348 // Flush the OTLP queue before returning (the spec's Shutdown-includes-ForceFlush): the pump
349 // saw the same drain flip and is flushing under its own deadline; give it that long plus a
350 // beat, then move on — telemetry never holds the process open indefinitely.
351 if let Some(pump) = otlp_pump {
352 let _ = tokio::time::timeout(
353 crate::otlp::SHUTDOWN_FLUSH_DEADLINE + Duration::from_secs(1),
354 pump,
355 )
356 .await;
357 }
358 Ok(())
359}
360
361/// Resolve when the drain flag flips to `true` — or when the sender is gone (serve returned),
362/// which closes the same way. A helper rather than an inline `wait_for` in `select!` arms:
363/// `wait_for` yields a `watch::Ref` (a lock guard, not `Send`), and dropping it INSIDE this fn
364/// keeps the surrounding connection future `Send` (spawnable).
365pub(crate) async fn drained(drain: &mut watch::Receiver<bool>) {
366 let _ = drain.wait_for(|d| *d).await;
367}
368
369/// Serve one connection: HTTP/2 when `h2` (the ALPN result), HTTP/1.1 otherwise. `scheme` is the
370/// connection's wire scheme, passed through to the chain. Request handling (route → chain →
371/// forward) is identical across protocols; only the wire framing differs — for h2 the multiplexed
372/// streams each become one transaction, capped at `MAX_CONCURRENT_STREAMS` (ADR 000015). When the
373/// drain flag flips (ADR 000039), the connection finishes its in-flight requests and closes —
374/// hyper's `graceful_shutdown` disables HTTP/1.1 keep-alive (an idle connection closes at once)
375/// and sends the HTTP/2 GOAWAY.
376async fn serve_conn<I>(
377 state: Arc<ServerState>,
378 io: I,
379 scheme: &'static str,
380 h2: bool,
381 peer: SocketAddr,
382 mut drain: watch::Receiver<bool>,
383) where
384 I: hyper::rt::Read + hyper::rt::Write + Unpin + Send + 'static,
385{
386 let service = service_fn(move |req| handle(state.clone(), scheme, peer, req));
387 let result = if h2 {
388 let conn = hyper::server::conn::http2::Builder::new(TokioExecutor::new())
389 .max_concurrent_streams(MAX_CONCURRENT_STREAMS)
390 // Detect dead h2 peers (the h2 sibling of h1's `header_read_timeout`): without a
391 // ping-based keep-alive, a client that completes the preface and vanishes holds its
392 // connection permit until the process exits. hyper's keep-alive only runs with a
393 // timer configured, so the timer is set here exactly like the h1 branch below.
394 .timer(hyper_util::rt::TokioTimer::new())
395 .keep_alive_interval(Some(H2_KEEP_ALIVE_INTERVAL))
396 .keep_alive_timeout(H2_KEEP_ALIVE_TIMEOUT)
397 .serve_connection(io, service);
398 tokio::pin!(conn);
399 tokio::select! {
400 res = conn.as_mut() => res,
401 // `wait_for` also completes when the sender is gone (serve returned) — same close.
402 _ = drained(&mut drain) => {
403 conn.as_mut().graceful_shutdown();
404 conn.await
405 }
406 }
407 } else {
408 let conn = hyper::server::conn::http1::Builder::new()
409 // enforce a header-read timeout (slowloris on headers) and an explicit
410 // header-count cap. The header-read timeout only fires with a timer configured, so set
411 // both rather than relying on hyper's timer-less (inert) default.
412 .timer(hyper_util::rt::TokioTimer::new())
413 .header_read_timeout(INBOUND_HEADER_READ_TIMEOUT)
414 .max_headers(MAX_HEADERS)
415 .serve_connection(io, service)
416 // Upgrade support (ADR 000048): without this, the OnUpgrade a 101 fulfils would
417 // error and no tunnel could ever splice. h1 only — h2/h3 use (extended) CONNECT.
418 .with_upgrades();
419 tokio::pin!(conn);
420 tokio::select! {
421 res = conn.as_mut() => res,
422 _ = drained(&mut drain) => {
423 conn.as_mut().graceful_shutdown();
424 conn.await
425 }
426 }
427 };
428 if let Err(e) = result {
429 tracing::debug!(error = %e, "connection closed with error");
430 }
431}