Status: Accepted
Date: September 16, 2026
Related: ADR-303 D3 and D4 (I/O split by kind; the runtime
runs before the first statement), ADR-055 D3 (the executor is
ours and drives the I/O that is already there), ADR-019
(standard input), Part III 13.3b (io-method)
The runtime finishes an operation in one of two ways. On a machine with a
completion queue the kernel does the read and the ring reports it; where the
queue is missing or forbidden, a worker thread does it and rings a bell —
a count the executor’s park hook watches. The two paths serve the same
operations, and without this record they differ in one thing: while the
executor parks on the ring, it waits on the ring’s own count of outstanding
jobs, and a worker’s reply is not one of them. A worker’s reply then cannot
wake a ring park, and no future may be fed from the worker path: block_on
spins where main is alone and panics otherwise, about a waker nobody
arranged.
Two things rest on it. Standard input is a worker operation, so io::read,
io::read_to_string and io::lines could only block the thread they run on.
And rt::io::wait, the readiness half of the runtime built for sockets, could
be blocked on and not awaited — and it is the piece an HTTP server needs
first.
Wiring standard input to readiness would not help: the defect is the park, not the input.
Where the executor parks on the ring, one file descriptor is registered on
the ring with a read always in flight. ring_the_bell writes to it. A
worker’s reply therefore completes a ring job, the park returns, and the
executor runs whatever the worker’s reply made ready. The two paths stop
differing in what they can feed: every worker operation is awaitable —
standard input, socket readiness, and whatever a later operation carries.
The eventfd read carries its own user data, is answered by re-arming it, and is never handed to a future as a result. The count of outstanding jobs the park consults excludes it, so a park with only the bell armed still sleeps.
The worst failure a runtime can have is a park nobody wakes. park’s own
false is a loud panic rather than a wait; this keeps that standard. A test
holds that a worker reply wakes a ring park, and that a future fed from a
worker completes under block_on with other tasks alive; a test of a hang
bounds itself, because a hang is the failure this may not have applies to
the test that reports it.
No second read shape for standard input, no ring path for a stream that has
no size to stat. io::read, io::read_to_string and io::lines suspend
because the path they were always on can now wake the executor. The same is
true of rt::io::wait, which is what the server’s socket layer awaits.
rt::io::wait is awaitable, which is the first thing the HTTP server’s
socket layer needs.wasm32 — nothing
changes: the park there already watches the bell.IORING_OP_POLL_ADD with its
own user data (u64::MAX, a number no slot index can wear), which hands the
kernel a descriptor and no buffer, so uring’s soundness rule has nothing
to keep alive. The eight bytes are taken afterwards by a non-blocking read.io_uring_enter holding that lock, so a worker that had
to take it to ring could never ring. It is per runtime, not per process: a
second Runtime has a second ring with a bell of its own.ring_the_bell bumps the count before it writes, so
a wake cannot be lost. A call that reaps any operation’s completion moves the
generation while the ring is still held, so an answer reaped by another
thread still ends the park of the thread waiting for it.IORING_ENTER_EXT_ARG, and ETIME, a kernel too old for it, and a refused
submission all mean nothing moved.Op::Readiness carries a duplicate of the descriptor, not its number,
because a future may be dropped while the operation is in flight; a dup
shares the file description, which is what readiness is about.Slot filled from an
ordinary thread, which would be outside the runtime’s accounting.