ADR-042: The asyncio surface is a Python driver over the stream cores
Status
Accepted (2026-11-01)
Implements ADR-041 § 9 and § 11 step 5b, and records the three shape decisions
§ 9 left open. Closes ADR-035 § 1's deferral of an asyncio surface — § 1 gains an
amendment note rather than being worked around — and closes
issue #252.
The semantics are not decided here. ADR-041 §§ 6, 6a and 6b fixed cancellation, resumability and the terminal state before any of this was written, deliberately, so that the hard part was settled while the cores were being built rather than discovered while being driven. This ADR is about where the driver lives and what it may hold.
Context
ADR-035 § 1 chose a blocking Python surface and deferred asyncio with a condition
attached: revisit when a real multiplexing consumer appears, rather than work around it.
ADR-041 § 9 is that revisit. It also removes the reason the deferral was comfortable — the
binding no longer owns a tokio runtime at all, so "async in Python" no longer means
bridging two executors. It means driving a state machine from the loop that is already
running.
What § 9 fixed: the sync surface does not change, the async surface is driven by Python's own event loop with no Rust runtime involved, and the async surface MUST NOT drive the blocking transport vtable of § 8. One core, two drivers, no blocking call inside a coroutine.
What § 9 did not fix: which language the driver is written in, what the boundary between Python and Rust carries, and how a borrowed-payload write operation survives a suspension. Those are below.
Decision
1. The driver is Python source, not pyo3
hurray-python becomes a mixed Rust/Python package: python-source in pyproject.toml,
the __init__.py maturin was generating checked in as-is, and hurray/aio.py beside it.
The readiness logic is loop.add_reader / add_writer, a future per wait, and
remove_reader on the way out — about 120 lines that read as what they are in Python, and
as hand-built future plumbing in pyo3. ADR-041 § 6a's own pseudo-code is Python-level;
writing the driver in Python keeps the thing being reviewed the same shape as the thing
that was decided.
This is not a restructure: maturin already shipped hurray/ as a package with a generated
__init__.py doing from .hurray import *, so the import path, the module identity, and
every __module__ are unchanged. Nothing moves except what the wheel copies in.
Consequence accepted: the package now has Python that CI must exercise in its own
right. The repr contract (test_reprs.py) grew a submodule walk and switched from class
names to class identity — hurray.StreamReader and hurray.aio.StreamReader are
different classes with the same name, and a name-keyed guard would have let either stand
in for the other.
2. The surface is descriptor-only, and the transfer stays in Rust
Python awaits readiness. One synchronous call into the extension then performs the
read(2) or write(2) and reports the byte count to the core before it returns.
Two things follow, and both are the point:
- ADR-041 § 6a's case 2 — bytes landed, count never reported — is unreachable by construction. There is no suspension point between the syscall and the report, so no cancellation can land there.
- No
memoryviewinto the core's destination ever reaches Python. One that outlived a single step would be a use-after-free, becausestep()replaces the destination when a buffer completes. Keeping the transfer on the Rust side is what prevents such a view from existing, rather than documenting that callers must not keep one.
Consequences stated rather than hidden:
- Unix-only — the same boundary as the sync surface's
fileno()support (issue #215). A path orbyteshas no readiness to await; a regular file is refused outright, becauseepollcannot poll one and a file read does not block anyway. - No async file API.
hurray.loadandhurray.saveget nohurray.aiocounterpart. asyncio has no asynchronous random access, so one would be ADR-041 step 5a's API wearing a coroutine — whichasyncio.to_threadalready expresses at a cost the caller can see. - The descriptor is put in non-blocking mode, which
dupshares with the caller's own object. Any descriptor an event loop drives must be in that mode regardless.
3. WriteOp<'a> is held across suspension by a self-referential holder
A WriteOp borrows the core, the descriptors and the payload slices for as long as it
lives, and an async writer has to hold one across a suspension. Neither alternative
survives inspection:
- Drop the op when the sink would block. Mid-item that sets the core's
tornflag and poisons the writer by ADR-041 § 6b — so a stream larger than a socket buffer could never be written at all. - Buffer the item's wire bytes and write them later. That is the second, tensor-sized payload copy ADR-041 § 2 exists to forbid: stop-condition territory, not a trade-off.
So one #[pyclass] owns a boxed StreamWriterCore, the pinned payload, and the op with
its lifetime erased to 'static. The unsafe is one erase helper with its invariants
written out, and the type's field order is load-bearing: op is declared first because
Rust drops fields in declaration order and the op borrows every field below it.
WriteOp::park() / core.resume(..) in hurray-io was considered and rejected. It
would publish public surface for exactly one consumer, and parking a composite means
rebuilding the frame stack from a node path on resume — moving the hard part into the
crate that is supposed to have no opinion about drivers.
4. A composite's borrowed node tree is pinned in a flat arena
composite.rs builds the borrowed tree for a synchronous composite write by
continuation-passing: each node's &[&[u8]] lives in the stack frame that built it, so
every node is constructed around the call that consumes it. That is safe, and it cannot
survive an await.
The async writer therefore flattens the tree into an arena of boxed descriptors, boxed
slice vectors and boxed node vectors, children before parents. Boxing each level is the
mechanism: appending to one of the arena's Vecs may reallocate the Vec, but it never
moves a Box's pointee, so references already handed out stay valid — including across
the move that returns the arena to its owner.
The two builders are deliberately not merged. Pointing the synchronous writer at the
arena would trade its safe, stack-scoped builder for an erased-lifetime one in order to
delete duplication, which is the wrong way round: the duplication is two different
answers to two different questions, and only one of them needs unsafe. A comment on each
says so, because "unify these" is the obvious wrong move for a future reader.
Async composite writes were briefly scoped out of this pass, to be added later behind a clear error. That was rejected on the grounds that it is the same work either way, plus a temporary refusal, an issue, and a second pass through the same file to delete both — while shipping a surface we already knew we would change.
Consequences
-
hurray.aio.StreamReaderandhurray.aio.StreamWriterexist, and the parity rule inCLAUDE.mdis satisfied in the one direction it never has been: anything the sync surface can stream, the async one can stream, composites included. -
The bytes are identical.
hurray.aio.StreamWriterandhurray.StreamWriteremit the same stream for the same items, which is asserted rather than assumed, and the frozen corpus inhurray-io/tests/fixtures/now has a fourth reader. -
Cancellation is observable and specified: a cancelled read resumes, a cancelled write poisons if and only if the item had already emitted a byte. Both are tested, and the poisoned writer's error names the one recovery that is not available — a fresh writer over the same connection.
-
hurray-iogained nothing, and needed to be asked for less than expected. The driver does not evaluate § 6b's predicate at all:StreamWriterCoresets its owntornflag at an item's first accepted byte and clears it when the item completes, and reports it throughis_unusable()— so dropping the cancelled operation is the whole of the driver's obligation, andpoisoned()answers the question afterwards.WriteOp::emitted()is public and would have made the same decision explicitly; using it would have put a second copy of the predicate in the binding, to be kept in step with the core's for no new answer. Unlike step 5a, which had to publishFileWriterCore::poison, this pass needed no new surface in the core crate at all. -
No new coverage-matrix rows. Async is a driver style, not a format capability; the matrix describes what the format can express, and the same honesty applied to
level-2in #240.Affirmed by #264: this bullet was re-opened as a missing row and upheld, so the two mechanical reasons are recorded here rather than re-derived.
check-coverage-matrix.pyraises when two rows claim one capability, and the Rust probe already provesstream_read/stream_writethrough the tokio async driver — async streaming in Rust is not an unlisted capability, it is how the listed rows are proven. And neither thehurray-fficell nor the Unix-only restriction has an honest glyph: ❌ would assert a C gap that § 8 of ADR-041 denies, and ◐ means "partly implemented" where this surface is fully implemented and conditionally available — a restriction the table already carries unlabelled for the sync reader'sfileno()transport. What the matrix gained instead is one sentence in its interchange intro: rows are framings, not driver styles.
Alternatives Considered
loop.sock_recv_into / sock_sendall. The obvious primitives, and ADR-041 § 6a rules
them out: both perform the transfer inside a loop callback and then resolve a future, so a
cancellation landing between those two events loses the byte count — unrecoverably, since
0x00 is valid tensor data.
asyncio.StreamReader / StreamWriter and the protocol API. Avoids the lost count
entirely, because cancelled data stays in asyncio's own buffer. It hands back a bytes
that must then be copied into the core's destination, which is the copy § 2 forbids.
Resumability cannot be bought with a payload copy.
A thread-backed façade — asyncio.to_thread around the blocking surface, presented as
hurray.aio. Rejected as dishonest packaging: it is the sync API with a thread behind it,
which callers can write in one line and see the cost of. It is also what an async file
API would have to be, which is why there isn't one.
Generating the driver with pyo3-asyncio or an equivalent. Rejected: it exists to
bridge a Rust executor to Python's, and after ADR-041 there is no Rust executor to bridge.
Open Questions Deferred
- Whether the readiness wait should use a protocol/transport instead of
add_reader.loop.add_readeris unavailable on Windows' proactor loop, so aProtocol-based driver is the shape a Windows port would need — along with an answer to issue #215's handle-space problem, which is the actual blocker. Revisit if Windows support is scheduled. - Whether
hurray.aioshould offer a bounded-concurrency helper — a reader pool over many peers. Not until something needs it; the loop already composes withasyncio.gather, and a helper that only wrapsgatheris surface without capability.