Skip to main content

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}