logicaffeine_system/http/engine.rs
1//! Engine selection + the pre-serialized static-response table — the
2//! cross-platform half of the HTTP fast path.
3//!
4//! LOGOS serves HTTP everywhere; the ENGINE is a knob. Two engines exist:
5//!
6//! - **tokio** — the portable baseline in [`super::server`]. Always compiled,
7//! works on every native platform, and is the only engine for the
8//! interpreter tier (whose `!Send` handler drives `Listener`/`Conn` inline).
9//! - **uring** — the Linux thread-per-core io_uring engine in
10//! `engine_uring` (behind the `io-uring` feature). The compiled
11//! (AOT) tier's fast path: its handlers are plain `Fn`s, exactly the
12//! engine's contract.
13//!
14//! [`choose_engine`] is the ONE selection law, applied by the serve entries in
15//! `server.rs`: `LOGOS_HTTP_ENGINE=uring|tokio` overrides; the default is
16//! uring wherever a probe ring actually initializes (Linux, `io-uring` feature
17//! compiled in, kernel support present), else tokio. Selection is ALWAYS
18//! announced with one stderr line — loud, never silent — so a deploy can never
19//! quietly run the slow path.
20
21use std::collections::HashMap;
22
23use super::wire::{serialize_response_conn, HttpRequest, HttpResponse};
24
25/// Which engine a serve entry runs on.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum EngineKind {
28 /// The Linux thread-per-core io_uring engine.
29 Uring,
30 /// The portable tokio accept loop.
31 Tokio,
32}
33
34/// The outcome of [`choose_engine`]: the engine plus the human-readable reason
35/// that goes on stderr.
36#[derive(Debug, Clone)]
37pub struct EngineChoice {
38 pub kind: EngineKind,
39 pub reason: String,
40}
41
42/// Read the selection knobs from the environment and apply
43/// [`choose_engine_from`]. `LOGOS_HTTP_FORCE_FALLBACK=1` makes the uring probe
44/// fail artificially (the fallback-honesty test hook — it exercises the same
45/// path a ring-init failure takes on a real kernel without io_uring).
46pub fn choose_engine() -> EngineChoice {
47 let requested = std::env::var("LOGOS_HTTP_ENGINE").ok();
48 let force_fallback = std::env::var("LOGOS_HTTP_FORCE_FALLBACK")
49 .map(|v| v.trim() == "1")
50 .unwrap_or(false);
51 let requested_lifecycle =
52 lifecycle_knob_requested(|k| std::env::var(k).ok().filter(|v| !v.trim().is_empty()));
53 match requested_lifecycle {
54 // A lifecycle guarantee was ASKED for by name. The uring engine calls its
55 // handler inline on the shard thread, so it cannot enforce a wall-clock
56 // request budget, answer a probe path, or shed by admission — and silently
57 // serving without the guarantee someone configured is the one outcome worse
58 // than being slower. Fall back, and say which name did it.
59 Some(knob) => EngineChoice {
60 kind: EngineKind::Tokio,
61 reason: format!(
62 "{knob} configures a lifecycle guarantee the io_uring engine cannot enforce \
63 (its handler runs inline on the shard thread); serving on tokio so the \
64 guarantee holds"
65 ),
66 },
67 None => choose_engine_from(requested.as_deref(), force_fallback, probe_uring),
68 }
69}
70
71/// The name of the first lifecycle knob the environment sets, if any — the pure
72/// half of the rule above, so it can be tested without the process environment.
73///
74/// Only knobs the uring engine cannot honor are listed. Body and read-deadline
75/// caps ARE enforced there (they are properties of parsing, not of scheduling),
76/// so configuring those does not cost you the fast engine.
77pub fn lifecycle_knob_requested(get: impl Fn(&str) -> Option<String>) -> Option<&'static str> {
78 const UNSUPPORTED: [&str; 5] = [
79 "LOGOS_REQUEST_TIMEOUT_MS",
80 "LOGOS_MAX_INFLIGHT",
81 "LOGOS_READY_PATH",
82 "LOGOS_HEALTH_PATH",
83 "LOGOS_CORRELATION_ID",
84 ];
85 UNSUPPORTED.into_iter().find(|k| get(k).is_some())
86}
87
88/// The pure selection law. `probe` initializes a throwaway ring (or explains
89/// why it cannot); it runs only when uring is actually a candidate.
90pub fn choose_engine_from(
91 requested: Option<&str>,
92 force_fallback: bool,
93 probe: impl FnOnce() -> Result<(), String>,
94) -> EngineChoice {
95 let requested = requested.map(|r| r.trim().to_ascii_lowercase());
96 match requested.as_deref() {
97 Some("tokio") => EngineChoice {
98 kind: EngineKind::Tokio,
99 reason: "LOGOS_HTTP_ENGINE=tokio".to_string(),
100 },
101 Some("uring") => try_uring("LOGOS_HTTP_ENGINE=uring", force_fallback, probe),
102 Some(other) => EngineChoice {
103 kind: EngineKind::Tokio,
104 reason: format!(
105 "LOGOS_HTTP_ENGINE={other:?} is not recognized (expected `uring` or `tokio`)"
106 ),
107 },
108 None => try_uring("default", force_fallback, probe),
109 }
110}
111
112fn try_uring(
113 origin: &str,
114 force_fallback: bool,
115 probe: impl FnOnce() -> Result<(), String>,
116) -> EngineChoice {
117 if force_fallback {
118 return EngineChoice {
119 kind: EngineKind::Tokio,
120 reason: format!("{origin}, but LOGOS_HTTP_FORCE_FALLBACK=1 forced the tokio fallback"),
121 };
122 }
123 match probe() {
124 Ok(()) => EngineChoice {
125 kind: EngineKind::Uring,
126 reason: format!("{origin}, io_uring probe ring initialized"),
127 },
128 Err(why) => EngineChoice {
129 kind: EngineKind::Tokio,
130 reason: format!("{origin}, but {why}"),
131 },
132 }
133}
134
135/// The real probe: can a ring be set up on this box, with this build?
136#[cfg(all(target_os = "linux", feature = "io-uring"))]
137fn probe_uring() -> Result<(), String> {
138 super::engine_uring::probe().map_err(|e| format!("io_uring probe failed: {e}"))
139}
140
141/// The engine is not compiled in (non-Linux target, or the `io-uring` feature
142/// is off) — tokio serves, everywhere, with the reason on stderr.
143#[cfg(not(all(target_os = "linux", feature = "io-uring")))]
144fn probe_uring() -> Result<(), String> {
145 Err(
146 "the io_uring engine is not compiled in (requires Linux and the `io-uring` feature)"
147 .to_string(),
148 )
149}
150
151/// The one loud line: which engine this serve entry runs on, and why.
152pub fn announce(entry: &str, choice: &EngineChoice) {
153 let kind = match choice.kind {
154 EngineKind::Uring => "uring",
155 EngineKind::Tokio => "tokio",
156 };
157 eprintln!("logos-http: {entry}: engine={kind} ({})", choice.reason);
158}
159
160/// Pre-serialized static responses — the memcpy fast path.
161///
162/// A table from request path to the FULLY rendered wire bytes of a response,
163/// in both connection dispositions. A serve entry carrying a table answers a
164/// matching request by copying bytes straight into the socket, before (and
165/// instead of) dispatching the handler.
166///
167/// Both variants are rendered by the same [`serialize_response_conn`] the
168/// handler path uses, so table bytes are byte-identical to what the handler
169/// path would produce for the same [`HttpResponse`] — the invariant the
170/// engine differential asserts.
171///
172/// Matching is deliberately conservative: only `GET` requests with an empty
173/// query string hit the table (anything else — `POST`, a `?query`, an unknown
174/// path — falls through to the handler).
175#[derive(Debug, Default, Clone)]
176pub struct StaticResponses {
177 entries: HashMap<String, StaticEntry>,
178}
179
180#[derive(Debug, Clone)]
181struct StaticEntry {
182 keep_alive: Vec<u8>,
183 close: Vec<u8>,
184}
185
186impl StaticResponses {
187 pub fn new() -> Self {
188 Self::default()
189 }
190
191 /// Render `resp` into both wire variants and file them under `path`.
192 pub fn insert(&mut self, path: &str, resp: &HttpResponse) {
193 self.entries.insert(
194 path.to_string(),
195 StaticEntry {
196 keep_alive: serialize_response_conn(resp, true),
197 close: serialize_response_conn(resp, false),
198 },
199 );
200 }
201
202 /// The pre-rendered wire frame for `req` under the given disposition, if
203 /// the table answers it.
204 pub fn wire_frame(&self, req: &HttpRequest, keep_alive: bool) -> Option<&[u8]> {
205 if req.method != "GET" || !req.query.is_empty() {
206 return None;
207 }
208 self.entries.get(&req.path).map(|e| {
209 if keep_alive {
210 e.keep_alive.as_slice()
211 } else {
212 e.close.as_slice()
213 }
214 })
215 }
216
217 pub fn is_empty(&self) -> bool {
218 self.entries.is_empty()
219 }
220
221 pub fn len(&self) -> usize {
222 self.entries.len()
223 }
224}
225
226/// The shard-count law for the uring engine: `LOGOS_HTTP_SHARDS` when it
227/// parses to a positive count (clamped to 1024), else the box's available
228/// parallelism, else 1. Pure so it is testable without touching the process
229/// environment.
230pub fn shard_count_from(env_shards: Option<&str>, available_parallelism: usize) -> usize {
231 env_shards
232 .and_then(|v| v.trim().parse::<usize>().ok())
233 .filter(|&n| n >= 1)
234 .map(|n| n.min(1024))
235 .unwrap_or_else(|| available_parallelism.max(1))
236}
237
238#[cfg(test)]
239#[path = "../../../logicaffeine_tests/unit/logicaffeine_system/http/engine/tests.rs"]
240mod tests;