ADR-041: hurray-io splits into a sans-io core; every binding drives it
Status
Accepted (2026-10-19)
Direction approved on review; the invariants in §§ 2–6 were tightened in response to it. Accepted once the first implementation pass landed (#237), which answered the destination-allocator question and settled on two state machines rather than one. Step 2 followed in #239, closing issue #238: the Tokio readers and writers became drivers over the core, and composite reassembly moved out of the read core into a synchronous
CompositeAssembler— reassembly consumes no bytes, so it is not the byte machine's work, and keeping them apart is what lets one core serve both the flat and the assembled view of a stream.Step 3 followed:
HurrayReaderandHurrayWriterbecame real handles over the § 8 transport vtable, driving the same core with no runtime linked, and the two stream rows flipped forhurray-ffi. Two decisions it settled. § 8's rule that a failing callback moved no bytes is what makes a failed call retryable — it leaves the handle's position matching the transport's, soUnusable::LostReadCountandLostWriteCountare unreachable from a conforming C transport, exactly as step 2 found for the Tokio drivers; a write that fails after its first byte is the one irrecoverable case, and is reported as a closed sink because that is what it is. And #174 was re-examined here as this section said it should be: a stream read yields a descriptor and its buffers, so it yields oneHurrayTensorowning both. The capsule half of #174 — ADR-030 § 2's pointer — is deliberately left, so that issue stays open. Steps 4–6 remain, and thelevel-2row with them: a C writer can emit descriptors it decoded but cannot yet originate one, and the encode surface that fixes it is a descriptor builder rather than the singlehurray_descriptor_encodesymbol the row names.Step 4's first half followed (#242): the file container got its own two machines,
FileReaderCoreasked by range andFileWriterCoreemitting one forward pass, withFileReader/FileWriterreduced to drivers. It settles the one-vs-two open question for good: four cores, not one. The deciding property is that the request vocabulary is the core's entire interface — "the nextnbytes" and "the bytes[a, b)" are different interfaces, not two settings of one — and the file carries no stream frame prefix, so a shared machine would have needed a mode flag. What is shared is what is genuinely one contract: theDestinationAllocseam, because §§ 2 and 5 are one promise about payload memory.It also found an asymmetry § 6 did not anticipate. A random-access core needs no unusable state at all. Every request is absolute and the position advances only on a reported count, so a lost count is recovered by re-issuing the same range, and re-filling the destination from the range's start is idempotent. By the same argument a failed job leaves the core usable — only a failed open is terminal, there being no index to start again from. § 6's unusable state is a property of a stream, not of the contract.
Step 4's second half followed (#247):
HurrayFileReaderandHurrayFileWriterdrive the random-access core over the same vtable, with the KV section reachable in both directions, and thefile_write,file_readandfile_kvrows flipped forhurray-ffi. ABI 7 → 8.It replaced § 8's
seekwith a positionalread_at, and the reason is step 4a's own finding: the core's request vocabulary is absolute ranges. Aseek+readpair reintroduces a cursor the core never uses, turns one range request into two callbacks with two statuses, and leaves a "seek succeeded, read failed" interleave to reason about. One positional call carries its own position, which makes § 8's "a failing callback moved no bytes" rule say something stronger here than it does for a stream: a failed range read is retryable because re-issuing it reads the same bytes.sizestayed, called once at open, because a container is located from its end and no amount of reading discovers a file's length.It also found the one place where the C surface must promise less than the stream surface does. [RETRACTED by step 4c — see § 6b.] A failed file write ends the file. By the time bytes move, the tensor has consumed its name in the index and staged its descriptor, and the core offers no rollback for either, so a retry under the same name would describe a file other than the one on the sink. The writer is poisoned whether the failure landed before or after the first byte. The underlying sharp edge is the Rust driver's too, and is reported as #248 rather than fixed here.
Step 4c retracts the paragraph above. Investigating #248 showed that the burnt name and the staged descriptor were not a constraint to document but a state split to remove: an operation was committing to the core at submission while earning its index entry at completion. Two further defects came with it. The orphan descriptor does not merely waste space — it makes the file non-conforming, because
file-format.md's tensor region is a sequence of complete tensors and the orphan is never padded to 8, so the next real descriptor can begin unaligned. And neither tokio driver poisoned the core on a mid-itemErr, soFileWritercould emit a half-written tensor and a clean footer index: a self-consistent index over a non-conforming region, with no error raised anywhere. § 6b replaces the retracted promise; the terminal case is a partially emitted item, not a failed one.Step 5's first half followed (#251):
hurray-python's synchronous surface stopped driving the Tokio drivers through ablock_onand now drives the cores itself overstd::io, which is the half of § 9 that is not theasyncioAPI. No async runtime is linked into the extension any more —hurray-pythonwas the last member askinghurray-iofor itstokiofeature — and the nested-block_onconstraint that shapedfile_io.rs's composite write is gone with it. Every byte the writers emit is unchanged, verified against the pre-refactor build, and the frozen corpora now read through a third reader.It made
FileWriterCore::poisonpublic, and the reason generalises: a composite group spans several operations, so poisoning it is the driver's job, and § 1 puts drivers at the edge — in another crate, for every binding but one. A tool the in-crate driver needs and an out-of-crate driver cannot reach is a hole in the sans-io boundary, which the dead-code warning on atokio-less build made visible.It also found that the Python binding copies every payload out of the core's aligned destination into a 64-aligned allocation, so a handle declaring 4096 arrives at an address satisfying 64 — #235 one layer up, and the only binding still paying a tensor-sized copy per read. Reported as #253 rather than fixed in a pass whose acceptance was unchanged behaviour; it is the same question § Open Questions parks as "who allocates the aligned destination".
#253 is fixed. A Python tensor now adopts the
Bytesthe core froze, as a thirdBufferStorevariant whose owner is theBytesitself — the same answer § 2 gives C, wherehurray-ffihands back aHurrayBufferpointing into the destination. No binding pays a payload copy per read any more, and the declared alignment is true of the bytes in all three. The destination is still the core's allocation, not a NumPy one: aDestinationAllocthat allocated a NumPy array would need the GIL at the point the core callsallocate, inside thepy.detacha read holds for its whole I/O section. That seam stays open and unused by Python, which costs nothing now that nothing is copied.Step 5b landed (#252, ADR-042).
hurray.aio.StreamReaderandhurray.aio.StreamWriterare driven by Python's own event loop: readiness is awaited, and each transfer reports its byte count in the same synchronous call, so § 6a's case 2 is unreachable rather than merely unlikely. The two tests § 11 step 5 names both exist and both pass — a read cancelled mid-tensor resumes on the same reader, a write cancelled after its first byte poisons — and so does the third case they imply: a write cancelled before its first byte leaves the writer usable, which is § 6b's other half.It needed nothing new in
hurray-io, and less of it than expected: the driver never evaluates § 6b's predicate.StreamWriterCoresetstornat an item's first accepted byte and clears it on completion, andis_unusable()reports it — so dropping a cancelled operation is the driver's whole obligation. (WriteOp::emitted()is public and would answer the same question; consulting it would duplicate the predicate in the binding.) Step 5a had to publishFileWriterCore::poisonfor the equivalent need, so this is the sans-io boundary holding rather than leaking again. ADR-042 records the three shapes § 9 left open, plus a fourth it did not anticipate: a composite's borrowed node tree has to be pinned in a flat arena, because the binding's continuation-passing builder cannot outlive the frame that built it.Step 6 landed (#258), and with it the last open question this ADR carried. Composites now cross the C boundary on both surfaces, which flips the last two
hurray-ffirows of the interchange table: every one of the seven § Context names is ✅ in all three columns.The C sum type is preorder plus
member_count— the format's own shape. A head followed by its members, each node declaring how many of the nodes after it are its direct members, asHurrayItemon the read side and an array ofHurrayCompositeNodeon the write side. The deciding argument is not ergonomics: a tree of pointers crossing an ABI can be cyclic, and a walker on either side loops forever on one, while an array with member counts cannot describe a cycle at all. It is also one allocation for a C caller instead of one per level, and it is what the wire already says. The kind tag is not redundant with the count, becauseCompositeLayoutpermitsmember_count == 0: a zero-member head and a plain tensor are otherwise indistinguishable.It needed nothing new in
hurray-io— both cores have handled composites since the passes that built them (§ 10's explicit stack, step 2'sCompositeAssembler, step 4a'sflatten_composite), andFileWriterCore::poison, published for step 5a, is what the C file driver needed for the same reason. Converting the C array into the borrowed treehurray-iotakes is the binding's own work, done in reverse preorder — a node's members always sit after it — with one isolatedunsafeto publish a boxed array for the rest of the call, the shape ADR-042 § 4 wrote down.Two things it exposed, both fixed here. The whole composite-validation family of
hurray_core::Errormapped toHURRAY_ERR_INTERNAL, blaming the library for a caller's tree; they are layout-structural errors and now reportHURRAY_ERR_INVALID_LAYOUT. They were unreachable from C before this step, which is why nothing had caught it. And a file composite spans several index entries rather than being one item, so a defect found after the head is on the sink — a member's name, not only a transport failure — cannot be recovered from: the writer is poisoned, and § 6b's retry applies only to a failure on the head itself. The stream surface has no such case, a composite there being one item.
Resolves the bridge question HurrayReader and HurrayWriter have carried as bare
typedefs since C ABI 1, and which ADR-034 deferred alongside "does streaming reuse
this handle". Addresses issue #233.
Unblocks three parked things: Level 2 for the C surface, the seven streaming and file
rows that are ❌ for hurray-ffi, and the half of
#174 that waits on a Layer 5 C surface —
which puts this upstream of the 1.0 gate in
#225.
Context
hurray-io is generic over tokio::io::AsyncRead/AsyncWrite and every entry point is
an async fn: StreamReader::next_tensor, StreamReader::next_item,
FileReader::read_tensor, FileWriter::finish. The C ABI is six versions of blocking,
HurrayStatus-returning functions. Nothing connects them, so the generated status page
shows seven consecutive ❌ in the hurray-ffi column — stream write, stream read, stream
composites, file write, file read, file KV metadata, file composites — against ✅ in both
other columns. A C, Go or Julia consumer can decode a descriptor handed to it and cannot
obtain one.
What the bindings do today
hurray-python has already answered this question, and its answer is the thing to look
at before choosing another one. Every I/O entry point in the bindings is
py.detach(|| runtime.block_on(/* … */))
— the GIL is released so other Python threads keep running, but the calling coroutine is
blocked for the duration. There is no async method in the Python surface at all.
The cost is recorded in the code, at the point where it was paid
(hurray-python/src/file_io.rs):
// One block_on per step rather than one around the whole loop: a composite's
// borrowed node tree is built by a *synchronous* continuation (see
// with_file_nodes), and block_on cannot be called from inside another.
A nested block_on panics, so the composite file writer is structured around the bridge
rather than around the problem. That is one wart today; it is the shape of every future
wart in the same place.
This matters more than it looks, because hurray-python is not a courtesy binding. The
parity rule in CLAUDE.md says anything the Rust layers can express about a tensor, the
Python bindings must express too — and Python is the canonical runtime with an event
loop, which is exactly the audience the one async interface in this domain was built for.
What comparable projects chose
Surveyed for #233. The useful axis is not sync versus async but who drives progress: whose thread runs the work, and who owns the file handle or socket.
| Implementation | Whose thread | Whose I/O | What the C caller must own |
|---|---|---|---|
| Arrow C stream interface | caller, blocked | producer | nothing |
| GGUF / llama.cpp | caller, blocked | library | nothing |
| zenoh-c | library | library | a callback, a drop policy |
| rustls-ffi | caller | caller | sockets, buffers |
| quiche | caller | caller | sockets, buffers, timers |
| gRPC core | caller, polling | library | threads, a poll loop |
| UniFFI | caller's executor | library | an event loop |
| Arrow async device stream handler | producer | producer | an event loop |
Four findings bear on the decision.
- Nothing in this domain exposes async over a C ABI. Arrow — whose C data interface
Hurray's is modelled on — documents
ArrowArrayStream::get_nextas a blocking pull-style iteration function, witherrnocodes and an in-band end-of-stream (get_nextsucceeds and returns an already-released array). GGUF is blunter:llama_model_load_from_filetakes a path and blocks, mmapping tensor data. - The one async interface in the domain was built for event-loop languages, not C.
Arrow's
ArrowAsyncDeviceStreamHandleris consumer-allocated and producer-pushed, and its stated motivation is that the synchronous interface is difficult to use with anything resembling Pythonasync/await— the targets named being R, Python and Ruby. It remains experimental years after the blocking interface stabilised. - The sans-io entries answer a different question. rustls-ffi and quiche are not C API shapes; they are architectures from which a small C API falls out. rustls-ffi's own claim is the point: "Rustls doesn't do any I/O on its own … because of that it can be used equally well in a blocking or non-blocking I/O context." quiche goes further, and makes the application own the event loop and its timers too, because QUIC has a clock.
- The alternatives that give C real concurrency charge for it. gRPC core creates no threads of its own and requires the application to poll a completion queue "preferably in a tight loop" or no I/O progress happens. UniFFI hands futures across by making the foreign side supply the executor, does not support cancellation at all, and assumes an event loop exists on the other side — which, for C, it does not.
So the C surface should be blocking. That much every comparable project agrees on, and it was never really in doubt. The open question is what sits behind it.
What the two paths already do, which is not symmetrical
The read path allocates: read_buffers creates one BytesMut per buffer, resizes it,
reads into it and freezes it. The write path borrows: write_tensor(&desc, buffers: &[&[u8]]) validates each slice's length against its handle, writes the encoded
descriptor, then write_alls each caller-owned slice in turn. It never assembles a
contiguous output buffer, and it never copies a payload.
Any core that serves both has to preserve both properties, and they are different properties. § 2 and § 3 state them separately for that reason.
An alignment gap that exists today
buffer-protocol.md is normative twice over: the base address of every non-empty buffer
MUST be at least 64-byte aligned, and a reader MAY rely on the declared alignment for
SIMD loads. BytesMut allocates through Vec<u8>, so what both readers hand out carries
the allocator's alignment for a 1-byte type — 8 or 16 in practice — while the descriptor
beside it declares 64, or 4096 for a GPU/IPC writer.
That is a live conformance bug, filed as #235 and fixable without this refactor. It is recorded here because it is the reason § 5 states alignment as a tested guarantee rather than assuming the current behaviour is already correct.
Decision
1. hurray-io splits into a sans-io core and an async shell
The core is a state machine that consumes and produces bytes and touches no file, socket
or runtime. The Tokio StreamReader/StreamWriter/FileReader/FileWriter become thin
drivers over it, keeping their current public signatures.
This is the layer boundary rustls and quiche are built on, and it is the format's own shape: descriptors precede their buffers, the encoding is self-delimiting, back-references are forbidden, and the file writer emits in a single forward pass. A format designed so a reader never looks backwards is a format whose reader wants to be a state machine.
Half of it already exists. TensorDescriptor::decode is pure and lives in hurray-core;
stream/frame.rs is 71 lines that read a 10-byte prefix, take descriptor_length, read
that many bytes and hand them to the core. That is a state machine, written in async.
2. On the read path the core supplies the destination
The core MUST hand the driver an aligned, correctly sized destination to fill, and
MUST NOT accept payload bytes the driver has already placed somewhere else.
rustls_connection_read_tls is the model: the caller supplies a callback that writes
into rustls's own buffer.
What this does and does not promise. It promises no payload copy after the transport writes into the destination — the transport's write is the only copy, and for a socket that copy is unavoidable. It is not "end-to-end zero-copy", which only an mmap or a shared memory handle can give and which is a separate path, not this one. Keeping those two claims distinct matters, because the naive design this rules out — driver buffer in, core buffer out — adds a second, tensor-sized copy on every tensor, and that is what makes it unmergeable rather than merely suboptimal.
The destination is owned by the core for the lifetime of the item being read, and is handed to the caller only when that item completes (§ 6).
3. On the write path the core stays borrowed and scatter/gather
Symmetrically, and this is not the same requirement restated: the core MUST accept
borrowed, non-contiguous payload slices and MUST NOT assemble them into one output
buffer. It emits the encoded descriptor and then yields each caller-owned slice in turn
for the driver to write, which is exactly what write_tensor does today with write_all
per buffer.
A core that concatenated would copy every payload on the way out, which is the same defect as § 2's in the other direction, and it would also lose the vectored-write opportunity the current shape leaves open.
Borrowed payloads need a stated lifetime, because an async binding cannot assume one. A Python coroutine cannot hold arbitrary borrowed memory across a suspension point, so:
- The caller MUST keep every payload slice valid until the core reports that item written.
The core MUST expose that point explicitly — a write is complete when the core says so,
not when the last
writecallback returns. - A binding that cannot make that promise for caller memory MUST pin it for the duration:
hurray-pythonholds a reference to the backing object (a NumPy array, a buffer handle) from the moment a write begins until the core releases it, and the release is what drops the reference. - The core MUST NOT retain a payload pointer after reporting the item written.
4. The progress contract is explicit, and partial progress never loses position
The core's driver-facing API is a step function. One request is outstanding at a time, and the driver answers it before the core produces another:
NeedInput { dst: &mut [u8] } driver fills, then reports how many bytes
NeedOutput { src: &[u8] } driver writes, then reports how many bytes
Item(…) one tensor or composite node is complete
Finished clean end, at an item boundary
Error(…) see § 6 for what survives it
The rules that make short and zero-length progress unambiguous:
- A short read is not EOF. Reporting
0 < n < dst.len()is normal: the core keeps its position and re-offers the remainder. Onlyn == 0means the transport reached end of input — and then end at an item boundary isFinished, while end mid-item is an error.stream/frame.rsalready draws exactly this line ("a 0-byte read before any descriptor byte is a clean EOF"), and the core MUST preserve it. - A short write is not completion, and a zero-length write is not success. Reporting
n < src.len()re-offers the remainder. Reportingn == 0MUST NOT be treated as progress; a driver whose sink is closed MUST say so through a distinct signal, which is an error if an item is in flight. - The progress API never blocks and never waits. It does not know what a transport is. A driver that cannot proceed without blocking returns to its own executor, and the core holds position until the driver comes back. This is the property that lets one core serve both a blocking C driver and a Python event loop (§ 8, § 9).
- Byte counts are reported for every
NeedInputandNeedOutput. Nothing is inferred from a callback's return value alone.
5. Alignment is a declared guarantee with a test that can fail
The destination of § 2 MUST satisfy the alignment the buffer handle declares — not 64 as a constant, and not whatever the allocator happens to return. The spec requires that declaration to be a power of two and at least 64 for a non-empty buffer, and permits a reader to rely on it for SIMD loads, so the declaration is the contract.
This is stated as a decision because today's implementation does not keep it (#235), and
because BytesMut is the obvious thing to reach for and is wrong. An empty buffer
(byte_size == 0) stays legal at any declared power of two.
The test that proves it: read tensors whose handles declare 64, 128 and 4096 and assert
ptr as usize % declared == 0 for every buffer handed out, across the multi-buffer and
composite paths. It must fail if the allocation path ever regresses to a plain Vec-backed
allocation. hurray-python/src/numpy_allocator.rs and tests/test_aligned_allocator.py
are the in-repo precedent for both the technique and the test.
6. Cancellation, resumability, and the unusable state
Cancellation interrupts the driver's transport operation. It never interrupts the core, which has no I/O to interrupt.
- The core is resumable across cancellation if and only if the byte position is known.
A driver MUST perform a transport operation and report its byte count as one
uninterruptible step; cancellation is observable only at a step boundary. Then a
cancelled
awaitleaves the core exactly where the last report left it, and the next call continues. - Otherwise the core enters an explicit unusable state. If bytes moved and could not be reported, the core MUST be marked unusable by name — a state its API exposes and every subsequent call reports — never left to diverge silently from the transport. "Probably still fine" is not a state.
- A partially filled destination is never observable. It belongs to the core until the item completes, so a cancelled read cannot hand anyone a half-written or partially initialised buffer.
- Caller memory outlives the operation that uses it. A buffer the binding owns — Python's included — MUST remain valid until every outstanding native operation has released it. The core releases on item completion or on entering the unusable state, and the binding drops its reference then, not before.
- In Python: a cancelled
await reader.next_item()leaves the reader usable and the nextawaitresumes the same tensor. A reader that became unusable raises a distinct, named error on next use rather than an arbitrary one.
Deciding this now rather than at step 5 is deliberate: it constrains the core's state transitions and its ownership model, which are built in step 1.
6a. What the atomic step costs in asyncio, and the shape it forces
The atomicity requirement above is cheap in a blocking C driver and is the hardest thing
in this ADR to honour in Python, because asyncio can deliver CancelledError at an
await after the operation behind it has already moved bytes. Writing the three cases
out is what makes the constraint visible:
dst = core.need_input() # a memoryview into the core's destination
n = await transport.readinto(dst) # cancellation point
core.report_filled(n) # synchronous, never blocks (§ 4)
- Cancelled before any byte lands — nothing moved, position known, resumable.
- Cancelled after bytes landed in
dstbut before the count is reported — the bytes are in the core's buffer and the core believes none arrived. Position unknown, and no inspection can recover it, because0x00is valid tensor data. Unusable. - The await returned and the report has not run yet — unreachable, provided the report is a synchronous call with no await between it and the transfer. § 4's non-blocking progress API is what guarantees that, so this case is closed by construction.
Case 2 is reachable with exactly the primitive this design wants —
loop.sock_recv_into(sock, buf) performs the recv in a loop callback and then resolves
a future, and a cancellation that lands between those two events loses the count.
The apparent escape is a trap. Using asyncio.StreamReader.read(n) avoids case 2
entirely, because cancelled data stays in the StreamReader's own buffer and never reaches
ours — but it hands back a bytes object that must then be copied into the core's
destination. That is precisely the second, tensor-sized copy § 2 exists to forbid. A
Python async driver cannot buy resumability with a copy; it would be trading the reason
for the refactor against one of its requirements.
So the async driver MUST be readiness-driven, not transfer-await-driven. The await
covers readiness only (loop.add_reader/add_writer, or a protocol's
data_received); the transfer and its byte-count report happen together inside one
synchronous callback, where no cancellation can interleave. Then case 2 cannot arise and
§ 6's resumable branch is the normal path rather than a theoretical one.
The write side is not symmetrical, and is worse. A cancelled read with an unknown
count damages only our own state. A cancelled write with an unknown count has already put
an unknown number of payload bytes on the wire, and a stream cannot be un-sent: the
reader at the far end will see a truncated item. So when the count is unknown on the write
path, the core MUST become unusable and the sink MUST be treated as poisoned — a fresh
writer over the same connection is not a recovery. sock_sendall is unusable here for
that reason; the same readiness-driven shape is required.
This is step 5's hardest requirement and step 1 has to leave room for it: the report API must be callable from inside an event-loop callback, which § 4 already requires by making it non-blocking.
6b. An item commits at completion; a partially emitted item is terminal
A submitted item MUST NOT mutate any state the core carries past the operation until the core reports that item written. The name it consumes, the index entry it earns, and the bytes it stages all belong to the operation, not the core, until that point. An operation abandoned before its first byte reaches the transport therefore leaves the core exactly as it was, and resubmitting the same item — under the same name — MUST succeed.
The "nothing emitted" predicate is exactly decidable, which is what makes the rule implementable rather than aspirational: a core's write position advances in exactly one place, the progress report of § 4, so a core always knows whether the item in flight has had a byte accepted.
Once an item's first byte has been accepted, the output can no longer be made conforming by
any forward-only writer: a stream has no resynchronisation point, and a file container's
tensor region is a sequence of complete tensors (file-format.md §§ Tensor Region,
Descriptor Placement). Rollback is not available either — the file writer never seeks, and
the transport vtable of § 8 has no truncate. So the core MUST become unusable at that first
byte and MUST retire that state only when the item completes. It MUST NOT depend on a
driver reporting the abandonment, because a driver that returns an error and drops the
operation reports nothing.
finish is an item under this rule. A footer that emitted nothing is retryable; one that
emitted anything is terminal.
Note (non-normative): this supersedes "a failed file write ends the file" in the step 4b preamble above. The terminal case is a partially emitted item, not a failed one, and the two surfaces now promise the same thing.
7. Two drivers, because the format has two access patterns
The sequential driver feeds bytes forward: stream read and write, and the file writer's
single pass. The random-access driver answers range requests — the core asks for bytes
[a, b) and is given them — because the file reader seeks: the trailer sits at
file_size - 40, the index is located from it, and tensors are read by name.
Whether that is one state machine with two drivers or two machines is left to § Open
Questions; what is decided here is that a sequential byte feed alone does not serve
FileReader, so the core's interface admits both.
8. The C ABI: a blocking surface over a transport vtable, with stated rules
HurrayReader and HurrayWriter become real handles. Every call is synchronous and
returns a HurrayStatus. Transport is a vtable of callbacks — read, write, read_at, size
— supplied by the caller at construction.
Amended by step 4b: this section said
seekwhere it now saysread_at. Step 4a established that the file core asks in absolute ranges, so the positional read is the callback that matches the request: one call, one count, no cursor anyone must agree about, and a failed call that re-reads the same bytes when re-issued. A callback is free to implement it as a seek and a read of its own — that is where a cursor belongs.
Not a path and not a file descriptor: a file reader must address its input, file descriptors are already a portability wart of their own (#215), and a vtable composes with a socket, a pipe, a memory span, or a caller's own object. It is the structural choice Arrow makes when it hands a consumer four producer callbacks.
The vtable is blocking by contract — and that contract belongs to the driver, not the core. A C callback that blocks until it has bytes is correct and expected here, precisely because § 4's progress API is independently non-blocking.
The rules the vtable needs, all of which are ABI surface and none of which may be left to inference:
- Partial transfers are legal. The read callback returns the number of bytes written into the destination; fewer than requested is a short read, not an error, and not EOF.
- Zero is not success on the write side. The read callback returning
0means end of input. The write callback returning0is not completion — a closed or failing sink is reported through a distinct status, which is an error if an item is in flight. - EOF, error and closed are three statuses, never one.
- A range request is absolute.
read_atreads from the offset it is handed and keeps no cursor of its own;sizeis asked once, when a file reader is created.read_atreporting0is a file shorter than its own structure claims, not an end of input — a container's end is known from its size, never discovered by reading. - No reentrancy. A callback MUST NOT call any Hurray function on the handle that invoked it. The alternative is a reentrancy and thread-safety contract on every handle, bought for a use case nobody has.
- The context pointer belongs to the caller. Hurray never frees it. The vtable carries an optional release callback for it, symmetric with the buffer release callbacks that already cross this ABI (ADR-009).
- One handle, one thread at a time, which the absence of an internal runtime makes a one-sentence rule rather than a threading chapter.
9. Python keeps its synchronous API and gains an asynchronous one
The existing sync surface does not change; its py.detach(|| block_on(…)) calls become
py.detach(|| /* drive the core */). Alongside it, an async surface driven by Python's
own event loop — await reader.next_item() — with no Rust runtime involved. This is
UniFFI's "the foreign bindings supply the executor" without anything being generated, and
it is the parity rule satisfied in the one direction it never has been.
The async surface MUST NOT drive the blocking vtable of § 8. Python's loop drives the core directly through § 4's progress API and performs its own awaits for transport. One core, two drivers, no blocking call inside a coroutine.
10. Composite traversal becomes an explicit stack
StreamReader::read_item currently recurses through nested composites via
Box::pin(self.read_item(depth + 1)), and StreamWriter::write_composite_unchecked
recurses the same way. A state machine cannot, so the nesting becomes an explicit stack.
This is an improvement to be collected rather than a cost to be absorbed:
max_composite_depth is currently enforced against an implicit call depth, and becomes a
bound on a visible structure that a C caller can also walk.
Step 6 made that literal. The visible structure a C caller walks is the preorder node array of § Open Questions' resolved sum-type entry, and the bound reached the C surface as
HurrayReaderOptions::max_composite_depth, which bounds the assembler and leaves the flat view of the same bytes untouched.
11. Sequencing, and the condition that stops it
- Sequential stream core, proving the contracts that the rest depends on: § 2's destination ownership, § 3's borrowed scatter/gather output, § 4's partial-progress rules (short read, zero read at a boundary and mid-item, short write, zero write), § 5's alignment test, and § 6's resumability — including at least one nested-composite case. Composite state is where suspension gets interesting, and a core validated only on single tensors can be elegant there and awkward the moment a composite must survive being suspended mid-member.
- Rust drivers over it. Acceptance is behavioural, not textual: unchanged public signatures, unchanged observable behaviour, byte-for-byte wire compatibility on a corpus, and the existing integration tests passing. New or adapted test harnesses are legitimate and expected; what is not legitimate is a changed signature or a changed byte.
- The C surface: handles, the transport vtable and its § 8 rules, stream read and write. ABI version bump, decided in its own pass.
- Random-access driver and the file core, then the C file surface. Split in two when
scheduled, as step 3's precedent required: the Rust core and drivers
(#242), then the C file surface with
its own ABI bump (#247), which is where
seekbecameread_at. - Python async surface, implementing the semantics § 6 already fixes — readiness-driven per § 6a, with a test that cancels mid-tensor and asserts the reader resumes rather than poisons, and one that cancels mid-write and asserts it poisons rather than silently truncating.
- Composites across both surfaces, including the C sum-type answer. Full C composite
support lands here; the core's composite handling was proven in step 1. Landed as
#258:
hurray_reader_next_item,hurray_writer_write_composite,hurray_file_reader_read_composite,hurray_file_writer_write_composite, theHurrayItem/HurrayCompositeNodepair,max_composite_depthonHurrayReaderOptions(ABI 8 → 9), andHURRAY_ERR_NOT_COMPOSITE— which is a question wrongly asked, not a defect, exactly as step 4b argued forHURRAY_ERR_NOT_FOUND.
Stop condition: stop if the contract in §§ 2–4 cannot be satisfied without an additional payload copy — a flaw in the abstraction, demonstrated by argument and not by a single disappointing benchmark. A first prototype that copies once and can be improved is not that. The fallback is then the C-only blocking façade in § Alternatives, which this ADR rejects on architecture rather than on feasibility, and which step 1 leaves untouched.
Alternatives Considered
A blocking façade over a hidden runtime, for C only. Each HurrayReader owns a
current-thread Tokio runtime and block_ons one operation per call; hurray-io is
unchanged. This was the first recommendation for #233 and it works — it is what Arrow's C
consumers live with. Rejected because it builds a second copy of hurray-python's wall
instead of removing the first, leaves the nested-block_on constraint in place for every
future binding, and puts a Tokio runtime inside a C consumer's binary to do work that
needs no concurrency. Retained explicitly as the fallback under § 11's stop condition.
A completion queue (gRPC core's model). Buys genuine concurrency, at the price of a C consumer that must donate threads and poll in a tight loop or see no progress at all. No consumer has asked for concurrency; several will want a single blocking call.
Futures across the C boundary (UniFFI's model). Requires an executor on the far side, and C does not come with one. Note that this is precisely what § 9 does for Python, where an executor is a given — the model is right for one binding and wrong for the other, which is itself an argument for putting the split below both.
A library-owned background runtime with completion callbacks (zenoh-c's shape). Puts a thread-safety contract on every handle and makes buffer-release-callback timing the hardest thing in the documentation to state correctly.
A feed(bytes) core that returns assembled buffers. The conventional sans-io shape,
and the reason §§ 2–3 are written as requirements. It copies each payload twice over the
round trip — once into the driver's buffer, once into the core's — which for a format whose
first principle is zero-copy converts the refactor from a gain into a regression.
Leave the C ABI without I/O. The status quo, and defensible for exactly as long as nobody writes a non-Python binding. It also leaves #174 — and therefore the 1.0 compatibility contract — blocked on a question nobody is answering.
Consequences
- The seven streaming and file rows flip for
hurray-ffi, and Level 2 for the C surface stops being "deferred pending the async bridge design" because the bridge exists. hurray-pythongains an async surface. An asyncio application stops needing a thread pool to read a Hurray stream.- Surgery on shipped, tested code: 1,879 lines across
hurray-io/src/stream/andhurray-io/src/file/, released in 0.1.0. The public API is intended to survive unchanged, which § 11 step 2 turns into a behavioural check. - The invariants in §§ 2–6 are binding on both possible core shapes, which is what makes leaving the one-versus-two-machines question open safe rather than vague.
hurray-iogrows an internal API with its own tests and documentation. This only breaks even if the Tokio layer genuinely shrinks to a driver; if both layers end up substantial, the result is net new surface, whichCLAUDE.mdcalls slop. Reviewers should hold step 2 to that standard.- The risk this trades into: complexity moves out of Tokio and into a cross-language memory and progress protocol. §§ 2–6 exist to make that protocol specified rather than emergent, and they are the part of this ADR to review hardest.
- A future WASM, Go, or Node binding inherits a core it can drive, rather than arriving with its own bridge question.
- The C surface still has no cancellation mid-call, and this ADR does not invent one. Arrow's has none either and UniFFI declines to support it where the host can. § 6 gives Python real cancellation semantics because Python can express them.
- The atomicity rule in § 6 constrains the shape of the Python async driver, not just its
internals: it must be readiness-driven (§ 6a). A driver written the obvious way —
awaita transport method that transfers into our buffer — is either unsound under cancellation or buys soundness with the copy § 2 forbids. This is the single most likely place for this design to be implemented wrongly and still look correct.
Required Documentation Amendments
docs/impl/c-ffi.md— the reader and writer handles, the transport vtable and every § 8 rule, the blocking contract, the thread-affinity rule, and § 6b's writer retry contract (which replaces "a failed file write ends the file").docs/impl/rust-reference.md— the core/driver split, and §§ 2–6 as requirements on an implementation rather than internal details, including § 6b's commit-at-completion rule for writer cores.docs/impl/python-bindings.md— the async surface, its relationship to the sync one, and § 6's cancellation and buffer-lifetime semantics.docs/cookbook/— a C consumer reading a stream through the vtable, and the Python async counterpart on the same page, per the parity rule.website/coverage-matrix.toml— the seven streaming and file rows'c_symbols, and thelevel-2row.docs/SUMMARY.md— add this ADR.
Open Questions Deferred
One state machine or two.Answered: two per direction, four in all. Step 1 split the stream's read and write because §§ 2 and 3 are different properties rather than mirror images; step 4 split the file from the stream because the request vocabulary — "the nextnbytes" versus "the bytes[a, b)" — is the core's interface, and because the file's descriptors carry no stream frame prefix. Sharing one machine would have meant a mode flag, which this project does not do. §§ 2–6 bound every shape, as this question assumed, and theDestinationAllocseam is shared across all four because §§ 2 and 5 are one promise.Who allocates the aligned destination.Answered: a binding may. The core supplies the destination (§ 2) at the declared alignment (§ 5), andDestinationAllocis the seam a binding implements so a Python read can land directly in a NumPy array, asnumpy_allocator.rsalready does elsewhere. Step 1 made it aBox<dyn DestinationAlloc>handing outBox<dyn Destination>; step 2 made it an associated type on a core generic over the allocator instead. The reason is not taste: a boxed trait object erasedSync,UnwindSafeandRefUnwindSafefrom the core and so from every published type containing it, whichcargo semver-checkscaught against 0.1.0'sStreamReader. The generic form keeps those traits, drops a heap allocation per buffer, and letsfreezetakeselfby value. Type erasure is no longer available by default, which costs nothing yet: a C allocator is a concrete struct holding the caller's vtable, so step 3 instantiates the core over that rather than erasing it.- The read result versus #174.
next_tensoryields a descriptor plus its buffers, exactly the pairing #174 folds into oneHurrayTensor. Whichever lands first constrains the other; this ADR deliberately does not decide it, but it does remove the reason #174 was postponed, so #174 should be re-examined when step 3 is scheduled. Composites as a C sum type.Resolved in step 6: preorder plusStreamItemis a tensor or a composite head with members, and the C surface has no way to express a sum type. § 10's explicit stack has to produce something C can walk.member_count, the format's own shape —HurrayItemout, an array ofHurrayCompositeNodein, one node per tensor, nesting in the count column. Chosen over a pointer tree because an array with member counts cannot describe a cycle, which a caller-built tree crossing an ABI can; and thekindtag stays because a head may legally declare zero members.A lateResolved by § 6b in step 4c:finishfailure. The file writer's footer index is written last, so a writer that fails after the data leaves a file with no index. What both surfaces promise about that state is unstated today and should not stay that way.finishis an item, so a footer that emitted nothing is retryable and one that emitted anything is terminal. Both surfaces promise that; the core-level retry is reachable from C, and not from the tokio driver, whosefinish(self)consumes the sink.