ADR-199: A Readiness Wait Is a Registration, Not a Worker

Status: Superseded by ADR-303 — its D1-D5 are D8-D12 there Date: September 22, 2026 Target Version: Nikaia 0.0.165 Builds: #90’s second step — the socket layer that keeps registrations rather than answering one readiness question at a time Narrows: ADR-038 D3’s readiness half — the mechanism, not the surface Related: ADR-121 D1 and D4 (the bell, and readiness as something awaitable), ADR-296 D12 (measure before choosing a shape, and this is what that is for), ADR-297 D6 (the drain this had to keep)

1. Context

A readiness wait was a [worker::Op]. The descriptor was duplicated, handed to an I/O worker over a channel, and the worker blocked in the poller for the whole of the wait.

io-workers is 1 by default — ADR-038 D4’s one I/O thread always runs. So a wait that had not answered blocked every other wait in the process.

That is not a slowness; it is a ceiling on the default configuration. Measured before anything was written: two readiness waits at once, the second on a pipe that already had a byte in it. The second did not get a turn in two seconds. A server waiting on accept while a connection waits on read is that shape exactly, so an HTTP server could not have been built on it.

2. Decisions

D1 — One poller for the process, one thread inside its wait, and arming happens on the calling thread.

Registering is an epoll_ctl. It does not block, so there is nothing to hand to anybody: the caller arms and gets a future. When the kernel answers, the thread fills the slot, wakes the waker and rings the bell.

Nothing occupies a thread while it waits for something that has not happened, which is the whole of what was wrong.

D2 — The numbers chose this shape, and they said the poller was never the cost.

ADR-296 D12 asks for a measurement before a shape is chosen. It was taken, and it moved the design:

  per wait
a poller built per wait, which is what the worker did 7.4 µs
one poller, add and delete around each wait 2.5 µs
a registration kept and re-armed 1.2 µs
the whole round trip through a worker 35 µs

#90’s own words for this step were keep registrations, and keeping them is worth 6.2 µs of 35. The hop was worth 28. So the step is not what the line said it was, and the line was written before anything had been measured — which is the case that rule exists for.

After: rt::io::waiting, which is what a server uses, goes from 35.4 µs to 6.6 µs. rt::io::wait, the blocking one nothing hot uses, goes from 35.3 to 29.2 — it is a condvar round trip with the readiness thread and stays one.

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

Runtime::pending counts them and ADR-297 D6’s drain waits them out. That is not a new promise: it is the one the worker path made, kept through a different mechanism. The park asks is anything outstanding before it sleeps, and the drain asks it before it lets a program go.

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

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

D5 — A cancelled wait costs nothing.

Dropping the future deletes the registration. For a socket that is a connection the program stopped caring about, and it is the case a server meets most.

3. Consequences

4. What this does not decide

5. What is built

All of it. crates/nikaia-std/src/rt/readiness.rs, both rt::io surfaces routed through it, Op::Readiness and poll_one deleted, pending and the drain carrying registrations, and a_wait_that_never_answers_does_not_block_one_that_would in crates/nikaia-std/tests/sockets.rs — which is the two-second stall, written down so it cannot come back.

This site is open source. Improve this page.