Skip to main content

logicaffeine_system/http/
server.rs

1//! The tokio accept loop — the runtime under a LOGOS `Serve`.
2//!
3//! This module **is** the swappable transport boundary. Its public surface —
4//! [`Listener`] / [`Conn`] (low-level, for the interpreter's single-threaded
5//! re-entry) and [`serve`] / [`spawn`] (a convenience for a plain
6//! `Fn(HttpRequest) -> HttpResponse`) — is what a hyper-backed transport would
7//! re-provide identically, so swapping to hyper never touches the LOGOS handler
8//! bridge, `http.lg`, codegen, or the tests.
9//!
10//! Two entry shapes, because the two tiers re-enter LOGOS differently:
11//! - The **interpreter** is `!Send`: it drives [`Listener::accept`] +
12//!   [`Conn::read_request`] / [`Conn::write_response_conn`] as owned futures on
13//!   its own thread (no thread hop), calling back into the LOGOS handler
14//!   between `.await` points. It never uses [`serve`]/[`spawn`].
15//! - **AOT** compiles the handler to a plain `Fn(HttpRequest) -> HttpResponse`
16//!   (owned library types, `Send`), so it uses [`serve`] (block) or [`spawn`].
17//!
18//! Connection semantics (both tiers, so the cross-tier differential stays
19//! byte-identical): HTTP/1.1 **persistent connections** — requests are read in
20//! a loop per connection, the shared `request_wants_keep_alive` law decides the
21//! disposition, a parse error answers `400` and closes, EOF ends the
22//! connection, and at most [`MAX_INFLIGHT`] connections are in flight at once.
23//! A handler failure (a panic here; a runtime error on the interpreter tier)
24//! answers the shared [`internal_error`] `500` for THAT request and the server
25//! keeps serving.
26
27use std::collections::BTreeMap;
28use std::future::Future;
29use std::net::SocketAddr;
30use std::pin::Pin;
31use std::sync::{Arc, Mutex};
32use std::time::Duration;
33
34use tokio::io::{AsyncReadExt, AsyncWriteExt};
35use tokio::net::{TcpListener, TcpStream};
36
37use super::engine::{self, EngineKind, StaticResponses};
38use super::lifecycle::{service_unavailable, stamp_correlation_id, Admission, Lifecycle};
39// Re-exported here so the lifecycle-aware entry and its types share one path
40// (`http::server::{spawn_with_lifecycle, LifecycleConfig, DrainOutcome, …}`) —
41// the surface an embedder and the lifecycle lock use.
42pub use super::lifecycle::{DrainOutcome, LifecycleConfig};
43use super::wire::{
44    parse_request_consuming, request_wants_keep_alive, serialize_response_conn, HttpRequest,
45    HttpResponse, ParseError,
46};
47
48/// The engine-facing handler shape: compiled LOGOS handlers are plain `fn`s,
49/// so `Fn + Send + Sync` fits the AOT tier exactly. The interpreter tier's
50/// `!Send` handler never goes through this — it drives [`Listener`]/[`Conn`]
51/// inline on its own thread.
52type SharedHandler = dyn Fn(HttpRequest) -> HttpResponse + Send + Sync;
53
54/// A protocol-upgrade handler: takes over a connection's raw socket once the shared loop has
55/// completed the HTTP-level handshake (a `GET <path>` request carrying `Upgrade: websocket`,
56/// answered `101 Switching Protocols`) — the socket, plus any bytes already read into the
57/// connection's buffer past that request (almost always empty; a bodyless `GET` ends its own
58/// frame at the header block, but a pipelined first WS frame is possible and must not be
59/// dropped). Spawned as its own task by the loop, which then moves on to the next connection —
60/// the handler owns the socket for as long as it likes.
61pub type UpgradeHandler =
62    Arc<dyn Fn(TcpStream, Vec<u8>) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
63
64/// Every path this server answers as an upgrade, by its exact request path (no query string —
65/// same convention as every other route here). `Arc`-shared so the loop clones it once per
66/// connection task rather than once per request.
67pub(super) type Upgrades = Arc<BTreeMap<String, UpgradeHandler>>;
68
69fn no_upgrades() -> Upgrades {
70    Arc::new(BTreeMap::new())
71}
72
73/// Whether `req` asks to upgrade a path this server has registered — a `GET` naming one of
74/// `upgrades`' paths, with an `Upgrade: websocket` header (checked by VALUE, since only the
75/// header NAME is case-normalized on parse). Returns the matched handler, cloned.
76fn matching_upgrade(
77    upgrades: &BTreeMap<String, UpgradeHandler>,
78    req: &HttpRequest,
79) -> Option<UpgradeHandler> {
80    if req.method != "GET" {
81        return None;
82    }
83    if !req
84        .headers
85        .get("upgrade")
86        .is_some_and(|v| v.eq_ignore_ascii_case("websocket"))
87    {
88        return None;
89    }
90    upgrades.get(&req.path).cloned()
91}
92
93/// `Sec-WebSocket-Accept`, derived from the client's `Sec-WebSocket-Key` by RFC 6455's own
94/// algorithm — `tungstenite`'s implementation of it, already in the dependency tree behind the
95/// `relay` feature (the only upgrade this server has ever needed one for). Without that feature
96/// no request can ever complete an upgrade handshake, so [`matching_upgrade`]'s result is moot —
97/// this is what actually gates the behaviour, not a second check at the call site.
98#[cfg(feature = "relay")]
99fn websocket_accept_key(req: &HttpRequest) -> Option<String> {
100    let key = req.headers.get("sec-websocket-key")?;
101    Some(tokio_tungstenite::tungstenite::handshake::derive_accept_key(key.as_bytes()))
102}
103#[cfg(not(feature = "relay"))]
104fn websocket_accept_key(_req: &HttpRequest) -> Option<String> {
105    None
106}
107
108/// The RFC 6455 handshake decision for `req` against `upgrades`, in ONE place so every tier that
109/// drives its own connection loop (the tokio `serve_connection` below; the interpreter, which is
110/// `!Send` and never reaches `serve_connection` at all) reaches the exact same answer rather than
111/// hand-copying the accept-key derivation and the `101` response line a second time.
112///
113/// - [`None`]: `req` does not ask to upgrade a registered path at all (wrong method, no
114///   `Upgrade: websocket` header, or a path nothing is registered for) — proceed to the ordinary
115///   request path exactly as if `upgrades` were empty.
116/// - `Some(Ok((response, handler)))`: the handshake is complete — write `response` verbatim, then
117///   hand the raw connection to `handler`.
118/// - `Some(Err(()))`: the path/method/header matched a registered upgrade, but the handshake
119///   itself could not be completed (no `Sec-WebSocket-Key`, or the `relay` feature is not compiled
120///   in). The connection must be DROPPED WITH NO RESPONSE — never a fallback to the ordinary
121///   handler, and never a generic error page (see [`UpgradeHandler`]'s own doc comment: this is
122///   what actually gates the behaviour, not a second check at the call site).
123pub fn try_upgrade(
124    req: &HttpRequest,
125    upgrades: &BTreeMap<String, UpgradeHandler>,
126) -> Option<Result<(String, UpgradeHandler), ()>> {
127    let handler = matching_upgrade(upgrades, req)?;
128    match websocket_accept_key(req) {
129        Some(accept) => Some(Ok((
130            format!(
131                "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n"
132            ),
133            handler,
134        ))),
135        None => Some(Err(())),
136    }
137}
138
139/// The most connections either tier holds open at once. Accepting pauses at the
140/// cap (further connections wait in the OS backlog), so a flood of held-open
141/// sockets cannot exhaust memory.
142pub const MAX_INFLIGHT: usize = 64;
143
144/// The port a `Serve` statement actually binds: the `LOGOS_PORT` environment
145/// variable when set (falling back to `PORT`, the Fly/Heroku/Render
146/// convention), else the program's own port expression. ONE seam serving both
147/// the largo full-stack dev proxy (which runs the program as a child on a
148/// port it chose) and PaaS deployment — implemented here so the interpreter
149/// and AOT tiers cannot drift.
150pub fn env_port_override(program_port: u16) -> u16 {
151    port_override(
152        std::env::var("LOGOS_PORT").ok().as_deref(),
153        std::env::var("PORT").ok().as_deref(),
154    )
155    .unwrap_or(program_port)
156}
157
158/// Announce the address a `Serve` statement bound, on stdout and at once: `Serving on http://<bound>`.
159/// Every tier prints it from here, after the listener is bound and before the first request is read,
160/// so whoever started the server (the full-stack dev server, a test, an orchestrator) learns the real
161/// address, including the port the system chose for a requested port `0`. It is flushed because a
162/// server never returns: anything left in a buffer would never be seen.
163pub fn announce_serving(bound: SocketAddr) {
164    crate::io::println(format_args!("Serving on http://{bound}"));
165    crate::io::flush_stdout();
166}
167
168/// The whole address a `Serve` statement binds — host AND port, composed HERE
169/// so no tier can build one of its own.
170///
171/// ★ THE HOST HAD TO BECOME AN INPUT. Both tiers used to spell
172/// `format!("127.0.0.1:{}", env_port_override(port))` at their own bind site,
173/// which meant a `largo build --release` binary in a container, on Fly, Render
174/// or a k8s pod bound loopback and was **unreachable from outside its own
175/// network namespace** — while `docs/web.md` recommended exactly that
176/// deployment. Every test dialed `127.0.0.1` from the same host, so nothing
177/// caught it.
178///
179/// ★ AND WHY IT RETURNS THE WHOLE STRING rather than just the host: two callers
180/// composing "the host" with "the port" is two chances to compose it wrongly,
181/// and IPv6 is where that bites (see `bind_address`). One function answers
182/// the question both sites are actually asking.
183/// ★ IT TAKES THE PORT IT IS GIVEN. Reading `LOGOS_PORT` in here as well would apply
184/// the port override TWICE — harmless today because the environment dominates either
185/// way, and exactly the kind of hidden second application that stops being harmless the
186/// moment the rule gains a case. One function reads the host, one reads the port, and a
187/// call site shows both: `env_bind_address(env_port_override(8080))`.
188pub fn env_bind_address(port: u16) -> String {
189    bind_address(std::env::var("LOGOS_HOST").ok().as_deref(), port)
190}
191
192/// The pure law behind [`env_bind_address`]: `LOGOS_HOST` names the interface,
193/// defaulting to `127.0.0.1`.
194///
195/// The default is loopback on purpose — running a program must not turn a
196/// laptop into a public server. `LOGOS_HOST=0.0.0.0` is the one line that makes
197/// a container reachable, and it is a decision somebody takes rather than one
198/// they inherit.
199///
200/// An EMPTY (or blank) value means UNSET, because that is what an unset
201/// variable expands to in the shell scripts that set these
202/// (`LOGOS_HOST="$HOST"` with no `HOST`) — binding the empty host instead would
203/// fail with a message about an address nobody wrote.
204///
205/// ★ An IPv6 host is BRACKETED: `::` and a port joined by a colon is `:::8080`,
206/// which parses as no address at all, so a dual-stack deployment would fail to
207/// bind and blame the address rather than the missing brackets. A host the
208/// author already bracketed is taken as written.
209fn bind_address(host: Option<&str>, port: u16) -> String {
210    let host = host
211        .map(str::trim)
212        .filter(|h| !h.is_empty())
213        .unwrap_or("127.0.0.1");
214    if host.contains(':') && !host.starts_with('[') {
215        format!("[{host}]:{port}")
216    } else {
217        format!("{host}:{port}")
218    }
219}
220
221/// The pure law behind [`env_port_override`]: `LOGOS_PORT` wins over `PORT`;
222/// a value that is not a valid port is ignored (never a panic mid-serve).
223fn port_override(logos_port: Option<&str>, port: Option<&str>) -> Option<u16> {
224    logos_port
225        .and_then(|v| v.trim().parse::<u16>().ok())
226        .or_else(|| port.and_then(|v| v.trim().parse::<u16>().ok()))
227}
228
229/// Reading a request off a connection failed.
230#[derive(Debug)]
231pub enum ReadError {
232    /// The peer closed before (or between) requests — a normal end of stream.
233    Closed,
234    /// Socket I/O error.
235    Io(std::io::Error),
236    /// The bytes received were not a valid request; the server answers `400`.
237    Parse(ParseError),
238}
239
240/// A bound TCP listener. Resolve `127.0.0.1:0` to its real port via
241/// [`Listener::local_addr`] before dialing.
242pub struct Listener {
243    inner: TcpListener,
244}
245
246/// One accepted connection: the socket plus its receive buffer, which persists
247/// across requests — keep-alive drains exactly one request's frame per parse
248/// and keeps the remainder (a pipelined next request) buffered, and the
249/// allocation is reused for every request on the connection.
250pub struct Conn {
251    stream: TcpStream,
252    buf: Vec<u8>,
253}
254
255impl Listener {
256    /// Bind a TCP listener (e.g. `"127.0.0.1:0"` for an OS-assigned port).
257    pub async fn bind(addr: &str) -> std::io::Result<Listener> {
258        let inner = TcpListener::bind(addr).await?;
259        Ok(Listener { inner })
260    }
261
262    /// The actual bound address (resolves an ephemeral `:0` port).
263    pub fn local_addr(&self) -> std::io::Result<SocketAddr> {
264        self.inner.local_addr()
265    }
266
267    /// Accept the next connection.
268    pub async fn accept(&self) -> std::io::Result<Conn> {
269        let (stream, _peer) = self.inner.accept().await?;
270        Ok(Conn {
271            stream,
272            buf: Vec::with_capacity(1024),
273        })
274    }
275}
276
277impl Conn {
278    /// Read and parse the next complete request, growing the connection buffer
279    /// until the header block and `Content-Length` body have arrived (bounded by
280    /// the wire core's `MAX_HEADERS`/`MAX_BODY`). Consumes exactly the request's
281    /// frame from the buffer; pipelined bytes stay for the next call.
282    pub async fn read_request(&mut self) -> Result<HttpRequest, ReadError> {
283        let mut chunk = [0u8; 8192];
284        loop {
285            match parse_request_consuming(&self.buf) {
286                Ok((req, used)) => {
287                    self.buf.drain(..used);
288                    return Ok(req);
289                }
290                Err(ParseError::Incomplete) => {} // need more bytes
291                Err(e) => return Err(ReadError::Parse(e)),
292            }
293            let n = self.stream.read(&mut chunk).await.map_err(ReadError::Io)?;
294            if n == 0 {
295                // EOF: a clean close if nothing buffered, else a truncated request.
296                return if self.buf.is_empty() {
297                    Err(ReadError::Closed)
298                } else {
299                    Err(ReadError::Parse(ParseError::Incomplete))
300                };
301            }
302            self.buf.extend_from_slice(&chunk[..n]);
303        }
304    }
305
306    /// [`Conn::read_request`], but with a per-server `max_body` cap that is
307    /// TIGHTER than the wire core's global [`super::wire::MAX_BODY`]: the instant
308    /// the header block has arrived, the declared `Content-Length` is checked
309    /// against `max_body` and an over-limit request is rejected with
310    /// [`ParseError::BodyTooLarge`] BEFORE the promised body bytes are ever
311    /// buffered — a client cannot make the server hold megabytes for a body it
312    /// declared past the cap. Everything else is the identical parse.
313    pub async fn read_request_capped(&mut self, max_body: usize) -> Result<HttpRequest, ReadError> {
314        let mut chunk = [0u8; 8192];
315        loop {
316            // Once the header terminator is in the buffer, enforce the config
317            // body cap on the DECLARED length before waiting for the body.
318            if let Some(cl) = declared_content_length(&self.buf) {
319                if cl > max_body {
320                    return Err(ReadError::Parse(ParseError::BodyTooLarge));
321                }
322            }
323            match parse_request_consuming(&self.buf) {
324                Ok((req, used)) => {
325                    self.buf.drain(..used);
326                    return Ok(req);
327                }
328                Err(ParseError::Incomplete) => {} // need more bytes
329                Err(e) => return Err(ReadError::Parse(e)),
330            }
331            let n = self.stream.read(&mut chunk).await.map_err(ReadError::Io)?;
332            if n == 0 {
333                return if self.buf.is_empty() {
334                    Err(ReadError::Closed)
335                } else {
336                    Err(ReadError::Parse(ParseError::Incomplete))
337                };
338            }
339            self.buf.extend_from_slice(&chunk[..n]);
340        }
341    }
342
343    /// Serialize and write a one-shot (`Connection: close`) response, then flush.
344    pub async fn write_response(&mut self, resp: &HttpResponse) -> std::io::Result<()> {
345        self.write_response_conn(resp, false).await
346    }
347
348    /// Serialize and write a response with an explicit connection disposition
349    /// (`Connection: keep-alive` holds the connection for the next
350    /// [`Conn::read_request`]; `close` ends it), then flush.
351    pub async fn write_response_conn(
352        &mut self,
353        resp: &HttpResponse,
354        keep_alive: bool,
355    ) -> std::io::Result<()> {
356        let bytes = serialize_response_conn(resp, keep_alive);
357        self.write_bytes(&bytes).await
358    }
359
360    /// Write an already-rendered wire frame (the static-table fast path),
361    /// then flush.
362    async fn write_bytes(&mut self, bytes: &[u8]) -> std::io::Result<()> {
363        self.stream.write_all(bytes).await?;
364        self.stream.flush().await
365    }
366
367    /// Hand off this connection's raw socket, with any bytes already read into the connection
368    /// buffer past the request the loop just parsed — for a protocol upgrade that takes over
369    /// after an HTTP request this server already answered (see [`UpgradeHandler`]). Consumes the
370    /// connection; nothing else may read from or write to it afterward. Private: [`Conn::upgrade`]
371    /// is its one caller.
372    fn into_raw_parts(self) -> (TcpStream, Vec<u8>) {
373        (self.stream, self.buf)
374    }
375
376    /// Complete an upgrade [`try_upgrade`] accepted: write its `101` response, then hand the socket
377    /// — with whatever was read past the request — to its handler, spawned as its own task. The
378    /// one hand-off every connection loop makes, the tokio engine's and the interpreter's alike.
379    pub async fn upgrade(mut self, response: String, handler: UpgradeHandler) {
380        if self.write_bytes(response.as_bytes()).await.is_ok() {
381            let (stream, prefix) = self.into_raw_parts();
382            tokio::spawn(async move { (handler)(stream, prefix).await });
383        }
384    }
385}
386
387/// Peek the declared `Content-Length` (in bytes) from a raw request buffer,
388/// but only once the full header block (`\r\n\r\n`) has arrived — before that
389/// the value is not yet trustworthy (a header could still be in flight).
390/// Returns `None` when the header block is incomplete, absent, or the value
391/// does not parse; the caller then falls through to the ordinary parse (which
392/// answers `400` for a genuinely malformed length). Used by
393/// [`Conn::read_request_capped`] to enforce a config body cap on the DECLARED
394/// length before buffering the body.
395fn declared_content_length(buf: &[u8]) -> Option<usize> {
396    let end = buf.windows(4).position(|w| w == b"\r\n\r\n")?;
397    let head = std::str::from_utf8(&buf[..end]).ok()?;
398    for line in head.split("\r\n") {
399        if let Some((name, value)) = line.split_once(':') {
400            if name.trim().eq_ignore_ascii_case("content-length") {
401                return value.trim().parse::<usize>().ok();
402            }
403        }
404    }
405    None
406}
407
408/// A page document composed for one visitor of a server that answers CSRF tokens: `page` with the
409/// visitor's token in its head (`token` is the server's answer at its reserved token path), that
410/// answer's `Set-Cookie` set on it, and the whole never stored by a cache — the document is this
411/// visitor's alone. `None` when `token` is no token (a server with no `## Session` does not serve
412/// the path), and the page goes out as it was.
413pub fn page_with_visitor_token(page: &HttpResponse, token: &HttpResponse) -> Option<HttpResponse> {
414    if token.status != 200 {
415        return None;
416    }
417    let token_text = std::str::from_utf8(&token.body)
418        .ok()
419        .filter(|t| !t.is_empty())?;
420    let document = std::str::from_utf8(&page.body).ok()?;
421    let validator = |name: &str| {
422        [
423            "etag",
424            "cache-control",
425            "vary",
426            "last-modified",
427            "content-length",
428        ]
429        .iter()
430        .any(|v| name.eq_ignore_ascii_case(v))
431    };
432    let mut headers: BTreeMap<String, String> = page
433        .headers
434        .iter()
435        .filter(|(name, _)| !validator(name))
436        .map(|(k, v)| (k.clone(), v.clone()))
437        .collect();
438    headers.insert("Cache-Control".to_string(), "no-store".to_string());
439    if let Some((_, cookie)) = token
440        .headers
441        .iter()
442        .find(|(name, _)| name.eq_ignore_ascii_case("set-cookie"))
443    {
444        headers.insert("Set-Cookie".to_string(), cookie.clone());
445    }
446    Some(HttpResponse {
447        status: page.status,
448        headers,
449        body: crate::csrf::with_csrf_meta(document, token_text).into_bytes(),
450    })
451}
452
453/// The canned `413` a request gets when its declared body or header block is
454/// larger than the server's cap — a DEFINED "too large" refusal (distinct from
455/// the malformed `400`) so a client can tell the two apart. Shared by both
456/// tiers. `text/plain`, body `Payload Too Large`.
457pub fn payload_too_large() -> HttpResponse {
458    let mut headers = BTreeMap::new();
459    headers.insert("Content-Type".to_string(), "text/plain".to_string());
460    HttpResponse {
461        status: 413,
462        headers,
463        body: b"Payload Too Large".to_vec(),
464    }
465}
466
467/// Map a request read error to the response the server writes: an oversized
468/// BODY (a declared `Content-Length` past the cap) is a defined `413` (never
469/// buffered / never dispatched) so a client can tell "too big" from
470/// "malformed". Everything else — including an oversized HEADER block, whose
471/// head the server cannot even parse — is the malformed `400` (the shape the
472/// cross-tier `http_differential` lock pins). THE shared read-error law both
473/// the tokio and io_uring engines apply so their refusals are identical.
474pub fn read_error_response(e: &ParseError) -> HttpResponse {
475    match e {
476        ParseError::BodyTooLarge => payload_too_large(),
477        _ => bad_request(),
478    }
479}
480
481/// The canned `400` a connection gets when its bytes don't parse — shared by
482/// both tiers so the differential stays byte-identical.
483pub fn bad_request() -> HttpResponse {
484    HttpResponse {
485        status: 400,
486        headers: BTreeMap::new(),
487        body: b"Bad Request".to_vec(),
488    }
489}
490
491/// The canned `500` a request gets when its handler fails (a runtime error on
492/// the interpreter tier, a panic on the AOT tier) — shared by both tiers. The
493/// failure detail is deliberately NOT on the wire (it goes to stderr): status
494/// 500, `text/plain`, body `internal error`.
495pub fn internal_error() -> HttpResponse {
496    let mut headers = BTreeMap::new();
497    headers.insert("Content-Type".to_string(), "text/plain".to_string());
498    HttpResponse {
499        status: 500,
500        headers,
501        body: b"internal error".to_vec(),
502    }
503}
504
505/// Serve on an already-bound [`Listener`], looping forever — the TOKIO engine.
506/// Connections run CONCURRENTLY (one task each, capped at [`MAX_INFLIGHT`])
507/// with HTTP/1.1 keep-alive, so a stalled or held-open connection never blocks
508/// the rest; handler calls are serialized on a mutex (one at a time, matching
509/// the single-threaded interpreter tier). A handler panic answers the shared
510/// [`internal_error`] `500` for that request and the server keeps serving. A
511/// parse error answers `400` and closes; a closed/errored socket just ends
512/// that connection.
513///
514/// A pre-bound tokio listener always serves on the tokio engine (the io_uring
515/// engine binds its own `SO_REUSEPORT` shard listeners); engine selection
516/// lives in [`serve`]/[`serve_with_statics`]. If `LOGOS_HTTP_ENGINE=uring` is
517/// set anyway, this says so on stderr rather than silently ignoring the knob.
518pub async fn serve_on<F>(listener: Listener, handler: F) -> std::io::Result<()>
519where
520    F: FnMut(HttpRequest) -> HttpResponse + Send + 'static,
521{
522    serve_on_with(listener, handler, Vec::new()).await
523}
524
525/// [`serve_on`] with a table of protocol-upgrade paths (see [`UpgradeHandler`]) — a `GET` to one
526/// of them, asking to upgrade, is answered `101` and handed off instead of reaching `handler`.
527/// Every other request is unaffected, so `serve_on(l, h)` is exactly `serve_on_with(l, h, [])`.
528pub async fn serve_on_with<F>(
529    listener: Listener,
530    handler: F,
531    upgrades: Vec<(&str, UpgradeHandler)>,
532) -> std::io::Result<()>
533where
534    F: FnMut(HttpRequest) -> HttpResponse + Send + 'static,
535{
536    let asked_for_uring = std::env::var("LOGOS_HTTP_ENGINE")
537        .map(|v| v.trim().eq_ignore_ascii_case("uring"))
538        .unwrap_or(false);
539    if asked_for_uring {
540        eprintln!(
541            "logos-http: serve_on: engine=tokio (LOGOS_HTTP_ENGINE=uring, but a pre-bound \
542             listener always serves on the tokio engine — use serve() for the io_uring path)"
543        );
544    }
545    let upgrades: Upgrades = Arc::new(
546        upgrades
547            .into_iter()
548            .map(|(p, h)| (p.to_string(), h))
549            .collect(),
550    );
551    serve_loop(
552        listener,
553        serialize_handler(handler),
554        None,
555        ready_serve_lifecycle(),
556        upgrades,
557    )
558    .await
559}
560
561/// [`serve_on`] with a SHARED handler (`Fn + Send + Sync`) — calls run CONCURRENTLY across
562/// connection tasks instead of queueing on one mutex.
563///
564/// ★ WHY BOTH EXIST. [`serve_on`] serializes deliberately: a LOGOS handler on the interpreter tier
565/// is `FnMut` and single-threaded, and running two turns at once there would not be the same
566/// program. A handler that is already `Sync` has no such constraint, and serializing it anyway
567/// makes every SLOW request a stop-the-world event.
568///
569/// That is not hypothetical — it is what the dev server did. `largo serve` answers its own static
570/// assets, its pages and the `/__gen` reload poll from the same handler that PROXIES to the backend
571/// with a multi-second timeout, so one hung route stalled the CSS, the wasm and the reload poll
572/// behind it: the page could not even refresh to tell you something was wrong.
573pub async fn serve_on_shared<F>(listener: Listener, handler: F) -> std::io::Result<()>
574where
575    F: Fn(HttpRequest) -> HttpResponse + Send + Sync + 'static,
576{
577    serve_on_shared_with(listener, handler, Vec::new()).await
578}
579
580/// [`serve_on_shared`] with a table of protocol-upgrade paths (see [`UpgradeHandler`]) — a `GET`
581/// to one of them, asking to upgrade, is answered `101` and handed off instead of reaching
582/// `handler`. Every other request goes through the ordinary request/response path unchanged, so
583/// `serve_on_shared(l, h)` is exactly `serve_on_shared_with(l, h, [])`.
584pub async fn serve_on_shared_with<F>(
585    listener: Listener,
586    handler: F,
587    upgrades: Vec<(&str, UpgradeHandler)>,
588) -> std::io::Result<()>
589where
590    F: Fn(HttpRequest) -> HttpResponse + Send + Sync + 'static,
591{
592    let asked_for_uring = std::env::var("LOGOS_HTTP_ENGINE")
593        .map(|v| v.trim().eq_ignore_ascii_case("uring"))
594        .unwrap_or(false);
595    if asked_for_uring {
596        eprintln!(
597            "logos-http: serve_on_shared: engine=tokio (LOGOS_HTTP_ENGINE=uring, but a pre-bound \
598             listener always serves on the tokio engine — use serve() for the io_uring path)"
599        );
600    }
601    let upgrades: Upgrades = Arc::new(
602        upgrades
603            .into_iter()
604            .map(|(p, h)| (p.to_string(), h))
605            .collect(),
606    );
607    serve_loop(
608        listener,
609        Arc::new(handler),
610        None,
611        ready_serve_lifecycle(),
612        upgrades,
613    )
614    .await
615}
616
617/// The lifecycle the plain (non-orchestrated) serve entries run under: the full
618/// production lifecycle (readiness, backpressure, timeout, drain) but with the
619/// correlation-id header OFF by default (so the cross-tier byte differential is
620/// unmoved), started READY (there is no orchestrator on this path to flip it).
621///
622/// The config comes from [`LifecycleConfig::from_env`], which is the seam that
623/// gives every tier the same lifecycle without any tier knowing about it: the
624/// compiled `Serve` statement lowers to a call that reaches this function, so a
625/// deployed binary honors the lifecycle environment names with no change to code
626/// generation at all — exactly what `env_port_override` already does for the port.
627fn ready_serve_lifecycle() -> Lifecycle {
628    let lifecycle = Lifecycle::new(LifecycleConfig::from_env());
629    lifecycle.set_ready(true);
630    lifecycle
631}
632
633/// Wrap an exclusive (`FnMut`) handler into the shared engine contract: calls
634/// are serialized on a mutex — one at a time, matching the single-threaded
635/// interpreter tier. A panic inside a call unwinds through the guard (the
636/// engines' [`crate::failure::caught`] turns it into the shared `500`); `into_inner` clears
637/// the resulting poison on the next request, so one panic never wedges the
638/// handler.
639fn serialize_handler<F>(handler: F) -> Arc<SharedHandler>
640where
641    F: FnMut(HttpRequest) -> HttpResponse + Send + 'static,
642{
643    let handler = Mutex::new(handler);
644    Arc::new(move |req| {
645        let mut h = handler.lock().unwrap_or_else(|p| p.into_inner());
646        (*h)(req)
647    })
648}
649
650/// The tokio accept loop shared by every tokio-engine entry — now
651/// LIFECYCLE-AWARE: per-connection tasks, keep-alive, the static-table fast
652/// path, panic-to-500, 400-and-close, PLUS readiness gating (`503` when not
653/// ready), bounded backpressure (`503` promptly when over `max_inflight` —
654/// never a hang), a per-request time bound (`503` past `request_timeout`, slot
655/// freed), a correlation-id stamp (when the config enables it), and graceful
656/// drain (the loop `select!`s on the drain signal so drain actually stops it).
657///
658/// The lifecycle decisions come from the shared [`Lifecycle`] handle so the
659/// interpreter tier applies the identical law.
660async fn serve_loop(
661    listener: Listener,
662    handler: Arc<SharedHandler>,
663    statics: Option<Arc<StaticResponses>>,
664    lifecycle: Lifecycle,
665    upgrades: Upgrades,
666) -> std::io::Result<()> {
667    let drain = lifecycle.drain_signal();
668    loop {
669        // Defense in depth: if drain already began, stop before blocking in
670        // accept — closes the between-iterations window even if the edge below
671        // is missed (belt to the `notify_one` permit's suspenders).
672        if lifecycle.is_draining() {
673            break Ok(());
674        }
675        // Stop accepting the instant drain begins — otherwise wait for the next
676        // connection. `select!` races accept against the DEDICATED drain signal
677        // (pinged only by `drain`, so no spurious wakes) so a graceful drain
678        // actually halts the loop rather than being ignored.
679        let conn = tokio::select! {
680            biased;
681            _ = drain.notified() => break Ok(()),
682            // A stop request is a GRACEFUL one: stop advertising readiness so a
683            // load balancer moves traffic away, then let the in-flight work finish
684            // inside the drain deadline before the loop ends.
685            _ = super::lifecycle::shutdown_requested() => {
686                lifecycle.set_ready(false);
687                let deadline = lifecycle.config().drain_timeout;
688                lifecycle.drain(deadline).await;
689                break Ok(());
690            }
691            accepted = listener.accept() => match accepted {
692                Ok(c) => c,
693                // A transient accept error (e.g. fd exhaustion under a flood)
694                // ends THIS iteration, never the loop — the server stays up.
695                Err(_) => continue,
696            },
697        };
698        if lifecycle.is_draining() {
699            // A connection that slipped in as drain flipped: refuse it cleanly.
700            drop(conn);
701            continue;
702        }
703        let handler = Arc::clone(&handler);
704        let statics = statics.clone();
705        let lifecycle = lifecycle.clone();
706        let upgrades = Arc::clone(&upgrades);
707        tokio::spawn(async move {
708            serve_connection(conn, handler, statics, lifecycle, upgrades).await;
709        });
710    }
711}
712
713/// One connection's request loop: keep-alive, the static fast path, and the
714/// per-request lifecycle (admission, timeout, correlation-id). Extracted so the
715/// accept loop reads as pure orchestration.
716async fn serve_connection(
717    mut conn: Conn,
718    handler: Arc<SharedHandler>,
719    statics: Option<Arc<StaticResponses>>,
720    lifecycle: Lifecycle,
721    upgrades: Upgrades,
722) {
723    let max_body = lifecycle.config().max_body;
724    let read_deadline = lifecycle.config().read_deadline;
725    loop {
726        // Read the next request under the slow-header / slow-body deadline: a
727        // client that connects then stalls (a partial head, or a dribbled body)
728        // is DROPPED when `read_deadline` elapses rather than pinning this
729        // connection forever. A completed read proceeds as before.
730        let read =
731            match tokio::time::timeout(read_deadline, conn.read_request_capped(max_body)).await {
732                Ok(r) => r,
733                // Deadline: the peer stalled mid-request. Drop the connection (no
734                // response — a slowloris gets nothing) so the slot/fd is reclaimed.
735                Err(_) => break,
736            };
737        match read {
738            Ok(req) => {
739                // ★ AN UPGRADE ENDS THE HTTP LOOP FOR THIS CONNECTION, NOT THE ANSWER TO IT. Once
740                // the handshake response is written, the socket belongs to `upgrade` alone — going
741                // on to `handle_request`, or even reading another request off `conn`, would race
742                // the upgrade handler for the same bytes. Checked before the static fast path and
743                // readiness/backpressure: a registered path is answered on its own terms, not
744                // shed under load the way an ordinary GET would be (a dropped realtime channel is
745                // worse than a slow one). The decision itself lives in [`try_upgrade`], shared with
746                // every other tier that drives its own connection loop.
747                match try_upgrade(&req, &upgrades) {
748                    Some(Ok((response, upgrade))) => {
749                        conn.upgrade(response, upgrade).await;
750                        return;
751                    }
752                    Some(Err(())) => return,
753                    None => {}
754                }
755                let keep_alive = request_wants_keep_alive(&req);
756                // The memcpy fast path: a pre-serialized frame answers before
757                // the handler (or the lifecycle) is ever consulted. Static
758                // assets are always-available content — not gated by readiness.
759                if !lifecycle.config().stamp_correlation_id {
760                    if let Some(frame) = statics
761                        .as_ref()
762                        .and_then(|s| s.wire_frame(&req, keep_alive))
763                    {
764                        if conn.write_bytes(frame).await.is_err() || !keep_alive {
765                            break;
766                        }
767                        continue;
768                    }
769                }
770                let (resp, keep) =
771                    handle_request(&handler, &statics, &lifecycle, req, keep_alive).await;
772                if conn.write_response_conn(&resp, keep).await.is_err() || !keep {
773                    break;
774                }
775            }
776            Err(ReadError::Parse(e)) => {
777                // An oversized body/header block is a defined 413; any other
778                // parse failure is the malformed 400. Either way the connection
779                // closes (the peer sent something it cannot recover from).
780                let _ = conn
781                    .write_response_conn(&read_error_response(&e), false)
782                    .await;
783                break;
784            }
785            Err(ReadError::Closed) | Err(ReadError::Io(_)) => break,
786        }
787    }
788    // `conn` drops here → socket closes.
789}
790
791/// Apply the per-request lifecycle to one parsed request and produce the
792/// response to write (and the connection disposition). THE shared decision path
793/// both the connection loop here and the interpreter loop mirror: readiness /
794/// backpressure shed to `503`, a static frame when the correlation-id policy is
795/// off already handled the fast path, dispatch under a `request_timeout` with
796/// the handler on the blocking pool (so a synchronous handler can't starve the
797/// reactor and the timeout can actually fire), panic → `500`, and — when the
798/// config enables it — the correlation-id stamp.
799async fn handle_request(
800    handler: &Arc<SharedHandler>,
801    _statics: &Option<Arc<StaticResponses>>,
802    lifecycle: &Lifecycle,
803    req: HttpRequest,
804    keep_alive: bool,
805) -> (HttpResponse, bool) {
806    let started = std::time::Instant::now();
807    let client_cid = req.headers.get("x-correlation-id").cloned();
808    let stamp = lifecycle.config().stamp_correlation_id;
809    // Captured before `req` moves into the handler, so the access log carries
810    // method/path WITHOUT ever retaining an arbitrary request header VALUE.
811    let method = req.method.clone();
812    let path = req.path.clone();
813
814    // A configured probe path answers BEFORE admission, so a monitor asking
815    // whether this process is alive gets an answer during an overload rather than
816    // the overload itself. Nothing is reserved unless a path was named, so by
817    // default this never fires and the handler owns every route.
818    if let Some(mut resp) = lifecycle.probe_response(&path) {
819        let cid = lifecycle.correlation_id(client_cid.as_deref());
820        if stamp {
821            stamp_correlation_id(&mut resp, &cid);
822        }
823        access_log(lifecycle, &method, &path, resp.status, &cid, started);
824        return (resp, keep_alive);
825    }
826
827    // Admission: readiness / drain / backpressure. A shed request never reaches
828    // the handler and never occupies a slot beyond the try_acquire.
829    let guard = match lifecycle.admit() {
830        Admission::Admit(g) => g,
831        Admission::NotReady | Admission::Overloaded => {
832            let mut resp = service_unavailable();
833            let cid = lifecycle.correlation_id(client_cid.as_deref());
834            if stamp {
835                stamp_correlation_id(&mut resp, &cid);
836            }
837            access_log(lifecycle, &method, &path, resp.status, &cid, started);
838            // A shed request always closes: no keep-alive on a `503` (the peer
839            // should back off, and the connection carried no slot).
840            return (resp, false);
841        }
842    };
843
844    // The correlation id is decided up front so it is stable across the timeout
845    // and the assigned-id path (a no-id request must still get an id even on a
846    // timeout/panic response). It is always computed (for the access log), but
847    // only stamped on the wire when the config enables it.
848    let cid = lifecycle.correlation_id(client_cid.as_deref());
849
850    let h = Arc::clone(handler);
851    let timeout = lifecycle.config().request_timeout;
852    // Run the (possibly blocking) handler on the blocking pool so the timeout
853    // can fire even if the handler parks synchronously — a synchronous handler
854    // can never starve the reactor, and a slow one can never wedge a slot.
855    let call = tokio::task::spawn_blocking(move || {
856        crate::failure::caught(std::panic::AssertUnwindSafe(|| h(req)))
857    });
858    let out = tokio::time::timeout(timeout, call).await;
859    // The slot frees here regardless of outcome; a timed-out handler keeps
860    // running detached but no longer holds a slot (bounded, never wedged).
861    drop(guard);
862    lifecycle.notify_slot_freed();
863    let mut resp = match out {
864        Ok(Ok(Ok(resp))) => resp,           // handler returned
865        Ok(Ok(Err(_))) => internal_error(), // handler panicked → 500
866        Ok(Err(_)) => internal_error(),     // JoinError (blocking task failed)
867        Err(_) => service_unavailable(),    // deadline elapsed → 503, slot freed
868    };
869
870    // Response-body cap: a handler that produced a body past the server's
871    // `max_response_body` is refused with a defined `500` — the monstrous body
872    // is never serialized onto the wire, defending server and peer memory.
873    if resp.body.len() > lifecycle.config().max_response_body {
874        resp = internal_error();
875    }
876
877    if stamp {
878        stamp_correlation_id(&mut resp, &cid);
879    }
880    access_log(lifecycle, &method, &path, resp.status, &cid, started);
881    (resp, keep_alive)
882}
883
884/// Emit the structured, secret-safe access line for a request — opt-in via
885/// `LOGOS_HTTP_ACCESS_LOG` (any value) so it never spams the default path or
886/// the byte-differential (the line is stderr, never the wire). The line carries
887/// method/path/status/correlation-id/duration and STRUCTURALLY never an
888/// arbitrary request header value.
889fn access_log(
890    lifecycle: &Lifecycle,
891    method: &str,
892    path: &str,
893    status: u16,
894    cid: &str,
895    started: std::time::Instant,
896) {
897    let _ = lifecycle;
898    if std::env::var_os("LOGOS_HTTP_ACCESS_LOG").is_some() {
899        eprintln!(
900            "{}",
901            super::lifecycle::access_log_line(method, path, status, cid, started.elapsed())
902        );
903    }
904}
905
906/// Bind `addr` and serve forever — THE AOT entry, and the engine-selection
907/// seam: `LOGOS_HTTP_ENGINE=uring|tokio` picks the engine, defaulting to the
908/// Linux thread-per-core io_uring engine wherever a probe ring initializes
909/// (else tokio), always announced with one stderr line. Handler calls are
910/// serialized either way (the `FnMut` contract); [`serve_with_statics`] is the
911/// shared-handler entry that also carries the static-response fast path.
912pub async fn serve<F>(addr: &str, handler: F) -> std::io::Result<()>
913where
914    F: FnMut(HttpRequest) -> HttpResponse + Send + 'static,
915{
916    serve_shared(addr, serialize_handler(handler), None, no_upgrades()).await
917}
918
919/// [`serve`] with a table of protocol-upgrade paths (see [`UpgradeHandler`]) — a `GET` to one of
920/// them carrying `Upgrade: websocket` is handed to its handler once the handshake is answered, on
921/// either engine. A compiled backend whose pages talk to the relay registers `/_relay` here
922/// (`relay::RelayHub::upgrades`).
923pub async fn serve_with_upgrades<F>(
924    addr: &str,
925    handler: F,
926    upgrades: Vec<(&str, UpgradeHandler)>,
927) -> std::io::Result<()>
928where
929    F: FnMut(HttpRequest) -> HttpResponse + Send + 'static,
930{
931    let upgrades: Upgrades = Arc::new(
932        upgrades
933            .into_iter()
934            .map(|(p, h)| (p.to_string(), h))
935            .collect(),
936    );
937    serve_shared(addr, serialize_handler(handler), None, upgrades).await
938}
939
940/// [`serve`] for a handler that cannot leave the thread it was made on — one that holds the
941/// program's own bindings, whose collections are not `Send`. The handler runs on the thread that
942/// awaits this future, one request at a time, exactly as [`serve`] serializes its calls; the engine
943/// runs on a thread and runtime of its own and hands each request across, so the caller's runtime
944/// may be of any flavor. A panic inside a call answers the engines' shared `500`.
945pub async fn serve_here<F>(addr: &str, handler: F) -> std::io::Result<()>
946where
947    F: FnMut(HttpRequest) -> HttpResponse,
948{
949    serve_here_with(addr.to_string(), handler, no_upgrades()).await
950}
951
952/// [`serve_here`] with a table of protocol-upgrade paths, as [`serve_with_upgrades`].
953pub async fn serve_here_with_upgrades<F>(
954    addr: &str,
955    handler: F,
956    upgrades: Vec<(&str, UpgradeHandler)>,
957) -> std::io::Result<()>
958where
959    F: FnMut(HttpRequest) -> HttpResponse,
960{
961    let upgrades: Upgrades = Arc::new(
962        upgrades
963            .into_iter()
964            .map(|(p, h)| (p.to_string(), h))
965            .collect(),
966    );
967    serve_here_with(addr.to_string(), handler, upgrades).await
968}
969
970async fn serve_here_with<F>(addr: String, mut handler: F, upgrades: Upgrades) -> std::io::Result<()>
971where
972    F: FnMut(HttpRequest) -> HttpResponse,
973{
974    type Reply = std::sync::mpsc::SyncSender<HttpResponse>;
975    let (requests, mut inbox) = tokio::sync::mpsc::unbounded_channel::<(HttpRequest, Reply)>();
976    let forward: Arc<SharedHandler> = Arc::new(move |req| {
977        let (reply, answer) = std::sync::mpsc::sync_channel(1);
978        if requests.send((req, reply)).is_err() {
979            return internal_error();
980        }
981        answer.recv().unwrap_or_else(|_| internal_error())
982    });
983    let (finished, outcome) = tokio::sync::oneshot::channel();
984    std::thread::Builder::new()
985        .name("logos-http".to_string())
986        .spawn(move || {
987            let served = tokio::runtime::Builder::new_multi_thread()
988                .enable_all()
989                .build()
990                .and_then(|engine| engine.block_on(serve_shared(&addr, forward, None, upgrades)));
991            let _ = finished.send(served);
992        })?;
993    // The engine thread holds the only sender, so the inbox closes when the engine stops serving.
994    while let Some((req, reply)) = inbox.recv().await {
995        let answer = crate::failure::caught(std::panic::AssertUnwindSafe(|| handler(req)))
996            .unwrap_or_else(|_| internal_error());
997        let _ = reply.send(answer);
998    }
999    outcome.await.unwrap_or_else(|_| {
1000        Err(std::io::Error::other(
1001            "the server thread ended without an outcome",
1002        ))
1003    })
1004}
1005
1006/// Bind `addr` and serve forever with a SHARED handler (`Fn + Send + Sync` —
1007/// the compiled-handler contract; calls may run concurrently across shards or
1008/// connection tasks) and an optional pre-serialized [`StaticResponses`] table
1009/// both engines consult before dispatching. Engine selection as in [`serve`].
1010pub async fn serve_with_statics<F>(
1011    addr: &str,
1012    handler: F,
1013    statics: Option<Arc<StaticResponses>>,
1014) -> std::io::Result<()>
1015where
1016    F: Fn(HttpRequest) -> HttpResponse + Send + Sync + 'static,
1017{
1018    serve_shared(addr, Arc::new(handler), statics, no_upgrades()).await
1019}
1020
1021/// The one selection seam behind [`serve`]/[`serve_with_statics`]/[`serve_with_upgrades`]:
1022/// choose, announce, run — and if the chosen io_uring engine fails to BIND (distinct from the
1023/// probe), fall back to tokio with a second loud line, never a silent downgrade.
1024async fn serve_shared(
1025    addr: &str,
1026    handler: Arc<SharedHandler>,
1027    statics: Option<Arc<StaticResponses>>,
1028    upgrades: Upgrades,
1029) -> std::io::Result<()> {
1030    let choice = engine::choose_engine();
1031    engine::announce("serve", &choice);
1032    if matches!(choice.kind, EngineKind::Uring) {
1033        #[cfg(all(target_os = "linux", feature = "io-uring"))]
1034        {
1035            match super::engine_uring::UringServer::bind_with_upgrades(
1036                addr,
1037                super::engine_uring::EngineConfig::from_env(),
1038                Arc::clone(&handler),
1039                statics.clone(),
1040                Arc::clone(&upgrades),
1041            ) {
1042                Ok(server) => {
1043                    announce_serving(server.local_addr());
1044                    // Serving never returns; the engine lives as long as this
1045                    // future is polled (i.e. forever).
1046                    let _server = server;
1047                    std::future::pending::<()>().await;
1048                    unreachable!("a pending future never resolves");
1049                }
1050                Err(e) => {
1051                    eprintln!(
1052                        "logos-http: serve: engine=tokio (io_uring engine failed to bind: {e})"
1053                    );
1054                }
1055            }
1056        }
1057        #[cfg(not(all(target_os = "linux", feature = "io-uring")))]
1058        unreachable!("choose_engine only selects uring where the engine is compiled in");
1059    }
1060    let listener = Listener::bind(addr).await?;
1061    announce_serving(listener.local_addr()?);
1062    serve_loop(
1063        listener,
1064        handler,
1065        statics,
1066        ready_serve_lifecycle(),
1067        upgrades,
1068    )
1069    .await
1070}
1071
1072/// A running server spawned onto the tokio runtime. Dropping the handle aborts
1073/// the accept loop (mirrors `relay::RelayServer`). The handler must be `Send`;
1074/// the interpreter's `!Send` handler uses [`Listener`]/[`Conn`] directly instead.
1075///
1076/// The handle also carries the [`Lifecycle`], so an embedder can flip readiness
1077/// ([`HttpServer::set_ready`]) and drain gracefully ([`HttpServer::drain`])
1078/// exactly as an orchestrator's SIGTERM handler would.
1079pub struct HttpServer {
1080    addr: SocketAddr,
1081    task: tokio::task::JoinHandle<()>,
1082    lifecycle: Lifecycle,
1083}
1084
1085impl HttpServer {
1086    /// The bound address (resolve `:0` to its real port for clients).
1087    pub fn addr(&self) -> SocketAddr {
1088        self.addr
1089    }
1090    /// The `http://…` base URL a client dials.
1091    pub fn url(&self) -> String {
1092        format!("http://{}", self.addr)
1093    }
1094
1095    /// Flip the readiness gate: `false` sheds new requests with `503` (a load
1096    /// balancer stops routing), `true` serves them — independent of liveness
1097    /// (the accept loop stays up either way).
1098    pub fn set_ready(&self, ready: bool) {
1099        self.lifecycle.set_ready(ready);
1100    }
1101
1102    /// Whether the server is currently serving (ready and not draining).
1103    pub fn is_ready(&self) -> bool {
1104        self.lifecycle.is_ready()
1105    }
1106
1107    /// Gracefully drain: stop accepting new connections (new requests shed with
1108    /// `503`), wait up to `deadline` for in-flight requests to finish, and
1109    /// return the still-running count as [`DrainOutcome::dropped`] (`0` = every
1110    /// in-flight request completed). Never hangs past `deadline`.
1111    pub async fn drain(&self, deadline: Duration) -> DrainOutcome {
1112        self.lifecycle.drain(deadline).await
1113    }
1114
1115    /// The shared lifecycle handle (for advanced embedders).
1116    pub fn lifecycle(&self) -> &Lifecycle {
1117        &self.lifecycle
1118    }
1119}
1120
1121impl Drop for HttpServer {
1122    fn drop(&mut self) {
1123        self.task.abort();
1124    }
1125}
1126
1127/// Bind `addr`, spawn the accept loop onto the tokio runtime, and return a handle
1128/// carrying the bound port. For tests and embedders (the handler is `Send`).
1129/// Always the TOKIO engine: the handle's drop-aborts contract needs a task,
1130/// not OS threads — engine selection belongs to the blocking [`serve`] entry
1131/// (the io_uring engine's own handle is `engine_uring::UringServer`).
1132pub async fn spawn<F>(addr: &str, handler: F) -> std::io::Result<HttpServer>
1133where
1134    F: FnMut(HttpRequest) -> HttpResponse + Send + 'static,
1135{
1136    let listener = Listener::bind(addr).await?;
1137    let addr = listener.local_addr()?;
1138    let handler = serialize_handler(handler);
1139    // The plain spawn keeps the historical wire shape (no correlation-id header,
1140    // started ready) so existing tests and the cross-tier differential are
1141    // unperturbed — the full lifecycle (backpressure/timeout/drain) still runs.
1142    let lifecycle = ready_serve_lifecycle();
1143    let loop_lifecycle = lifecycle.clone();
1144    let task = tokio::spawn(async move {
1145        let _ = serve_loop(listener, handler, None, loop_lifecycle, no_upgrades()).await;
1146    });
1147    Ok(HttpServer {
1148        addr,
1149        task,
1150        lifecycle,
1151    })
1152}
1153
1154/// Bind `addr`, spawn the LIFECYCLE-AWARE accept loop, and return a handle that
1155/// exposes the full production lifecycle: [`HttpServer::set_ready`],
1156/// [`HttpServer::drain`], and — because `config.stamp_correlation_id` defaults
1157/// on — an `X-Correlation-Id` on every response. THE entry an embedder (and the
1158/// AOT `Serve` lowering, via a config seam) uses for a server that "stays
1159/// serving": graceful drain, readiness gating, bounded backpressure, per-request
1160/// time bounds, malformed-flood survival, and correlation ids.
1161///
1162/// The server starts READY (accepting traffic the moment it is bound, the
1163/// deployed default); an orchestrator that wants a warm-up window flips it with
1164/// `set_ready(false)` up front and back on once dependencies are wired.
1165///
1166/// Always the tokio engine: the drain/readiness handle needs a task, not the
1167/// io_uring engine's OS threads (whose blocking `serve` entry has no handle to
1168/// flip readiness on anyway — see the module docs).
1169pub async fn spawn_with_lifecycle<F>(
1170    addr: &str,
1171    handler: F,
1172    config: LifecycleConfig,
1173) -> std::io::Result<HttpServer>
1174where
1175    F: Fn(HttpRequest) -> HttpResponse + Send + Sync + 'static,
1176{
1177    let listener = Listener::bind(addr).await?;
1178    let addr = listener.local_addr()?;
1179    let handler: Arc<SharedHandler> = Arc::new(handler);
1180    let lifecycle = Lifecycle::new(config);
1181    lifecycle.set_ready(true);
1182    let loop_lifecycle = lifecycle.clone();
1183    let task = tokio::spawn(async move {
1184        let _ = serve_loop(listener, handler, None, loop_lifecycle, no_upgrades()).await;
1185    });
1186    Ok(HttpServer {
1187        addr,
1188        task,
1189        lifecycle,
1190    })
1191}
1192
1193#[cfg(test)]
1194#[path = "../../../logicaffeine_tests/unit/logicaffeine_system/http/server/tests.rs"]
1195mod tests;