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;