Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions contracts/embedder-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ translation envelope as the build-time artifact
of one stream/future idempotent (pass-through round trips —
host→guest→host — hand back the same handle machinery instead of
asserting), legalizes host↔host rendezvous for every element type, and
pins u8 stream chunks as `Uint8Array` in both directions.** This document supersedes `descriptor-ir.md`'s interim
pins u8 stream chunks as `Uint8Array` in both directions; amendment A6
(2026-08-11) ships the wasi-shims parking kernel always-on (§"WASI
examination", renumbered from a colliding second "A5"); amendment A7
(2026-08-11) makes component faults loud on host stream/future
operations (`PeerTrappedError`, never a hang or a fake end-of-stream)
and limits host ends to one in-flight operation per direction.** This document supersedes `descriptor-ir.md`'s interim
"host value mapping" table as the destination for host-facing value shapes.
The runtime's *raw* boundary (`instance.exports`, `HostImports`) keeps the
`definitions.py` interpreter shapes as an **internal** surface; the
Expand Down Expand Up @@ -197,6 +202,10 @@ class WitError<E = unknown> extends Error {
constructor(payload: E, message?: string);
}
class Trap extends Error { … } // existing; component-fatal, never a value
class PeerTrappedError extends Error { // A7: a stream/future op whose peer instance trapped
readonly cause: unknown; // chains to the Trap
readonly progress?: number; // write ops: elements delivered before the fault
}
```

- **Guest export with `result<T, E>`**: the call resolves to `T` on ok and
Expand Down Expand Up @@ -380,6 +389,29 @@ class DroppedError extends Error { … } // awaiting a dropped future rejects
underneath; the conventions layer exposes them as
`Stream.create<T>(): { stream: Stream<T>, writer: StreamWriter<T> }`
with `write`/`writeAll`/`cancelWrite`/`close`.
- **Component faults are loud on stream/future operations** (amendment
A7). When the component instance holding the peer end traps, its live
ends are retired: a parked host `read`/`write`/`writeAll`/future-await
**rejects with `PeerTrappedError`** (`cause` chains to the trap; a
write's `progress` reports elements delivered before the fault), and so
does any operation started afterwards. A fault is never presented as a
clean end-of-stream or a bare `DroppedError` — the same
no-wrong-data-as-success rule the producer direction has
(`StreamProducerError`) — with one precision: an operation that
genuinely COMPLETED before the trap keeps its result (a full write, a
read that copied data), and the fault surfaces on the export call and
on the handle's next operation. A trapping host **import** drops the
lifted stream/future arguments it abandoned, so their peers settle with
the truthful short count / end-of-stream. Only embedder negligence —
lowering a host end and never acting on it — still hangs, as documented
since v0.2.
- **One in-flight operation per host end, per direction** (amendment A7):
a second `write` while one is parked (or a second `read`, or a second
future operation) throws a `TypeError` synchronously — the host-side
spelling of the `CopyEnd` busy trap. Reading while a write is parked on
the same stream stays legal (they are different ends). Previously the
second operation could "rendezvous" against the first one's parked
buffer and report data as taken by a peer that never existed.

## Module wiring and instantiation

Expand Down Expand Up @@ -463,7 +495,7 @@ signatures for the representative slice:
**wasi:io@0.2.x pollable + streams** (p2): `pollable.block()`, `poll()`
and `blocking-read`/`blocking-write-and-flush` are **sync** WIT functions
that must park — the one p2 idiom that fights a JS host. The shim package
ships the PARKING KERNEL, always on (amendment A5, 2026-08-11;
ships the PARKING KERNEL, always on (amendment A6, 2026-08-11;
supersedes the original three-tier ruling and its "never (c) in this
package" mission line — the polymorph-iroh upstream-iroh consumer class
genuinely parks, which the always-ready stubs turned into a livelock):
Expand Down
74 changes: 74 additions & 0 deletions examples/guests/stream-pass/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions examples/guests/stream-pass/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ crate-type = ["cdylib"]

[dependencies]
# `async` is a default feature; it provides the guest-side task executor and
# stream/future support. No `async-spawn`: these exports never pump anything.
wit-bindgen = "=0.60.0"
# stream/future support. `async-spawn` backs `open-then-trap`'s background
# writer; the pass-through exports themselves never pump anything.
wit-bindgen = { version = "=0.60.0", features = ["async-spawn"] }

[profile.release]
opt-level = "s"
Expand Down
30 changes: 29 additions & 1 deletion examples/guests/stream-pass/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ wit_bindgen::generate!({
async: true,
});

use wit_bindgen::rt::async_support::StreamReader;
use wit_bindgen::rt::async_support::{FutureReader, StreamReader};

struct Component;

Expand All @@ -26,6 +26,34 @@ impl Guest for Component {
async fn pass_through_text(input: StreamReader<String>) -> StreamReader<String> {
input
}

async fn consume_then_trap(mut input: StreamReader<u8>, count: u32) {
for _ in 0..count {
let _ = input.next().await;
}
core::arch::wasm32::unreachable()
}

async fn open_then_trap(n: u32) -> StreamReader<u8> {
let (mut writer, reader) = wit_stream::new();
wit_bindgen::rt::async_support::spawn_local(async move {
let _ = writer.write(vec![7u8; n as usize]).await;
core::arch::wasm32::unreachable()
});
reader
}

async fn future_then_trap(mut gate: StreamReader<u8>) -> FutureReader<u32> {
let (writer, reader) = wit_future::new(|| 0u32);
wit_bindgen::rt::async_support::spawn_local(async move {
// Park until the host releases the gate — giving it time to park
// a read on the future — then trap without ever writing.
let _ = gate.next().await;
let _hold = writer;
core::arch::wasm32::unreachable()
});
reader
}
}

export!(Component);
12 changes: 12 additions & 0 deletions examples/guests/stream-pass/wit/world.wit
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,16 @@ world stream-pass {
export forward: async func(input: stream<u8>) -> u64;
/// Result-position pass-through for a non-numeric element type.
export pass-through-text: async func(input: stream<string>) -> stream<string>;

/// Reads `count` elements off `input`, then traps (unreachable) — the #66
/// shape: the stream's readable end dies inside this instance's poisoned
/// handle table while host writes are still parked.
export consume-then-trap: async func(input: stream<u8>, count: u32);
/// Returns a fresh stream, writes `n` bytes from a background task, then
/// traps — the write end dies in the poisoned table while the host reads.
export open-then-trap: async func(n: u32) -> stream<u8>;
/// Returns a fresh future whose write end dies unwritten in the poisoned
/// table: the background task reads one gate byte (so the host can first
/// park a read on the future), then traps before delivering the value.
export future-then-trap: async func(gate: stream<u8>) -> future<u32>;
}
5 changes: 2 additions & 3 deletions ports/webrtc/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions ports/websocket/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 31 additions & 1 deletion runtime/src/embedder/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
// trap, so the defensive wrapper is unnecessary by construction.
// * `Trap` — component-fatal, never a value (re-exported from cabi).
// * `DroppedError` — awaiting a future whose write end dropped without a
// value (R-fix review note 4).
// value (R-fix review note 4). Its uncomely sibling `PeerTrappedError`
// (below) is a drop that happened because the peer's instance trapped —
// branded separately so a fault is never mistaken for a clean end.

export { Trap } from "../cabi/trap.ts";

Expand Down Expand Up @@ -61,6 +63,34 @@ export class InvalidHandleError extends Error {
}
}

/**
* A stream/future operation whose peer end died in a trap-poisoned component
* instance (#66; contracts/embedder-api.md amendment A7).
*
* Discriminated from `DroppedError` on purpose: a clean drop is a normal
* outcome (end-of-stream, "no value"), while a poisoned peer means the
* component faulted — resolving the operation as if the stream simply ended
* would be wrong data reported as success, the same shape
* `StreamProducerError` exists to prevent in the other direction. `cause` is
* the recorded poisoning failure (its own `cause` is the underlying `Trap`);
* `progress` is how many elements a write had delivered before the peer died.
*/
export class PeerTrappedError extends Error {
override readonly cause: unknown;
readonly progress?: number;

constructor(where: string, cause: unknown, progress?: number) {
super(
`${where}: the peer component instance trapped, so this ` +
`stream/future operation can never complete — ` +
`${cause instanceof Error ? cause.message : String(cause)}`,
);
this.name = "PeerTrappedError";
this.cause = cause;
if (progress !== undefined) this.progress = progress;
}
}

function describePayload(p: unknown): string {
if (p === null || p === undefined) return String(p);
if (typeof p === "object" && "tag" in (p as Record<string, unknown>)) {
Expand Down
Loading
Loading