ADR-129: An Async Call Hands Back a Ticket That Can Be Cancelled, and a Stream Is the Callback the Function Already Takes

Status: Superseded by ADR-284 — its D1-D3 are D19-D21 there Date: September 19, 2026 Target Version: Nikaia 0.0.9 Related: ADR-125 D6 (the blocking and async forms), D2 (a callback across the boundary), D3 (the caller owns the memory); ADR-112 (cleanup under a deadline); ADR-102 (a function-typed parameter)

1. Context

ADR-125 D6 exports a function that may pause twice: blocking, and _async with a completion callback on a library thread. Two things a host wants of an asynchronous call were left open. Cancelling it: a user closed the window, a request timed out, the host is shutting down — and the work is still running on the library’s executor with nothing the host can say to it. Streaming: a function that produces many results over time — lines of a file, rows of a query, frames of a decoder — has no shape in D2’s table, because a list crosses only as a whole and a Seq does not cross at all.

Both have a native answer inside the language already. A task that loses a select race is cancelled at its next pause point and its cleanup still runs (Part I 6.4). And a function that wants to hand out results one at a time takes a function parameter (ADR-102), which D2 already carries across the boundary as a C function pointer and a context. This record says how each is reached from C, and adds no construct.

2. Decisions

D1 — The async form takes an optional ticket out-parameter, and <package>_cancel cancels the task behind it.

int hello_fetch_async(const char* url, size_t url_len,
                      uint8_t* out, size_t cap, size_t* written,
                      void (*done)(int status, void* ctx), void* ctx,
                      hello_op** op);
int hello_cancel(hello_op* op);
void hello_op_free(hello_op* op);

op is the last parameter of every _async form. A caller that will never cancel passes NULL and nothing is allocated. A caller that may cancel receives a ticket, and hello_cancel(op) asks the task behind it to stop: the task is cancelled at its next pause point, exactly as one that lost a select race, and its cleanup blocks run — adopted by the library’s runtime and bounded by cleanup-deadline, as ADR-112 says for a program. A sync body has no pause point and is not cancelled; the ticket exists, cancel returns OK, and done reports what the body did.

done is called exactly once, whatever happens: with the status the work produced, or with HELLO_E_CANCELLED (-7, the seventh negative code of ADR-125 D4) when the cancellation arrived first. cancel after done has run is OK and does nothing; cancel twice is OK. The ticket is freed by the caller through hello_op_free, after done — the caller owns its memory (ADR-125 D3), and a ticket freed before done is an E_ARGUMENT on the free, with the ticket left alone, because the library still needs it.

The blocking form has no cancellation: the caller’s thread is inside the call, and there is nobody to ask. A caller that wants both a result on its own thread and a way out uses the async form and waits on a condition of its own, which is what a C programmer does already.

D2 — A stream is a function that takes a callback, and the callback’s bool is the caller’s stop.

pub extern "C" fn lines(path: &str, each: fn(&str) -> bool sync) throws { … }

is exported as

int hello_lines(const char* path, size_t path_len,
                bool (*each)(const uint8_t* line, size_t line_len, void* ctx),
                void* ctx);

The library calls each once per item, on the calling thread (blocking form) or on a library thread (_async form). The item is a view that lives for the callback, as ADR-125 D3 says of every callback argument. When each returns false the function returns OK at once and produces nothing more — the ordinary meaning of the bool inside the language, where the body writes if !each(line) { return }. A callback that returns nothing streams without a stop.

Nothing new crosses the boundary: D2’s callback row carries it. What this record decides is that this is the streaming shape, and that a function which returns a Vec of text, a Vec of handles or a Seq is refused at the declaration with the message write it as a function that takes fn(item) -> bool sync, so that a library author is told the idiom rather than left to invent one.

D3 — Backpressure is the callback’s return, not a queue.

The library produces the next item after the callback has returned, never before. So a slow consumer slows the producer, a buffer never grows behind the host’s back, and the library holds at most one item in flight per call. A host that wants a queue builds one in its callback, in memory it owns.

3. Consequences

4. What this does not decide

5. What is built

Nothing. The order of work, after ADR-125 §5’s async form:

  1. the ticket, cancel, op_free and E_CANCELLED, with the exactly-once done;
  2. the bool callback row in the wrapper, and the refusal message for a returned list or Seq;
  3. an example: a streamed file and a cancelled fetch in the C example.

#110 carries it.

This site is open source. Improve this page.