Repository navigation
streams: producer pump's terminal writable.drop() is outside the try/catch — a guest trap during it is an unhandled rejection that kills the process #352
Description
producer pump's terminal writable.drop() is outside the try/catch — a guest trap during it is an unhandled rejection that kills the process
Severity: P1 Confidence: high
Location: runtime/src/embedder/streams.ts:1094-1095 (host.writable.drop() after the try/catch), :999 (void pump(...)); runtime/src/exec/host_streams.ts:1050-1054 (writable.drop → activity.pump()), :249-263 (HostActivity.pump rethrows after recording hostFailure); runtime/src/task/scheduler.ts:779-789 (tick rethrows the trap)
Authority: embedder-api.md §"Streams and futures" — "The runtime attaches rejection handling at the handle so no disposal or abandonment raises an unhandled rejection"; "Component faults are loud … a parked host … and every later operation, rejects PeerTrappedError" (the fault has a designated channel: the export call / hostFailure, not a stray promise).
Expected: when the pump's end-of-stream drop wakes the guest and the guest traps, the trap surfaces on the export call (it does) and/or store.hostFailure; the pump's own promise settles quietly.
Actual: writable.drop() → activity.pump() → store.tick() runs the woken guest thread synchronously; the trap propagates out of drop(), out of pump(), and void pump(...) has no handler. Stack (via --v8-flags=--stack-trace-limit=80, p2b_stack.ts): HostActivity.pump (host_streams.ts:258) ← Object.drop (host_streams.ts:1053) ← pump (streams.ts:1095).
Repro p2_pump_drop_unhandled.ts (stream-pass consumeThenTrap([], 1): guest parks on read, empty producer ends, drop wakes it, it traps):
run1 export call: rejected: Trap: guest trapped: unreachable
run1 unhandled rejections: ["Trap: guest trapped: unreachable"]
control export call: rejected: Trap: guest trapped: unreachable (trap on a data-carrying tick, no pump drop)
control unhandled rejections: []
p2c_process_crash.ts (no unhandledrejection listener, export rejection caught by the host): prints export rejected (handled): Trap: … then Deno dies with error: Uncaught (in promise) Trap: guest trapped: unreachable; the trailing console.log("still alive") never runs.
Distinct from known issues: #168/#84/#100 are about what a parked op observes at teardown; this is a control-flow leak of an already-delivered fault. #346 is direct-session verdict handling. Nothing open covers the pump's terminal drop.
Fix direction: wrap the terminal host.writable.drop() (and, for symmetry, the equivalent in lowerFutureSource is already wrapped) in a try/catch that only records (store.hostFailure ??= is already done by HostActivity.pump) — the same pattern Stream.drop() at streams.ts:445-449 already uses. Also audit Stream.cancelRead() (432-434) and StreamWriter.cancelWrite() (662-668), which call activity.pump() without a catch and can throw a guest trap synchronously from a void-typed method.
Running the repro
Scripts below were run from the repository root at baseline 1a4f5e1 with
the shim and fixtures built (just shim fixtures):
deno run --config runtime/deno.json --allow-read --allow-env=POLYENGINE_SCHED_SEED <script>
<REPO>/ in the scripts is the absolute path of the checkout (the review ran
them from outside the tree). Each repro was run at least twice and once under a
nonzero POLYENGINE_SCHED_SEED; output was identical across runs.
h.ts
// Shared harness for the embedder-handles adversarial track.
// Run from the repo root:
// deno run --config runtime/deno.json --allow-read --allow-env=POLYENGINE_SCHED_SEED <script>
const REPO = "<REPO>/";
export const root = REPO;
export const { instantiate } = await import(
REPO + "runtime/src/embedder/mod.ts"
);
export const { Translator } = await import(REPO + "runtime/src/shim/mod.ts");
export const streamsMod = await import(REPO + "runtime/src/embedder/streams.ts");
export const hostStreamsMod = await import(
REPO + "runtime/src/exec/host_streams.ts"
);
export const taskMod = await import(REPO + "runtime/src/task/mod.ts");
const shim = await Deno.readFile(
REPO + "target/wasm32-unknown-unknown/release/translator_shim.wasm",
);
export const translator = await Translator.create(shim);
export const turn = () => new Promise<void>((r) => setTimeout(r, 0));
export const micro = () => Promise.resolve();
export async function fixture(
rel: string,
imports: Record<string, unknown> = {},
opts: Record<string, unknown> = {},
) {
const componentBytes = await Deno.readFile(REPO + rel);
const { plan, adapters } = translator.translate(componentBytes);
return await instantiate({ plan, adapters, componentBytes }, imports, opts);
}
export const emb = (name: string) => `runtime/tests/embedder/${name}.wasm`;
export const guest = (name: string) =>
`examples/guests/build/${name}.component.wasm`;
export const unhandled: string[] = [];
export const unhandledStacks: string[] = [];
globalThis.addEventListener("unhandledrejection", (ev) => {
ev.preventDefault();
const reason = (ev as PromiseRejectionEvent).reason;
unhandled.push(String(reason));
unhandledStacks.push(
reason instanceof Error ? (reason.stack ?? "") : String(reason),
);
});
export async function caught(f: () => unknown): Promise<unknown> {
try {
await f();
} catch (e) {
return e;
}
return undefined;
}
export function timeout<T>(p: Promise<T>, ms = 200): Promise<T | "TIMEOUT"> {
return Promise.race([
p,
new Promise<"TIMEOUT">((r) => setTimeout(() => r("TIMEOUT"), ms)),
]);
}
export function show(label: string, v: unknown) {
console.log(label + ":", typeof v === "string" ? v : JSON.stringify(v));
}
export const errStr = (e: unknown) =>
e instanceof Error ? `${e.name}: ${e.message}` : String(e);p2_pump_drop_unhandled.ts
// Probe 2: the producer pump's terminal `host.writable.drop()` is outside its
// try/catch. If the drop's store pump runs the guest and the guest traps,
// does the `void pump()` promise reject unhandled?
import { caught, errStr, fixture, guest, show, timeout, turn, unhandled } from "./h.ts";
for (const label of ["run1", "run2"]) {
const c = await fixture(guest("stream-pass"), { sink: async () => 0n }, {
jspi: false,
});
// Guest parks on input.next(); the pump ends with zero elements; the drop
// wakes the guest which gets None and traps (unreachable).
const call = c.exports.consumeThenTrap([], 1);
const e = await timeout(call.then(() => "resolved", (x: unknown) => "rejected: " + errStr(x)), 500);
show(label + " export call", e);
await turn();
await turn();
show(label + " unhandled rejections", unhandled.splice(0));
}
// Control: same guest, but the trap happens with no host stream drop involved
// (the host stream has data and the guest reads then traps on its own tick).
{
const c = await fixture(guest("stream-pass"), { sink: async () => 0n }, {
jspi: false,
});
const call = c.exports.consumeThenTrap([1], 1);
const e = await timeout(call.then(() => "resolved", (x: unknown) => "rejected: " + errStr(x)), 500);
show("control export call", e);
await turn();
await turn();
show("control unhandled rejections", unhandled.splice(0));
}p2c_process_crash.ts
// F2 without any unhandledrejection listener: the process dies.
const REPO = "<REPO>/";
const { instantiate } = await import(REPO + "runtime/src/embedder/mod.ts");
const { Translator } = await import(REPO + "runtime/src/shim/mod.ts");
const translator = await Translator.create(await Deno.readFile(REPO + "target/wasm32-unknown-unknown/release/translator_shim.wasm"));
const componentBytes = await Deno.readFile(REPO + "examples/guests/build/stream-pass.component.wasm");
const { plan, adapters } = translator.translate(componentBytes);
const c = await instantiate({ plan, adapters, componentBytes }, { sink: async () => 0n }, { jspi: false });
try { await c.exports.consumeThenTrap([], 1); } catch (e) { console.log("export rejected (handled):", String(e)); }
await new Promise((r) => setTimeout(r, 50));
console.log("still alive");
Activity
lannbot commentedon Sep 12, 2026
Cross-check against the pinned Component Model reference (
definitions.py@7c67611) and wasmtime4675ee1. Verdict vocabulary: SPEC-BACKED = the reference mandates the expected behavior; CONTRACT-ONLY = spec silent, polyengine's own contract decides; POLICY-QUESTION = neither decides; WEAKENED = part of the claim is overstated (corrections below).Spec: silent — d.py has no host pump, no JS promises, and no "unhandled" state;
a trap ends the whole computation (
trap_if). The only relevant modeled fact:SharedStreamImpl.drop→reset_and_notify_pending(DROPPED)synchronouslyruns the parked reader's
on_copy_done, i.e. the drop is the wake-up whosecontinuation (in polyengine,
store.tick()→ guest resume) can trap. The specdoes not say where that trap goes; polyengine's channel is embedding policy.
Contract: :433-435 "The runtime attaches rejection handling at the handle so no
disposal or abandonment raises an unhandled rejection" — written for handle
disposal, but the pump's end-of-stream drop is the producer's disposal of its
writable end and falls under the same clause's intent. :145-147 "A trap escaping
a guest activation poisons its instance … Catching its host-side rejection does
not restore that instance" and :177-178 "Background faults are recorded for
pending operations and subsequent entry" designate the channels (export
rejection,
hostFailure). Nothing in the contract permits a second, unowneddelivery. Confirmed in code:
embedder/streams.ts:1079-1095— try/catch closesat :1082,
host.writable.drop()at :1095 is outside it;:999 void pump(...);host_streams.ts:1050-1054drop()→activity.pump()→:249-264rethrowsafter
store.hostFailure ??= e;scheduler.ts:779-788rethrows the trap afterpoisoning.
Wasmtime: same designated channel, no leak possible. Every host-task
future (including the producer pump created in
new_transmit, :2599-2784, andthe
pipe_to_guestcompletion, :2387-2411) lives inConcurrentState::futuresand is polled by
poll_until(concurrent.rs:1275-1340); anErrreturnsstraight out of
run_concurrent(:1333Err(e) => return Poll::Ready(Err(e))).A guest trap raised while delivering the end-of-stream event surfaces the same
way. There is no second place for the error to go.
Verdict: CONTRACT-ONLY — the spec is inapplicable (no host pump); the
contract's no-unhandled-rejection and designated-channel clauses decide it, and
HostActivity.pumpalready recordshostFailurebefore rethrowing, so therethrow out of a
void-ed promise is pure duplicate delivery.wasmtime: same behavior — host-task errors return through
run_concurrent; nothing escapes.Severity: keep P1.
p2c_process_crash.tsshows default Deno dying after theexport rejection was already handled; that is a crash induced by a guest
unreachable, reachable with the publiccreateStream/array-producer API.Notes: the fix is the one-liner the issue names (wrap
:1095likeStream.drop()at:445-449). ThecancelRead/cancelWriteaudit item isreal but distinct: those are
void-typed public methods that can throw a guesttrap synchronously (contract :429 "never throw" is about drop/cancel on
futures;
Stream.cancelRead/StreamWriter.cancelWritehave no explicitnever-throw clause, so file that half as POLICY-QUESTION if split out).
lannbot commentedon Sep 12, 2026
Re-evaluated on current main
5616bce, Deno 2.9.5. Promote P1 → P0, matching the repository label's “Known crash or major correctness bug” definition.Replayed both the listener-observed probe and the no-listener subprocess. The subprocess prints that the export rejection was handled, then exits 1 with
Uncaught (in promise) Trap; its final “still alive” marker never runs. An empty producer plus a guest trap crosses the intended failure boundary and terminates the host process without host API misuse. Fix first: observe the pump's terminal-drop failure through the designated fault channel and prevent the duplicate unhandled rejection. Preserve that failure's recorded cause rather than swallowing it globally.lannbot commentedon Sep 13, 2026
Verified the #352 fix survives the #369 lifecycle refactor on main
ae86a4a. Both empty-producer runs and the data-carrying control produce the expected export Trap with zero unhandled rejections. The no-listener subprocess exits 0 and printsstill alive. Both committed regression modes pass as part of 57 focused tests. Remains resolved.