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:
- The end of a stream is a value the caller receives, not a shape it guesses. The read-after-dropped trap becomes unreachable for code that reads its result.
- A partial write is visible, and a reader that went away is reported rather than silently ending the transfer.
stream.write's drain loop leaves the compiler for the stdlib, where it is readable Wado rather than synthesized TIR.- A generic wrapper over a CM primitive becomes expressible at all, which the pipeline order previously forbade.
Negative:
- Every stream call site changes shape, the stdlib and the packages included.
- Moving synthesis past monomorphize reorders a large phase (
cm_bindingis ~13.7k lines) and makes its interaction with monomorphize a fixpoint rather than a sequence.
Neutral:
Future::readkeeps itsOption<T>: a future carries one value, and the status it already consults is what makes the optionNone.
Known gaps
- Operating on an end whose peer has dropped still traps rather than being
defined.
Stream<T>is an opaquei32handle today (GenericResource→WirType::I32), so an end value has nowhere to keep the copy status a defined answer would read, and D6 below blocks the&mut selfroute. - D6 of Reference Representation:
&mut resourcehas no stable cell, so a method cannot write a handle back to the caller's binding. Independent of this WEP, and a prerequisite for any design that mutates an end in place. BLOCKEDstays absorbed inside the adapters andStreamis still not joinable to aWaitableSet, soselectover several streams andcancel_read/cancel_writeremain unreachable — see Async Canonical Options. This is what a Go-style channel needs and does not have yet.- A non-byte
write_raw_allhas no lowering.synthesize_lower_list_to_bufferreads its length and elements throughListalone, so nothing sources them from a slice. STREAM_CHUNK_ELEMENTSis 4096 for every payload, where the call sites this WEP replaced picked per site (read(65536)for bytes,read(16)for records). The read helper allocatesmax * elem_sizeup front, so one number cannot suit both; a payload-aware size wants a measurement first.
Related WEPs
- Async Canonical Options for
stream.read/stream.write— the BLOCKED protocol these copies run under. - Redesign Wasm CM Builtins as Resource Canonical Attributes — the resource surface the stream methods are declared on.
- TIR-Level CM Binding Synthesis — the phase this WEP moves.
- Wasm CM Component Import — component-to-component streaming, which made the discarded status deterministic rather than latent.
