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
34 changes: 23 additions & 11 deletions contracts/embedder-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,17 +460,29 @@ signatures for the representative slice:
waitFor(howLong: bigint): Promise<void> } // 4 lines over setTimeout; zero JSPI
```

**wasi:io@0.2.x pollable + streams** (p2): `pollable.block()` and
`blocking-read`/`blocking-write-and-flush` are **sync** WIT functions that
must park — the one p2 idiom that fights a JS host. Three-tier strategy:
(a) default: type-only stubs — C0 finding #6: the entire corpus links
`wasi:io/poll` via the libc baseline yet **no leg ever called a pollable
method**; (b) buffer-backed streams: sync `read`/`check-write` serve from
host-side buffers filled by background pumps, so the sync fast path never
parks; (c) when a guest genuinely blocks: a `suspending()`-marked import
(amendment A1) parks the frame on JSPI (engine-floor caveat, visible in
types and in the marker). A pollable is a
thin class over a task-core waitable:
**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;
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):
`block`/`poll` are `suspending()`-marked (A1/A2) with sync fast paths, so
a ready pollable costs one engine hop and only a genuine wait parks the
frame. Timer pollables are real (monotonic-clock subscribe-*). On engines
without JSPI, `chooseMode` degrades to plain and a genuine park raises a
clean `NeedsJspi` at the park site instead of livelocking; `jspi: false`
is the per-instantiation opt-out. Streams stay buffer-backed (sync
`read`/`check-write` never park — sufficient for every known consumer).
`Pollable` is publicly constructible — `new Pollable(ready, wait)` — as
the interop seam for external providers (e.g. consumer-side sockets glue)
whose pollables the kernel `poll()`s uniformly; `wait()` follows the
promise-swap producer shape (settle + re-arm per event; spurious wakes
fine). Consequence for the M2 zero-cost pin: a component importing marked
providers auto-detects into jspi mode on JSPI engines even if it never
parks — "zero-cost plain path" now reads "sync-only plan AND no marked
imports" (see contracts/intrinsics.md). A pollable is a
thin class over host-supplied readiness:

```ts
class Pollable { ready(): boolean; block(): void /* tier (c) only */ }
Expand Down
7 changes: 6 additions & 1 deletion contracts/intrinsics.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,12 @@ discipline). The park is the reference's plain non-cancellable
stays held across it (the #43 hold rule). UNMARKED sync-lowered
Promise-returning host functions still degrade to the clean `NeedsJspi`
capability signal in every mode — marking is the embedder's explicit,
per-declaration opt-in, never inferred. Pinned by
per-declaration opt-in, never inferred. The zero-cost-sync-only pin
narrowed when wasi-shims' parking kernel went always-on (embedder-api.md
A5): its marked `block`/`poll` are auto-detection evidence, so any
wasi-consuming component runs jspi mode on JSPI engines — the plain path
stays zero-cost for components that are sync-only AND import no marked
providers (or instantiate with `jspi: false`). Pinned by
`runtime/tests/embedder/suspending_imports_test.ts` (park round trip,
resume-time realloc, pin-(c) start trap, refusal messages) and the
plain-mode guard in `runtime/tests/async_lower_test.ts`.
9 changes: 7 additions & 2 deletions wasi-shims/src/clocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,13 @@ export function clocks(options: ClocksOptions = {}): { imports: Record<string, u
const monotonic02 = {
now: nowFn,
resolution: (): bigint => RESOLUTION_NS,
subscribeInstant: (_when: bigint): Pollable => new Pollable(),
subscribeDuration: (_when: bigint): Pollable => new Pollable(),
// Real timers (the parking kernel, io.ts): an always-ready timer stub
// livelocks any guest that sleeps by parking — tokio's reactor being
// the known consumer. `block()`/`poll()` on these park the frame until
// the deadline; `ready()` consults the clock, so the wake is exact.
subscribeInstant: (when: bigint): Pollable => Pollable.timer(when, nowFn),
subscribeDuration: (howLong: bigint): Pollable =>
Pollable.timer(nowFn() + howLong, nowFn),
};

const wallClock02 = {
Expand Down
173 changes: 136 additions & 37 deletions wasi-shims/src/io.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,46 @@
// `wasi:io@0.2` — error, poll, streams (contracts/embedder-api.md
// §"WASI examination", the p2 pollable + stream idioms).
// §"WASI examination").
//
// Tier strategy (contract, three-tier): (a) type-only `Pollable` stub — C0
// finding #6 (tools/smoke-c0/REPORT.md §6): the entire consumer corpus links
// `wasi:io/poll` via the libc wasip2 baseline, yet no leg ever called a
// pollable method, so a pollable that is always `ready()` needs no real
// parking; (b) buffer-backed streams — `OutputStream`/`InputStream` serve
// synchronously from host-side buffers, so their fast path never blocks.
// Tier (c) (JSPI suspension) is never implemented in this package.
// THE PARKING KERNEL. `pollable.block()`, `poll()` and `blocking-*` are
// sync WIT functions that must genuinely wait — the one p2 idiom that
// fights a JS host. This package used to ship always-ready stubs (the
// retired "three-tier strategy", grounded in C0 finding #6: no consumer
// leg ever called a pollable method) with real parking documented as
// "never (c) in this package". Both halves of that ruling expired:
//
// * the polymorph-iroh upstream-iroh consumer class (unmodified
// iroh/tokio) parks its reactor in `poll()` with timer + socket
// pollables — the always-ready stubs don't degrade for such a guest,
// they LIVELOCK it (block() no-ops, reads return empty, the frame
// never suspends, so the event loop never turns and no host pump can
// ever make progress);
// * the runtime's suspending-import machinery (embedder-api.md A1/A2)
// made real parking a per-declaration capability with graceful
// degradation, so the kernel is ALWAYS ON rather than an opt-in
// profile: on engines without JSPI, `chooseMode` falls back to plain
// and everything behaves like the old stubs until a guest genuinely
// parks — which then raises a clean `NeedsJspi` at the park site
// instead of livelocking. Embedders wanting guaranteed-plain
// instantiation pass `jspi: false`.
//
// Costs, deliberately confined: only the park-capable declarations are
// marked (`block`, `poll`) — hot-path `read`/`check-write` stay plain —
// and marking flips wasi-consuming components into jspi mode on JSPI
// engines (see the contract note on the narrowed zero-cost pin).
//
// INTEROP SEAM: `Pollable` is publicly constructible —
// `new Pollable(ready, wait)` — because external providers mint pollables
// this kernel must `poll()` uniformly. The known consumer's sockets glue
// (deliberately outside this package, per the delivery ruling) wires
// pollables to datagram queues exactly this way; the reference for the
// wake pattern is polymorph-iroh's shim (promise-swap edge triggering).
//
// Streams stay buffer-backed: `read`/`check-write` serve synchronously
// from host-side buffers, so their fast path never parks — sufficient
// for every known consumer (the iroh class does no stream I/O; its bytes
// ride datagrams).

import { WitError } from "@deltic/runtime/embedder";
import { suspending, WitError } from "@deltic/runtime/embedder";

/** A p2 `stream-error` value (variant): `closed` or `last-operation-failed`. */
export type StreamErrorValue =
Expand Down Expand Up @@ -38,38 +69,108 @@ export class IoError {
}

/**
* Tier (a): a pollable that is always ready.
* A pollable over host-supplied readiness.
*
* WIT-facing surface: `ready()` and `block()` (the latter parks the
* calling wasm frame when unready — @suspending, embedder-api.md A2:
* the class prototype is the brand authority).
*
* CONTRACT (contracts/embedder-api.md §"WASI examination"): `block()` is the
* one p2 idiom that "fights a JS host" — a synchronous WIT function that must
* park. This class takes the tier-(a) default: `ready()` is unconditionally
* `true` and `block()` is a documented no-op, never a real park. Justification
* (C0 finding #6, tools/smoke-c0/REPORT.md §6): across the full consumer
* corpus under test, every leg links `wasi:io/poll` as part of the libc p2
* baseline, but **no leg ever calls a pollable method** — the guests always
* take the eventually-consistent synchronous fast path instead. If a future
* consumer's guest genuinely blocks on a pollable, tier (c) (JSPI-backed
* suspension) is the documented escape hatch; it is deliberately not
* implemented here (mission: "never (c) in this package").
* Host-facing surface: the constructor and `waitPromise()`. `ready` must
* be cheap and side-effect-free; `wait` returns a promise that settles
* when readiness MAY have changed — block/poll re-check and re-wait in a
* loop, so spurious wakes are fine and `wait` is called repeatedly (return
* the CURRENT epoch's promise each call; the promise-swap pattern — settle
* and re-arm on every event — is the intended producer shape). The default
* (no arguments) is an always-ready pollable, the honest shape for
* type-only linkage and never-backpressured sinks.
*/
export class Pollable {
#ready: () => boolean;
#wait: () => Promise<void>;

constructor(
ready: () => boolean = () => true,
wait: () => Promise<void> = () => Promise.resolve(),
) {
this.#ready = ready;
this.#wait = wait;
}

/**
* A pollable that becomes ready at `deadline` (nanoseconds on the
* caller's clock). The timer is armed lazily on first wait and shared
* across waiters; `ready()` consults the clock, so it is exact even if
* the setTimeout fires early/late by a tick.
*/
static timer(deadlineNs: bigint, nowNs: () => bigint): Pollable {
let armed: Promise<void> | undefined;
const wait = (): Promise<void> => {
return armed ??= new Promise((resolve) => {
const deltaMs = Number(deadlineNs - nowNs()) / 1e6;
setTimeout(resolve, Math.max(0, deltaMs));
});
};
return new Pollable(() => nowNs() >= deadlineNs, wait);
}

ready(): boolean {
return true;
return this.#ready();
}

/** Parks the calling wasm frame until ready (sync fast path when
* already ready — no suspension, per-declaration marking only adds the
* engine's continuation hop). */
@suspending
block(): void | Promise<void> {
if (this.#ready()) return;
return (async () => {
while (!this.#ready()) await this.#wait();
})();
}
/** Tier (a) no-op — see class doc. Never parks. */
block(): void {}
}

/** `wasi:io/poll.poll` — tier (a): every pollable is ready, so every index is returned. */
export function poll(pollables: readonly Pollable[]): number[] {
return pollables.map((_p, i) => i);
/** Host-facing (not part of the WIT resource surface): the current
* epoch's wake promise, raced by `poll`. */
waitPromise(): Promise<void> {
return this.#wait();
}
}

/**
* Tier (b): a fixed/empty-buffer input stream.
*
* Serves `read`/`blocking-read` from an in-memory buffer supplied at
* construction (default empty, matching stdin's default in this package).
* `wasi:io/poll.poll` — indices of the ready pollables, parking the
* calling frame until at least one is ready. Sync fast path: if anything
* is ready right now, the indices return without a suspension.
*/
export const poll = suspending(
(pollables: readonly Pollable[]): number[] | Promise<number[]> => {
// io.wit: "poll [...] traps if the list [...] is empty". An unbranded
// host throw is the embedder contract's spelling of a trap.
if (pollables.length === 0) {
throw new Error("wasi:io/poll.poll: empty pollable list");
}
const readyNow = (): number[] => {
const out: number[] = [];
for (let i = 0; i < pollables.length; i++) {
if (pollables[i].ready()) out.push(i);
}
return out;
};
const first = readyNow();
if (first.length > 0) return first;
return (async () => {
for (;;) {
await Promise.race(pollables.map((p) => p.waitPromise()));
const ready = readyNow();
if (ready.length > 0) return ready;
}
})();
},
);

/**
* Buffer-backed input stream: serves `read`/`blocking-read` synchronously
* from an in-memory buffer supplied at construction (default empty,
* matching stdin's default in this package). Blocking degenerates to the
* sync read because the buffer is always immediately available.
*/
export class InputStream {
#buf: Uint8Array;
Expand All @@ -88,7 +189,6 @@ export class InputStream {
return out;
}

/** Tier (b): the buffer is always immediately available; blocking degenerates to `read`. */
blockingRead(len: bigint): Uint8Array {
return this.read(len);
}
Expand All @@ -111,10 +211,9 @@ export class InputStream {
}

/**
* Tier (b): an output stream over a byte sink.
*
* `checkWrite` always reports a large permit (the sink never truly backs
* up), so the synchronous fast path is always taken and `blocking-*` methods
* Buffer-backed output stream over a byte sink. `checkWrite` always
* reports a large permit (the sink never truly backs up), so the
* synchronous fast path is always taken and `blocking-*` methods
* degenerate to their non-blocking counterparts.
*/
export class OutputStream {
Expand Down
94 changes: 94 additions & 0 deletions wasi-shims/tests/blocking_guest_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// The parking kernel through a REAL guest frame (testdata/blocking-guest.wat):
// a sync export parks its own wasm stack on a timer pollable — via
// `pollable.block` (an A2-marked host-resource method) and via `poll` (an
// A1-marked plain import whose list result exercises guest realloc at
// resume time).
//
// FAIL-ON-PRE-FIX: under the retired always-ready stubs this guest
// returned instantly without sleeping (the livelock shape) — the
// elapsed-time assertions are the pin. Under `jspi: false` the park is
// refused cleanly at the park site (NeedsJspi), never livelocked.

import { assertEq, assertTrue } from "./asserts.ts";
import { Translator } from "@deltic/runtime/shim";
import { instantiate } from "@deltic/runtime/embedder";
import { wasiShims } from "../src/mod.ts";

const SHIM_WASM = new URL(
"../../target/wasm32-unknown-unknown/release/translator_shim.wasm",
import.meta.url,
);
const FIXTURE = new URL("testdata/blocking-guest.wasm", import.meta.url);

async function readIfPresent(url: URL): Promise<Uint8Array | null> {
try {
return await Deno.readFile(url);
} catch {
return null;
}
}

const shimBytes = await readIfPresent(SHIM_WASM);
const componentBytes = await readIfPresent(FIXTURE);
const ready = shimBytes !== null && componentBytes !== null;

async function boot(opts: { jspi?: boolean } = {}) {
const translator = await Translator.create(shimBytes!);
return await instantiate(
{ componentBytes: componentBytes!, translator },
{ ...wasiShims() },
opts,
);
}

const NAP_NS = 80_000_000n; // 80ms
const MIN_ELAPSED_MS = 60; // generous CI slack; pre-fix behavior was ~0ms

Deno.test({
name: "blocking guest: pollable.block parks the frame for the full duration",
ignore: !ready,
fn: async () => {
const c = await boot();
const t0 = performance.now();
assertEq(await c.exports.nap(NAP_NS), 1);
const elapsed = performance.now() - t0;
assertTrue(
elapsed >= MIN_ELAPSED_MS,
`nap returned after ${elapsed}ms — the livelock shape (pre-fix: ~0ms)`,
);
},
});

Deno.test({
name: "blocking guest: poll parks, wakes, and lowers its ready list through guest realloc",
ignore: !ready,
fn: async () => {
const c = await boot();
const t0 = performance.now();
assertEq(await c.exports.napPoll(NAP_NS), 1, "one ready index");
const elapsed = performance.now() - t0;
assertTrue(
elapsed >= MIN_ELAPSED_MS,
`nap-poll returned after ${elapsed}ms — the livelock shape (pre-fix: ~0ms)`,
);
},
});

Deno.test({
name: "blocking guest: jspi:false refuses the park cleanly instead of livelocking",
ignore: !ready,
fn: async () => {
const c = await boot({ jspi: false });
let raised: unknown;
try {
await c.exports.nap(NAP_NS);
} catch (e) {
raised = e;
}
assertTrue(raised !== undefined, "expected the park to be refused");
assertTrue(
String(raised).includes("must block"),
`refusal names the blocked frame, got: ${raised}`,
);
},
});
Loading
Loading