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;