Wado

WEP: Stream Copy Results

A CM copy reports how it ended. Stream::read throws that report away, so the end of a stream is unobservable and the next read traps. This WEP gives the copy result a Wado type and hands it back to the caller.

Context

stream.read and stream.write return a packed (count << 4) | status, where the status is a CopyResult (CanonicalABI.md):

class CopyResult(IntEnum):
  COMPLETED = 0   # this copy finished; says nothing about the peer's future
  DROPPED   = 1   # the peer end is gone; no further copy on this end
  CANCELLED = 2   # a cancel finished and returned the buffer; the end stays usable

Wado's adapters keep the count and discard the status (rt.wado::cm_stream_read_u8, resource_rewrite::synthesize_stream_read_func), so Stream::read returns a bare List<T> and end-of-stream is inferred from an empty list.

The inference is wrong

A copy can report elements and DROPPED together — a peer that writes its last chunk and drops produces exactly that. The reader gets a non-empty list, loops, reads again, and the canonical traps:

cannot read after being notified that the writable end dropped

The trap is not a race. done is set only when this end itself receives DROPPED (futures_and_streams.rs:3956 on the direct return, :4879 on the event that settles a blocked copy). A peer that dropped without us noticing yields Dropped(0) on the next read (:3946), never a trap. The state is local, monotone, and caused by our own observation — which is why recording it where the caller can see it is a complete answer.

Nothing about this is specific to a guest peer. Dropped(n) carries a count, so a host that writes and drops in one go produces the same shape; today's WASI paths stay green only because their writes and drops arrive separately.

Writes have the same shape

stream.write reports (count, status) too: a reader may take a prefix, and the peer may have dropped. StreamWritable::write returns () and drains inside the adapter, so the caller learns neither how much was taken nor that the reader is gone — and its next write traps (futures_and_streams.rs:3507).

Futures already do it

Future::read consults the status and maps a non-COMPLETED copy to Option::None (synthesize_future_read_func). Streams are the odd one out.

Decision

The copy result is a type

/// How one CM copy ended (`CanonicalABI.md`, `CopyResult`).
pub enum CopyResult {
    /// The copy finished. It says nothing about the peer's future.
    Completed,
    /// The peer end is gone; no further copy is possible on this end.
    Dropped,
    /// A cancel finished and returned the buffer; the end stays usable.
    Cancelled,
}

A bool would collapse Dropped and Cancelled, which the CM keeps apart: only the first ends the stream. It would also invite reading the flag as the stream's state, when what it describes is the copy that just happened.

A read and a write return what the canonical reported

pub struct StreamChunk<T> {
    pub items: List<T>,
    pub result: CopyResult,
}

pub struct StreamWrite {
    pub count: i32,
    pub result: CopyResult,
}

impl Stream<T> {
    fn read(&self, max: i32) -> StreamChunk<T>;
}

impl StreamWritable<T> {
    fn write(&self, data: List<T>) -> StreamWrite;
}

Each is one canonical call. write no longer drains: a partial copy is what the canonical reported, and the loop that finishes the buffer moves to the wrapper below, out of the compiler and into Wado.

The primitives stay faithful to the CM, and the ergonomic layer sits on top of them — the same split the language applies elsewhere. A caller that honours the result it was handed can no longer reach the trap.

The stdlib carries the loops

impl Stream<T> {
    /// Read until the writable end drops.
    pub fn read_to_end(&self) -> List<T>;
}

impl StreamWritable<T> {
    /// Write every element, or stop early if the readable end drops.
    pub fn write_all(&self, data: List<T>) -> CopyResult;
}

These are ordinary Wado, generic over the element type, and are what most call sites use. A call site that streams — bounded memory, incremental work — writes the loop over read itself.

write_all offers the whole buffer once, then at most STREAM_CHUNK_ELEMENTS per copy. Each copy lowers what it offers, and a reader may take a short prefix, so offering the whole rest every time would make the loop quadratic.

No copy offers more than the Canonical ABI's 2^28 - 1 elements, which is where the canonical traps. read and write hand it at most that many, so a longer list writes short and a larger max reads short, as any copy may. The buffer a copy uses must still fit linear memory: one whose bytes an i32 cannot count traps before the copy, as a list that large does at any component boundary.

write_raw_all is the same loop for elements already in one array, without the value-semantics copy write makes. It is a #[cm] member rather than Wado. A loop in Wado cannot hold a linear-memory pointer, so it would lower the view again on every copy. core:rt lowers it once and advances the pointer. A file write is the case that needs it: wasmtime's filesystem stream takes 8 KiB per copy. Being a primitive, it is also what a handler for StreamWritable<u8> claims to capture println.

A view has no single-copy write. A caller looping over one would lower the rest again on every copy, which is the quadratic write above. A single copy matters only to a caller doing other work between copies, and BLOCKED is not reachable yet (see Known gaps).

write_raw_all hands the backing array to the canonical as it stands, which lines up with the CM buffer only for bytes. A wider element is a diagnostic naming write_all, not a lowering: cm_binding_function's stream arms are the hand-written u8 path, and the rewriter checks the element rather than trusting that everything else was parameterized above it.

A generic resource dispatches like any other declaration

impl Stream<T> is the first inherent impl on a generic resource, and the receiver-naming path did not know the shape: base_type_name, impl_receiver_key, fq_base_type_name, generic_dispatch_components and get_struct_name_from_type each enumerated GenericInstance and stopped there. An impl header and the calls to it then mangled under two different heads, so the methods and their call sites never met and monomorphize minted no instance. A generic resource is declared like the rest, and each of those answers now says so.

CM binding synthesis moves after monomorphization

A generic impl Stream<T> body cannot hold a #[cm] call today: binding synthesis runs before monomorphize (synthesis.rs, lib.rs), so the payload is still a type parameter and classification fails with

`T` has no Component Model representation as a `stream` element

read_to_end and write_all are exactly such bodies, so the payload-driven half of synthesis moves after monomorphize, where every element type is concrete. reflect_bridge is the precedent: a synthesis pass over FlatPackage that runs there for the same reason.

The generated bodies call generic stdlib functions (List<Elem>::with_capacity), whose instantiations monomorphize would already have had to mint. Synthesis therefore runs to a fixpoint with monomorphize rather than strictly after it.

Effect dispatch moves with it. A handler installed for Stream<u8> claims read wherever it is called from, and the wrappers it routes through are synthesized from the handler impls, which name the instantiation already. Only the rewriting of the call sites has to happen again, over the instances: a call left for the CM binding would reach the canonical past the handler that exists for it.

The payload scan splits the same way, and each half runs before the rewrites of its own half: both consume the pristine future-new / stream-new call shape the scan matches. A PayloadsValidated witness — minted only by a scan, taken by value by each rewrite — makes that order a compile error to get wrong. Without the post-monomorphize half, a payload the pre-monomorphize scan skipped for being a type parameter reached WIR build with no binding to call and panicked there, while the same program written without the generic helper was a clean diagnostic.

Consequences

Positive:

Negative:

Neutral:

Known gaps