ADR-303: The Runtime Is Nikaia’s Own — It Starts Before main, Is Tuned When the Program Starts, Completes Files and Registers Every Readiness Wait; the HTTP Server Is Ours and TLS Is rustls

Status: Accepted Date: October 3, 2026 History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached Answers: what “the runtime binding” is that the HTTP handler and parked cleanups wait on - our own or tokio - and whether the HTTP server is ours or hyper; how files and sockets are served; when the runtime starts and who tunes it; whether a Rust crate may bring a runtime of its own; and how a readiness wait is served so that one wait that has not answered does not hold up another (#90) Related: ADR-289 - what an HTTP server is: http is a package, the socket is std’s (D1, D6), everything that waits gives the thread up (D10), and a send-file key in the runtime file (D24); ADR-290 - how a Rust crate is described before it is called, the threads column, and NK2502 on a described call (D6, D7); how the HTTP/1.1 parser became Nikaia; ADR-263 D1 - a regular-file operation on an idle runtime runs on the calling thread, which narrows D3 here; ADR-121 D1 and D4 - the bell, and standard input awaitable on the worker path; ADR-297 D5 and D6 - a cancelled cleanup parks with this runtime, and the drain at exit under cleanup-deadline; ADR-292 D2 and D3 - overlap is what puts two operations in flight, and what touches orders; ADR-037 D2, D3 and D5 - user_parallelism, Shared at no, and what stays in nikaia.toml; ADR-005 §5 - the structural Send check, NK2502; ADR-002 D1 - crates.io, which D7 is about; ADR-296 - the grammar protocol HTTP is a grammar for; ADR-010 and ADR-017 - untrusted input and the escaping contract, which an HTTP server exercises; ADR-258 - the four weights

1. Question

Is the runtime under a Nikaia program - the I/O, the executor’s threads, the HTTP server on top - this project’s own, or a finished one bound in; and if ours, how does it serve each kind of I/O, when does it start, and who tunes it?

Without an answer, every record that waits on “the runtime” waits on a word: an HTTP handler has nothing to be called from, a cancelled cleanup has no queue to park in, and overlap has no I/O whose two halves could be in flight together. tokio had appeared in a parenthesis and a roadmap checkbox - chosen by repetition, not decided. The answer cannot default because the two halves of I/O have different answers, and the HTTP server sits on exactly the half where the finished libraries are.

2. How others do it

Language / tool Its answer What it costs there Source
Rust, tokio readiness (epoll) for everything; file I/O on a blocking thread pool a regular file is always “ready”, so every file read is a hand-off to a pool thread and a wake-up back from memory, not re-checked
Rust, hyper on tokio a finished HTTP server over a readiness runtime it pulls bytes from I/O it can ask; under completion I/O that needs a copy through an intermediate buffer, and the choice of server becomes the choice of I/O for sockets the same; ADR-289 §2
Linux io_uring completion: the kernel performs the operation and reports it no thread is involved; absent on older kernels, forbidden in some sandboxes, and nonexistent off Linux the same
rustls TLS as bytes in, bytes out none here: it has no opinion about how bytes arrive the same

What does not transfer: tokio’s thread pool for files is exactly the cost a completion queue removes, and a language whose overlap is written in the source (ADR-292) has a reason to want two file reads to cost nothing together that a library for Rust programs does not.

3. Options, weighed

Question Option Why it lost, or won
The HTTP server adopt hyper it decides socket I/O for us and bridging it to completion I/O spends most of what completion was for; unmaking it later means replacing the server, not the plumbing
  write our own (D1) HTTP is a grammar over bytes with a frame boundary, which ADR-296 exists for, and it exercises untrusted input and escaping on something real
TLS write our own years of security-critical work where a mistake is not slowness
  bind rustls (D2) it constrains nothing about how bytes arrive
Files a pool of threads, pre-started removes thread creation and still pays a wake-up per pair
  completion, blocking fallback chosen at run time (D3) no thread is involved, so a pair costs what one submission costs
A readiness wait a worker blocked in a poller per wait with one I/O worker by default, one wait that has not answered blocks every other
  one poller, a registration kept across waits saves little beyond add-and-delete, and couples a socket’s lifetime to the registry (§6)
  one poller, arm on the caller, add and delete around each wait (D8, D9) nothing occupies a thread while it waits, and the hop - the real cost - is gone
When it starts per operation, or lazily a thread started per pair costs a wake-up per pair
  before main, from a generated wrapper (D4) a link-time constructor has nowhere to report the drain’s outcome
Tuning build-time keys in nikaia.toml the operator who runs the program is not the person who compiled it
  a runtime file read at startup (D5)  

The number that decided the file half: on a running runtime a pair of file reads on the completion path adds nothing measurable over reading them one after the other, while the same pair on a pre-started thread pool adds a flat wake-up of about 38 µs. The number that decided the readiness half: a wait round-tripped through a worker cost about 35 µs, of which the hop to the worker was about 28 and keeping a registration across waits about 6.

4. Decision

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, and completion I/O reports data instead; bridging the two costs a copy through an intermediate buffer. Adopting one would decide the socket half of D3 for us, and undoing that would mean replacing the server rather than the plumbing under it.

The positive reason is larger: an HTTP request is a grammar over bytes with a frame boundary (ADR-296), its bytes are untrusted by construction (ADR-010), and its responses are where escaping is the template’s contract (ADR-017). Where the server lives - a package http over std’s socket - is ADR-289’s.

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

It is bound through ADR-002 D1’s passthrough like any other Rust crate. It takes bytes and produces bytes and has no opinion about how they arrive, so unlike a finished HTTP server it decides nothing about D3.

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

Changed by ADR-263 D1: a regular-file operation on a runtime with nothing else in flight runs on the calling thread.

A regular file goes to io_uring where the machine has it, with the blocking path as the fallback where it does not - an older kernel, a sandbox that forbids the syscalls, a target without either. The fallback is chosen at run time: a binary built where io_uring exists runs where it does not, and the probe is the ring failing to open. fs::map is not on this path; it hands back memory the operating system owns.

A regular file is the exception and everything else is the rule. A pipe, a terminal and a socket signal readiness honestly and block only while there is nothing there; only a regular file is always “ready” while the read blocks in the kernel anyway. So sockets and pipes are served by readiness, and how a readiness wait is served is D8-D12. Standard input is a stream and not a regular file, so it is not on the completion half; its read stays on the worker path and is awaitable through the bell (ADR-121 D4).

Both halves live behind one std surface, so that the next change of mechanism is a std change and not a compiler change: the choice is made at startup, inside std, where a compiler could not have made it, and an overlap (ADR-292 D2) is served without the emitter knowing which mechanism serves it.

What the I/O threads run is std’s and nothing else: a worker’s inbox is a closed, private list of operations, and none of them carries a closure. That is what makes ADR-037 D2’s promise - nothing the user wrote runs concurrently at no - a property of the types rather than of review.

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

An I/O thread already running costs no thread start per operation, so it is running before the first statement the user wrote. At user_parallelism = no that is all that starts: the I/O workers, and everything the user wrote on the main thread. At yes a pool for user code starts with it. The I/O workers are the compiler’s threads, not the user’s, which is why they start at no too (ADR-037 D2).

main belongs to the runtime. The emitter renames the program’s entry point and writes a main that starts the runtime, calls it, and then finishes the runtime. It is generated code and not a link-time constructor, because finishing is the drain at exit (ADR-297 D6) and it has an outcome to report. Which pool starts is written in by the compiler, because user_parallelism is a build switch and not a runtime setting. Only the plain shapes - fn main() and fn main() throws, no parameters, nothing returned - are wrapped; a wrapper that guessed at a signature would be worse than none.

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). What the person running the program tunes on the machine it runs on is read when it starts, from nikaia-runtime.toml in the working directory, or from the file NIKAIA_RUNTIME_CONFIG names outright (one binary, several deployments). The keys are spelled as nikaia.toml’s are:

ADR-289 D24 adds send-file, for how a file reaches a socket. Any other key is a typo and fails, as under nikaia.toml’s [build]. cleanup-deadline belongs here and not in nikaia.toml; a manifest that still carries it is accepted and told where it went, because refusing it would fail a manifest written to the specification that once documented it.

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 - header compression, flow control, stream multiplexing - is a different order of work, and wherever the documentation names it, it says it is not built.

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 met by 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 may depend on a crate that starts threads and an event loop of its own. That is allowed, under two rules:

What needs no rule: nothing is known about a foreign crate’s effects, so its touches is absent, so it reaches and orders against everything (ADR-292 D3). Interop cannot silently break the ordering guarantee; it only makes the program that uses it slower.

D8 — A readiness wait is a registration on one poller for the process, armed on the calling thread.

There is one poller for the process with one thread inside its wait. Registering is an epoll_ctl and does not block, so there is nothing to hand to anybody: the caller arms and gets a future. When the kernel answers, the poller’s thread fills the slot, wakes the waker and rings the bell. Nothing occupies a thread while it waits for something that has not happened. With one I/O worker by default (D5), a wait that occupied the worker blocked every other wait in the process - a ceiling on the default configuration, not a slowness, and the shape an HTTP server has exactly (a server waiting on accept while a connection waits on read).

What a program writes is unchanged: this is the mechanism under D3’s readiness half, not its surface.

D9 — The hop to a worker is what is removed; a registration is added and deleted around each wait.

The hop to a worker is most of a wait’s cost, and keeping a registration across waits is a small part of it (§3). So a wait adds its registration and deletes it when it is answered, and keeping one across waits is left open (§6).

D10 — A registration is outstanding, exactly as a worker operation is.

The runtime’s count of pending operations includes registrations, and the drain at exit (ADR-297 D6) waits them out. The park asks is anything outstanding before it sleeps, and the drain asks it before it lets a program go. A registration that leaves wakes the drain, as an answer arriving does.

D11 — The bell stays, because a Waker does not reach a parked executor.

ADR-121 D1’s bell is what wakes a thread asleep in the ring’s io_uring_enter or on the condvar, and neither watches a waker. So the poller’s thread rings it as a worker does, and the waker it also wakes is for an executor that honours one.

D12 — A cancelled wait costs nothing: dropping the future deletes the registration.

For a socket that is a connection the program stopped caring about, which is the case a server meets most.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.