ADR-038: The Runtime Is Ours, and So Is the HTTP Server

Status: Superseded by ADR-303 — its D1-D7 are D1-D7 there Related: ADR-289 (what an HTTP handler sees — this is the “runtime binding” it waits on), ADR-297 (Cleanup parks with the runtime, so D4 is what makes D3 of that record buildable), ADR-292 (the wake-up cost that decided D3), ADR-037 D2 (user_parallelism decides what D4 starts), ADR-002 D1 (crates.io, which D7 is about), ADR-296 (the grammar protocol D6 parses HTTP with)

1. Context

Three records wait on “the runtime binding” and none of them says what it is. tokio appears in a parenthesis in ADR-289 and as a checkbox in the roadmap — chosen by repetition rather than decided, which is the shape of dependency this project has spent a cleanup removing.

Two findings make the choice non-obvious and both are ours.

ADR-292: overlapping two operations costs a thread wake-up, about 46 µs, and that is an operating-system number no user-space scheduler removes. A runtime we wrote would not beat it.

But epoll cannot read a file at all. A regular file is always “ready” and the read blocks in the kernel anyway, which is why every readiness-based runtime serves file I/O from a thread pool. That pool is where the 46 µs comes from, and io_uring removes it — not by being a faster pool, but because no thread is involved: the kernel performs the read and reports when it is done.

(§5’s measurement sharpens this sentence, and the sharpening matters: a pre-started pool removes thread creation and still pays ~38 µs a pair for thread wake-up. Starting early is necessary and not sufficient; only having no thread at all makes a pair free.)

So the two halves of I/O have different answers, and the HTTP server sits on exactly the half where the finished libraries are.

2. Decisions

D1 — The HTTP server is Nikaia’s own, and hyper is not adopted.

A finished server would have to be given I/O it can ask for data from, and io_uring reports data instead. Bridging the two costs a copy through an intermediate buffer, which spends most of what io_uring was for. Adopting one therefore does not postpone the I/O decision — it makes it, for sockets, and unmaking it later means replacing the HTTP implementation rather than the plumbing under it.

The positive reason is larger. An HTTP request is a request line, then headers, then a body, delimited by fixed byte sequences: it is a grammar over bytes with a frame boundary, which is what ADR-296 exists for. And it is the one program that exercises ADR-010 (request bytes are Untrusted by construction, not by a special case) and ADR-017 (escaping is the template’s contract) on something real. A language whose HTTP server is written in it makes a different claim from a language with a binding to somebody else’s.

D2 — TLS is never ours. rustls is bound, as a Rust dependency.

Writing TLS is years of security-critical work where a mistake is not slowness. rustls is bound through ADR-002 D1’s passthrough like any other Rust crate.

It constrains nothing here, and that is worth recording: rustls takes bytes and produces bytes, and has no opinion about how they arrive. Unlike a finished HTTP server, it does not decide D3.

D3 — I/O is split by kind: completion for files, readiness for sockets.

Files go to io_uring where the machine has it, with the thread pool as the fallback where it does not — an older kernel, a sandbox that forbids the syscalls, wasm32 where neither exists. Sockets are readiness-based.

A regular file is the exception and everything else is the rule, which is the better way round to state this: a pipe, a terminal and a socket all signal readiness honestly, and a read on one blocks only while there is nothing there. Only a regular file lies — it is always “ready” and the read blocks in the kernel anyway. So standard input (ADR-019) belongs to the readiness half with the sockets, however much it looks like std::fs’s shape minus what a stream cannot keep.

This is where the measurement points: the cost ADR-292 measured is a file cost, and the two reads of its own example are two files. What io_uring adds for sockets is mainly batching syscalls, which is worth double-digit percentages rather than an order of magnitude — and is not something this project has measured.

Both live behind one std surface, for the reason ADR-292 gave when it put the overlap behind nikaia_std::task::both: the next change of mechanism must be a std change and not a compiler change. (That surface is what lets ADR-292 D6 lower a statement pair onto a completion pair without the emitter knowing which mechanism will serve it — the choice is made at startup, inside std, and a compiler could not have made it at all.)

D4 — The runtime starts before main, and user_parallelism says what starts.

An I/O thread that is already running costs nothing per operation; one started per pair of operations costs a wake-up per pair, which is the whole of §8.4’s finding. So it is running before the first statement the user wrote.

At user_parallelism = no that is all that starts: one I/O thread, and everything the user wrote on the main thread. At yes a pool for user code starts with it. The asymmetry is ADR-037 D2’s, restated where it becomes a thread count: the compiler’s own threads were never bounded by that switch, and the I/O thread is one of the compiler’s.

D5 — Tuning is a runtime configuration file, read at startup.

nikaia.toml keeps what the compiler must know — target and user_parallelism (ADR-037 D5) — and nothing else. How many I/O workers, how large the pool for user code, which I/O method, and how long shutdown drains are read when the program starts, by the person running it on the machine it runs on. A build-time key cannot be tuned by the operator, who is not the person who compiled it.

Four settings and no fifth. The I/O method’s default is auto, and it can be pinned — the same shape --ordering strict has: the default decides, and a person may overrule it to rule something out in the field.

cleanup-deadline moves here from nikaia.toml, where Part III 13.3 put it. How long a program waits at exit for pending cleanup (ADR-297 D6) is an operating property, and it was in the wrong file.

D6 — HTTP/1.1 first. HTTP/2 is named as unbuilt until it is built.

HTTP/1.1 is the grammar D1 is about. HTTP/2 is header compression, flow control and stream multiplexing — a different order of work, and the place where “production-ready” becomes expensive.

Part III’s std::http promises “a production-ready HTTP/1.1 and HTTP/2 server and client” with no status note. Until HTTP/2 exists that sentence is the kind of promise docs/README.md calls a defect, and it carries a status note from here on.

And the risk is named rather than discovered. A fresh HTTP/1.1 parser’s failure mode is request smuggling: a Content-Length and a Transfer-Encoding that disagree, a header sent twice, a chunked body with an edge case — and two servers in a chain then see two different requests. It is a test corpus and a fuzzer written alongside the parser, not an audit afterwards.

D7 — A Rust crate may bring its own runtime, and two rules keep that sound.

ADR-002 D1 promises crates.io, so a program can depend on a crate that starts threads and an event loop of its own — hyper among them. That is allowed, and two things about it are decided rather than discovered:

What needs no rule: the ledger knows nothing about a foreign crate’s effects, so touches is absent, so it reaches everything and orders against everything (ADR-292 D3). Fail-closed polarity means interop cannot silently break the ordering guarantee — it only makes programs that use it slower.

Evidence — the experiment §4 asked for has run, and it changes nothing here. A Nikaia program starts a hyper server and serves one request: examples/foreign-runtime/, written up in docs/history/foreign-runtime.md. Twenty-six crates resolve from crates.io and none reaches the Nikaia pipeline, so ADR-002 D1’s promise and this record hold at the same time. Three things the experiment found that this record did not say:

The third paragraph above was confirmed empirically and is asserted on every cargo test. The second rule could not be run — ADR-297 is unbuilt — and the argument for it is stronger than the reason given here: on the normal path the inserted cleanup() is never emitted at all, and ADR-297 D6’s drain cannot name what it did not park. The notes also name a sound alternative to refusing the crossing that this record did not consider.

3. Consequences

Three records stop waiting. ADR-289 can be built once D4 and D6 exist; ADR-297 D5’s parked cleanup has a queue to park in; ADR-292’s prediction — that the overlap is worth more at user_parallelism = no, because a running I/O thread carries no per-pair wake-up — becomes measurable rather than predicted. (Measured, and the prediction’s reason was the wrong half: a running thread still costs a wake-up, and what costs nothing is having no thread. ADR-292 D6.)

The dependency surface grows by rustls and, on Linux, an io_uring binding. Neither reaches a .nika file: D3’s std surface is what a program sees.

4. What is built

D3, D4 and D5. The runtime foundation: files complete, sockets signal readiness, both behind one std surface, and the whole of it is running before the first statement the user wrote. D1, D2, D6 and D7 are still decisions and not deliveries — see §4.5.

4.1 D4 — the runtime starts before main

fn main belongs to the runtime. The emitter renames the program’s own entry point to __nikaia_main and writes the one Rust runs:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let nikaia_runtime = nikaia_std::rt::start(nikaia_std::rt::UserCode::Sequential);
    let outcome = __nikaia_main();
    nikaia_runtime.finish();
    outcome
}

Three things about that shape are decided rather than incidental. The wrapper is generated code and not a link-time constructor, because finish is the other half — ADR-297 D6’s drain has an outcome to report, and an .init_array entry has nowhere to put one. UserCode is written in by the compiler, because user_parallelism is a build switch and D5 has four operating settings of which this is not one (ADR-037 D2). And only the plain shapes — fn main() and fn main() throws, no parameters, no returned value — are wrapped; anything else keeps its own name, because a wrapper that guessed at a signature would be worse than no wrapper.

What starts is what ADR-037 D2 allows. At user_parallelism = no: the I/O workers, and everything the user wrote on the main thread. At yes: a pool for user code as well.

4.2 D3 — the two halves, and the boundary that keeps user code off them

The I/O thread is the compiler’s, and the code says so. A worker’s inbox is a channel of Op, a closed enum private to nikaia_std::rt, and no variant of it carries a closure or anything else a program could put its own code into. Outside that module there is no way to name a value the channel accepts — so ADR-037 D2’s promise that nothing the user wrote runs concurrently at no is a property of the types rather than a convention review has to keep.

4.3 The measurement, and what it settles

ADR-292 predicted that the overlap carries no per-pair wake-up on a runtime that is already running, and said in the same sentence that there was nothing to time. There is now.

Two reads of the same two files, three vehicles, the same std, 20 000 pairs per repeat, nine repeats, 4 vCPU. What each vehicle adds per pair over reading the two files one after the other:

Read the signs and the ratios, not the microseconds. Re-running this same binary on the same box a day later put every figure below, the baseline included, 1.4–1.9× higher — a shared VM does not hold an absolute. What reproduced exactly is what the three columns are for: a floor in the first, a zero in the second, a flat tax in the third. docs/history/runtime-cost.md §6 has both runs.

added per pair task::both
(rayon::join)
fs::read_both,
completion
fs::read_both,
blocking fallback
5 B/file +59.2 µs (sd 1.4) −0.3 µs (sd 0.7) +37.9 µs (sd 0.9)
4 KiB/file +60.3 µs (sd 1.7) −0.5 µs (sd 0.6) +37.7 µs (sd 2.3)
64 KiB/file +67.3 µs (sd 4.6) −0.6 µs (sd 1.2) +37.3 µs (sd 1.1)
256 KiB/file +95.9 µs (sd 7.2) −0.9 µs (sd 4.6) −77.9 µs (sd 10.9)

Three things come out of it, and the third was not predicted.

The first column reproduces §8.4. It is higher than that section’s 46 µs because fixed measured a pair with nothing in it: here each closure also performs a read, so the stolen closure’s wake-up sits on the critical path of real work rather than beside it. Same floor, same shape, a little more of it.

The second column is D4 confirmed, and it is zero. Not “small” — the mean is negative at every size up to a quarter megabyte and the spread straddles zero. The kernel performs both reads; no thread is started and none is woken; fs::read_both costs what one io_uring_enter costs, and the ring was already there because the runtime started before main. ADR-292’s “none in principle” is now “none, measured”.

The third column is the finding, and it is D3’s own argument arriving as a number. The fallback does not deliver D4’s zero — it pays ~38 µs a pair. A pre-started thread removes thread creation; it cannot remove thread wake-up, and §8.4 already said the floor was the wake-up. So “the runtime starts before main” is necessary and not sufficient: what makes a pair free is that io_uring involves no thread at all. That is exactly why D3 splits I/O by kind rather than putting files on a pool, and it is the first evidence for the split from this project rather than from the literature.

The fallback’s 38 µs is also flat — ~38 µs at 5 bytes, at 4 KiB and at 64 KiB — and then goes negative at 256 KiB, where the overlap starts paying for itself. That crossover lands where ADR-292 put it, near a quarter megabyte, which is what a fixed per-pair cost amortised against a growing payload has to look like. The completion path has no crossover to find, because it has nothing to amortise.

The method, the machine, every repeat, the load the box was under, and one confound named rather than hidden are in docs/history/runtime-cost.md; benches/overlap/runtime.sh is the script that produced them.

4.4 D5 — four settings, and one key that moved

nikaia-runtime.toml, read at startup, four keys and no fifth: io-workers (default 1), user-pool (default 0, meaning as many as the machine has), io-method (default auto, pinnable to uring or blocking) and cleanup-deadline (default 30s, 0 disables draining). A key outside the four is a typo and fails, the same rule nikaia.toml’s [build] follows. NIKAIA_RUNTIME_CONFIG names the file outright, for one binary in several deployments.

Pinning uring on a machine that has none is a failure to start, not a silent fallback: the point of pinning a mechanism is to find out whether it is there.

cleanup-deadline in nikaia.toml is still accepted and prints where it went. Refusing it would fail a manifest somebody wrote to the specification that documented it.

The file’s name and search path, which D5 had left open, are now decided: nikaia-runtime.toml, looked for in the working directory, with NIKAIA_RUNTIME_CONFIG naming a file outright for one binary serving several machines. The keys are spelled as nikaia.toml’s are — io-workers, user-pool, io-method, cleanup-deadline — because a person who has written one of the two files has already learned the convention.

4.5 What is not built

D1’s HTTP server, D2’s rustls and D6’s HTTP/1.1 parser are untouched. (Since written: D1’s server is built at 0.0.166, and D6’s parser is a Nikaia grammar at 0.0.248 - nikaia-std/src/tools/http1.nika, by ADR-290’s route. D2 is still untouched.) D7’s first rule now has the check it named: ADR-005 §5’s structural Send check refuses what it can decide as NK2502, at both settings, and the rule’s reach is bounded by the honesty of the foreign signature as D7 now says. D7’s second rule — a Cleanup may not be owned by a foreign task — is still untouched, and cannot be built before ADR-297 is. The order §4’s first draft implied still holds, minus the part that now exists: a socket layer that keeps registrations rather than answering one readiness question at a time, then a minimal HTTP/1.1 server on it — which is also what gives ADR-292 the real program its debt has been waiting for — then the parsing moved into Nikaia, then rustls, then HTTP/2.

D7’s two rules were the exception worth checking early rather than late, because a program that starts hyper is the cheapest test of whether ADR-002 D1’s crates.io promise and this record can hold at the same time. That check ran, and §2’s D7 carries what it found.

And one thing is deliberately not built, because it is not this record’s to decide. fs::read_both exists and is measured, and no lowering uses it: at user_parallelism = no, ordering = "effects" still degrades to strict (ADR-292b). §8.5 called that degradation’s reason “no event loop, no sidecar and no async lowering yet”, and two of the three are now gone — but whether a statement pair may be lowered onto a completion-based pair is ADR-292’s decision, and making it here would be deciding another record’s question in this one’s delivery.

ADR-292 has since decided it — D10, and the answer is yes for a pair of file reads, at both settings of user_parallelism. Two things about that are worth having here rather than only there. The lowering does not name fs::read_both: that function overlaps whatever the mechanism costs, and D10 refuses to pay the fallback’s ~38 µs for an overlap the program did not ask for, so it names task::read_pair, which is this record’s §4.3 third column turned into a policy — free on the completion path, the written order on the fallback. And the measurement that settles it reproduces §4.3’s shape and not its absolute numbers on a different day: see docs/history/runtime-cost.md §6, where the same script on the same box measures join − seq at +112 µs where §4.3 has +59 µs. The signs, the flatness and the ratios hold; the box does not.

This site is open source. Improve this page.