Skip to main content

logicaffeine_system/http/
lifecycle.rs

1//! The production server LIFECYCLE — the shared state machine that makes a
2//! LOGOS `Serve` "serious and stays serving": it drains gracefully (never drops
3//! an in-flight request), gates readiness separately from liveness, sheds load
4//! with bounded backpressure (503, never an unbounded hang), bounds per-request
5//! time, survives malformed floods, and stamps every response/log line with a
6//! correlation id (secret-safe).
7//!
8//! Built ONCE here so BOTH serving tiers inherit the identical, proven
9//! behavior: the compiled-AOT tier through [`super::server::serve`] /
10//! [`super::server::spawn`] / [`super::server::spawn_with_lifecycle`], and the
11//! interpreter tier (`largo serve`) by calling the SAME pure decision functions
12//! ([`Lifecycle::admit`], [`Lifecycle::assign_correlation_id`],
13//! [`stamp_correlation_id`]) from its own `!Send` accept loop.
14//!
15//! The decisions are factored as small pure/`&self` methods on [`Lifecycle`] so
16//! the two loops — the tokio loop (Send, task-per-connection) and the
17//! interpreter's `FuturesUnordered` loop (`!Send`, inline) — share the exact
18//! same admission/backpressure/correlation-id law and can never drift.
19
20use std::collections::BTreeMap;
21use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
22use std::sync::Arc;
23use std::time::Duration;
24
25use tokio::sync::{OwnedSemaphorePermit, Semaphore};
26
27use super::wire::{HttpResponse, MAX_BODY};
28
29/// Tunables for the server lifecycle. Every field is public so an embedder can
30/// dial them; [`Default`] is the deployed shape.
31#[derive(Debug, Clone)]
32pub struct LifecycleConfig {
33    /// The most requests whose handlers may execute CONCURRENTLY. A request
34    /// arriving while the slots are full is shed with `503` immediately
35    /// (bounded backpressure — never queued into an unbounded hang).
36    pub max_inflight: usize,
37    /// The wall-clock budget for a single handler call. A handler that runs
38    /// past it yields `503` and frees the slot, so a slow handler cannot wedge
39    /// a connection (or a slot) forever.
40    pub request_timeout: Duration,
41    /// The wall-clock budget for READING one request off a connection (the
42    /// slow-header / slow-body deadline). A client that connects and then stalls
43    /// — sending a partial head or dribbling a body — is dropped when this
44    /// elapses rather than held forever (the slowloris defense: a stalled peer
45    /// can never pin a connection or a slot).
46    pub read_deadline: Duration,
47    /// How long a graceful [`Lifecycle::drain`] waits for in-flight requests to
48    /// finish before returning and reporting the still-running count as
49    /// force-closed.
50    pub drain_timeout: Duration,
51    /// The largest request body accepted (defends memory; passed through to the
52    /// wire parser's cap).
53    pub max_body: usize,
54    /// The largest RESPONSE body the server will ship. A handler that produces a
55    /// body past this cap is refused with a defined `500` (the giant body is
56    /// never serialized onto the wire), defending server and peer memory against
57    /// a handler that computes an unbounded response.
58    pub max_response_body: usize,
59    /// Whether every response carries an `X-Correlation-Id` (echoing the
60    /// client's, else a server-assigned one). ON for the lifecycle-aware
61    /// [`super::server::spawn_with_lifecycle`] entry; OFF for the plain
62    /// [`super::server::serve`]/[`super::server::spawn`] entries so the
63    /// cross-tier byte-for-byte differential is unperturbed (two independent
64    /// processes would assign different ids — that is the ONE thing a
65    /// byte-identical differential cannot tolerate).
66    pub stamp_correlation_id: bool,
67    /// The path that answers a READINESS probe, when one is configured.
68    ///
69    /// `None` — the default — reserves nothing, so a handler owns every path it
70    /// declares and the cross-tier byte-for-byte differential is unperturbed. Name
71    /// a path (`LOGOS_READY_PATH=/readyz`) and the server answers it BEFORE
72    /// admission: `200` while ready, `503` once draining or not yet ready. Opting
73    /// in is what makes a deployment's probe work; not opting in is what keeps the
74    /// language from quietly owning two of your routes.
75    pub ready_path: Option<String>,
76    /// The path that answers a LIVENESS probe, when one is configured. `200` for as
77    /// long as the process is answering at all — deliberately NOT gated on
78    /// readiness, because a draining server is alive and must not be killed for
79    /// shedding. `None` by default, for the same reason as [`Self::ready_path`].
80    pub health_path: Option<String>,
81}
82
83impl Default for LifecycleConfig {
84    fn default() -> Self {
85        LifecycleConfig {
86            max_inflight: 1024,
87            request_timeout: Duration::from_secs(30),
88            read_deadline: Duration::from_secs(30),
89            drain_timeout: Duration::from_secs(25),
90            max_body: MAX_BODY,
91            max_response_body: MAX_BODY,
92            stamp_correlation_id: true,
93            ready_path: None,
94            health_path: None,
95        }
96    }
97}
98
99impl LifecycleConfig {
100    /// The config the plain [`super::server::serve`]/[`super::server::spawn`]
101    /// entries use: the full lifecycle (readiness, backpressure, per-request
102    /// timeout, drain) EXCEPT the correlation-id header, which is gated off so
103    /// the cross-tier byte-for-byte differential stays byte-identical.
104    ///
105    /// The plain entries start READY (no orchestrator flips readiness), and use
106    /// the historical connection bound [`super::server::MAX_INFLIGHT`] so their
107    /// behavior is unchanged from before the lifecycle existed.
108    pub fn serve_default() -> Self {
109        LifecycleConfig {
110            max_inflight: super::server::MAX_INFLIGHT,
111            stamp_correlation_id: false,
112            ..LifecycleConfig::default()
113        }
114    }
115
116    /// [`Self::serve_default`] with any knob the environment names layered over it.
117    ///
118    /// This is the seam that gives EVERY tier the same lifecycle without any tier
119    /// knowing about it. The compiled `Serve` statement lowers to a call that routes
120    /// through the plain server entry, so sourcing the config here means a deployed
121    /// binary honors these names with no change to code generation at all — exactly
122    /// the trick the port override already plays. The interpreted and bytecode
123    /// servers read the same function, so the three tiers cannot drift.
124    ///
125    /// Every name is optional and an unparseable value is ignored rather than
126    /// fatal: a typo in a deployment's environment must not stop a server booting.
127    pub fn from_env() -> Self {
128        Self::from_env_with(|k| std::env::var(k).ok())
129    }
130
131    /// [`Self::from_env`] against an arbitrary lookup — the pure law, so it can be
132    /// tested without touching the process environment.
133    pub fn from_env_with(get: impl Fn(&str) -> Option<String>) -> Self {
134        let mut c = Self::serve_default();
135        let num = |k: &str| get(k).and_then(|v| v.trim().parse::<u64>().ok());
136        if let Some(v) = num("LOGOS_MAX_INFLIGHT").filter(|v| *v > 0) {
137            c.max_inflight = v as usize;
138        }
139        if let Some(v) = num("LOGOS_REQUEST_TIMEOUT_MS") {
140            c.request_timeout = Duration::from_millis(v);
141        }
142        if let Some(v) = num("LOGOS_READ_DEADLINE_MS") {
143            c.read_deadline = Duration::from_millis(v);
144        }
145        if let Some(v) = num("LOGOS_DRAIN_MS") {
146            c.drain_timeout = Duration::from_millis(v);
147        }
148        if let Some(v) = num("LOGOS_MAX_BODY").filter(|v| *v > 0) {
149            c.max_body = v as usize;
150        }
151        if let Some(v) = get("LOGOS_CORRELATION_ID") {
152            c.stamp_correlation_id = truthy(&v);
153        }
154        c.ready_path = get("LOGOS_READY_PATH")
155            .map(|p| p.trim().to_string())
156            .filter(|p| p.starts_with('/'));
157        c.health_path = get("LOGOS_HEALTH_PATH")
158            .map(|p| p.trim().to_string())
159            .filter(|p| p.starts_with('/'));
160        c
161    }
162}
163
164/// The environment's idea of yes. Anything else — including an empty value — is no,
165/// so `LOGOS_CORRELATION_ID=` cannot accidentally turn stamping on.
166fn truthy(v: &str) -> bool {
167    matches!(
168        v.trim().to_ascii_lowercase().as_str(),
169        "1" | "true" | "yes" | "on"
170    )
171}
172
173/// The verdict of [`Lifecycle::admit`]: whether a request may reach the handler,
174/// and if not, WHY — so both tiers answer the identical status.
175pub enum Admission {
176    /// The request may run; hold this slot permit until the handler returns
177    /// (dropping it frees the slot and decrements the in-flight count).
178    Admit(SlotGuard),
179    /// The server is draining or not yet ready — shed with `503`.
180    NotReady,
181    /// All [`LifecycleConfig::max_inflight`] slots are busy — shed with `503`
182    /// (bounded backpressure), promptly, never a queue.
183    Overloaded,
184}
185
186/// A held in-flight slot: while it lives, one of the `max_inflight` slots is
187/// occupied and the in-flight gauge counts it. Dropping it (handler returned,
188/// panicked, or timed out) frees the slot — RAII, so no path can leak one.
189pub struct SlotGuard {
190    _permit: OwnedSemaphorePermit,
191}
192
193/// The outcome of a graceful [`Lifecycle::drain`].
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub struct DrainOutcome {
196    /// In-flight requests still running when the drain deadline elapsed —
197    /// force-closed. `0` means every in-flight request finished gracefully.
198    pub dropped: usize,
199}
200
201/// The shared, cloneable lifecycle handle. One is created per server; the
202/// accept loop consults it per connection/request and an orchestrator flips
203/// readiness / requests drain through it.
204#[derive(Clone)]
205pub struct Lifecycle {
206    inner: Arc<LifecycleState>,
207}
208
209struct LifecycleState {
210    config: LifecycleConfig,
211    /// Readiness gate (liveness = the loop is up; readiness is separate).
212    ready: AtomicBool,
213    /// Set once drain begins: the accept loop stops taking new connections and
214    /// new requests are shed with `503`.
215    draining: AtomicBool,
216    /// The `max_inflight` concurrency budget. A permit == one in-flight handler.
217    slots: Arc<Semaphore>,
218    /// Pinged exactly once when drain begins — the accept loop `select!`s on it
219    /// to stop. Kept SEPARATE from `slot_signal` so a slot-free wake can never
220    /// be mistaken for a drain (nor a drain wake lost to a slot-free re-loop).
221    drain_signal: Arc<tokio::sync::Notify>,
222    /// Pinged when a slot frees, so the drain waiter re-checks the in-flight
223    /// count promptly rather than polling.
224    slot_signal: Arc<tokio::sync::Notify>,
225    /// Monotonic id source for server-assigned correlation ids; the per-process
226    /// nonce disambiguates ids across restarts/processes without a random draw
227    /// on the hot path.
228    corr_counter: AtomicU64,
229    corr_nonce: u64,
230}
231
232/// The response header that carries the correlation id (canonical casing).
233pub const CORRELATION_HEADER: &str = "X-Correlation-Id";
234
235impl Lifecycle {
236    /// Create a lifecycle from `config`. The server starts NOT ready by default
237    /// — an orchestrator calls [`Lifecycle::set_ready`] once dependencies are
238    /// wired — EXCEPT the plain-serve config, whose readiness the entry sets to
239    /// `true` up front (there is no orchestrator on that path).
240    pub fn new(config: LifecycleConfig) -> Lifecycle {
241        let slots = Arc::new(Semaphore::new(config.max_inflight));
242        Lifecycle {
243            inner: Arc::new(LifecycleState {
244                config,
245                ready: AtomicBool::new(false),
246                draining: AtomicBool::new(false),
247                slots,
248                drain_signal: Arc::new(tokio::sync::Notify::new()),
249                slot_signal: Arc::new(tokio::sync::Notify::new()),
250                corr_counter: AtomicU64::new(0),
251                corr_nonce: process_nonce(),
252            }),
253        }
254    }
255
256    /// The config this lifecycle runs under (for the accept loop's timeout /
257    /// body cap / correlation-id policy).
258    pub fn config(&self) -> &LifecycleConfig {
259        &self.inner.config
260    }
261
262    /// Flip the readiness gate. `false` → new requests are shed with `503`
263    /// (load balancers stop routing); `true` → traffic is served. Independent
264    /// of liveness (the loop stays up either way).
265    pub fn set_ready(&self, ready: bool) {
266        self.inner.ready.store(ready, Ordering::SeqCst);
267    }
268
269    /// Whether the server is currently serving traffic (ready and not draining).
270    pub fn is_ready(&self) -> bool {
271        self.inner.ready.load(Ordering::SeqCst) && !self.is_draining()
272    }
273
274    /// Whether a graceful drain is in progress.
275    pub fn is_draining(&self) -> bool {
276        self.inner.draining.load(Ordering::SeqCst)
277    }
278
279    /// THE admission decision — the one law both tiers apply per request:
280    /// - draining or not ready → [`Admission::NotReady`] (`503`, no dispatch);
281    /// - all slots busy → [`Admission::Overloaded`] (`503`, promptly, no queue);
282    /// - otherwise → [`Admission::Admit`] holding a slot for the handler's run.
283    ///
284    /// `try_acquire` (never `.await`) is the backpressure keystone: admission
285    /// never blocks the accept loop, so an over-capacity flood is shed, not
286    /// buffered into a hang.
287    pub fn admit(&self) -> Admission {
288        if !self.is_ready() {
289            return Admission::NotReady;
290        }
291        match Arc::clone(&self.inner.slots).try_acquire_owned() {
292            Ok(permit) => Admission::Admit(SlotGuard { _permit: permit }),
293            Err(_) => Admission::Overloaded,
294        }
295    }
296
297    /// How many in-flight slots are currently occupied (a handler is running).
298    pub fn inflight(&self) -> usize {
299        self.inner.config.max_inflight - self.inner.slots.available_permits()
300    }
301
302    /// The correlation id for a response: the client's `X-Correlation-Id`
303    /// (verbatim, when present and non-empty) else a freshly assigned one. Two
304    /// concurrent no-id requests get DISTINCT ids (the counter is atomic).
305    pub fn correlation_id(&self, client_supplied: Option<&str>) -> String {
306        match client_supplied {
307            Some(v) if !v.trim().is_empty() => v.to_string(),
308            _ => self.assign_correlation_id(),
309        }
310    }
311
312    /// Assign a fresh, unique correlation id (monotonic counter + per-process
313    /// nonce) — cheap and non-blocking, no RNG on the hot path.
314    pub fn assign_correlation_id(&self) -> String {
315        let n = self.inner.corr_counter.fetch_add(1, Ordering::Relaxed);
316        format!("{:016x}-{:x}", self.inner.corr_nonce, n)
317    }
318
319    /// The signal the accept loop `select!`s on so drain can stop it. Pinged
320    /// exactly once by [`Lifecycle::drain`]; combined with the `is_draining`
321    /// flag check in the loop, a stop can never be missed.
322    pub fn drain_signal(&self) -> Arc<tokio::sync::Notify> {
323        Arc::clone(&self.inner.drain_signal)
324    }
325
326    /// Call when a slot frees (a `SlotGuard` dropped) so a pending drain waiter
327    /// re-checks the in-flight count promptly rather than polling.
328    pub fn notify_slot_freed(&self) {
329        self.inner.slot_signal.notify_waiters();
330    }
331
332    /// Begin a GRACEFUL drain and wait up to `deadline` for in-flight requests
333    /// to finish. Flips the draining flag (the accept loop stops taking new
334    /// connections; new requests are shed with `503`), then awaits in-flight →
335    /// zero OR the deadline, returning the still-running count at the deadline
336    /// as [`DrainOutcome::dropped`]. Never hangs past `deadline`.
337    pub async fn drain(&self, deadline: Duration) -> DrainOutcome {
338        self.inner.draining.store(true, Ordering::SeqCst);
339        // Wake the accept loop out of its `select!` so it stops accepting.
340        // `notify_one` (not `notify_waiters`) STORES a permit when no waiter is
341        // currently registered, so a drain edge that races the loop's brief
342        // between-iterations window is caught by its next `notified()` poll — a
343        // drain stop can never be lost.
344        self.inner.drain_signal.notify_one();
345
346        let start = std::time::Instant::now();
347        loop {
348            if self.inflight() == 0 {
349                return DrainOutcome { dropped: 0 };
350            }
351            let remaining = match deadline.checked_sub(start.elapsed()) {
352                Some(r) if !r.is_zero() => r,
353                _ => {
354                    return DrainOutcome {
355                        dropped: self.inflight(),
356                    }
357                }
358            };
359            // Wake either when a slot frees (progress) or when the deadline
360            // slice elapses (re-check). A missed notification is bounded by the
361            // slice, so we never wait past the deadline.
362            let slice = remaining.min(Duration::from_millis(20));
363            let _ = tokio::time::timeout(slice, self.inner.slot_signal.notified()).await;
364        }
365    }
366}
367
368/// Stamp `resp` with the correlation-id header (canonical casing), IN PLACE.
369/// Shared by both tiers so the header they emit is identical. A handler-set
370/// `X-Correlation-Id` (any casing) is replaced — the lifecycle owns this header
371/// exactly as the wire writer owns the framing headers.
372///
373/// The value is sanitized (CR/LF/NUL stripped) as defense in depth: the wire
374/// parser already rejects those bytes in a parsed request header, but a
375/// correlation id built by any other caller can never split the response here.
376pub fn stamp_correlation_id(resp: &mut HttpResponse, id: &str) {
377    let clean: String = id
378        .chars()
379        .filter(|&c| !matches!(c, '\r' | '\n' | '\0'))
380        .collect();
381    resp.headers
382        .retain(|k, _| !k.eq_ignore_ascii_case(CORRELATION_HEADER));
383    resp.headers.insert(CORRELATION_HEADER.to_string(), clean);
384}
385
386/// The canned `503` a request gets when the server is not ready, draining, or
387/// Resolve when the process is asked to stop: `SIGTERM` or `SIGINT` on unix,
388/// Ctrl-C elsewhere.
389///
390/// Deliberately a FUTURE the accept loop selects on rather than a spawned task.
391/// Two of the three servers that need it — the tree-walker's and the bytecode
392/// VM's — drive `!Send` connection futures inline on a current-thread runtime and
393/// cannot spawn anything; handing them a future is what lets all three share one
394/// shutdown story instead of growing three.
395///
396/// If the signal handlers cannot be installed (a sandbox that forbids it), this
397/// never resolves, which is the right failure: the server keeps serving rather
398/// than shutting down because it could not listen for a shutdown.
399pub async fn shutdown_requested() {
400    #[cfg(unix)]
401    {
402        use tokio::signal::unix::{signal, SignalKind};
403        let term = signal(SignalKind::terminate());
404        let int = signal(SignalKind::interrupt());
405        match (term, int) {
406            (Ok(mut term), Ok(mut int)) => {
407                tokio::select! {
408                    _ = term.recv() => {}
409                    _ = int.recv() => {}
410                }
411            }
412            _ => std::future::pending::<()>().await,
413        }
414    }
415    #[cfg(not(unix))]
416    {
417        if tokio::signal::ctrl_c().await.is_err() {
418            std::future::pending::<()>().await;
419        }
420    }
421}
422
423impl Lifecycle {
424    /// The answer a configured probe path owes, or `None` when the path is the
425    /// program's own.
426    ///
427    /// Called BEFORE admission, so a probe is never shed by backpressure and never
428    /// consumes a slot — a monitor asking "are you alive" during an overload must
429    /// get an answer, not the overload. Readiness reports whether this server wants
430    /// traffic right now (`503` once draining, so a load balancer stops sending
431    /// while in-flight work finishes); liveness reports only that the process is
432    /// answering, and deliberately stays `200` through a drain, because a draining
433    /// server is healthy and killing it would abandon the requests it is finishing.
434    ///
435    /// Both paths are opt-in. With neither configured this returns `None` for every
436    /// path, so no route is taken away from the program and the byte-for-byte
437    /// cross-tier differential is unperturbed.
438    pub fn probe_response(&self, path: &str) -> Option<HttpResponse> {
439        let cfg = self.config();
440        if cfg.health_path.as_deref() == Some(path) {
441            return Some(plain_status(200, "alive"));
442        }
443        if cfg.ready_path.as_deref() == Some(path) {
444            return Some(if self.is_ready() {
445                plain_status(200, "ready")
446            } else {
447                plain_status(503, "not ready")
448            });
449        }
450        None
451    }
452}
453
454/// A tiny `text/plain` response — the shape both probes answer in.
455fn plain_status(status: u16, body: &str) -> HttpResponse {
456    let mut headers = BTreeMap::new();
457    headers.insert(
458        "content-type".to_string(),
459        "text/plain; charset=utf-8".to_string(),
460    );
461    HttpResponse {
462        status,
463        headers,
464        body: body.as_bytes().to_vec(),
465    }
466}
467
468/// over capacity — shared by both tiers (the readiness/backpressure twin of the
469/// wire core's `bad_request`/`internal_error`).
470pub fn service_unavailable() -> HttpResponse {
471    let mut headers = std::collections::BTreeMap::new();
472    headers.insert("Content-Type".to_string(), "text/plain".to_string());
473    HttpResponse {
474        status: 503,
475        headers,
476        body: b"Service Unavailable".to_vec(),
477    }
478}
479
480/// A best-effort per-process nonce for correlation ids: the PID mixed with the
481/// process start instant, so ids from two processes (and two restarts) almost
482/// never collide, WITHOUT an RNG draw on the hot path.
483fn process_nonce() -> u64 {
484    let pid = std::process::id() as u64;
485    let t = std::time::SystemTime::now()
486        .duration_since(std::time::UNIX_EPOCH)
487        .map(|d| d.as_nanos() as u64)
488        .unwrap_or(0);
489    // A cheap mix so the low bits of the PID and the time both matter.
490    pid.rotate_left(21) ^ t.wrapping_mul(0x9E37_79B9_7F4A_7C15)
491}
492
493/// A structured, SECRET-SAFE request log line: method, path, status,
494/// correlation id, and duration — and never an arbitrary request header VALUE
495/// (so a secret in `Authorization`/`Cookie` can never leak into logs). Returned
496/// as a string so the caller decides the sink (both tiers format identically).
497pub fn access_log_line(
498    method: &str,
499    path: &str,
500    status: u16,
501    correlation_id: &str,
502    dur: Duration,
503) -> String {
504    format!(
505        "logos-http method={method} path={path} status={status} \
506         correlation_id={correlation_id} dur_ms={}",
507        dur.as_millis()
508    )
509}
510
511#[cfg(test)]
512#[path = "../../../logicaffeine_tests/unit/logicaffeine_system/http/lifecycle/tests.rs"]
513mod tests;