Skip to content

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

@lannbot

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

  1. added
    bugSomething isn't working
    p1Correctness bugs likely to impact consumers; high-priority missing features
    on Sep 12, 2026
  2. lannbot commented on Sep 12, 2026

    @lannbot
    CollaboratorAuthor

    Cross-check against the pinned Component Model reference (definitions.py @ 7c67611) and wasmtime 4675ee1. 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) synchronously
    runs the parked reader's on_copy_done, i.e. the drop is the wake-up whose
    continuation (in polyengine, store.tick() → guest resume) can trap. The spec
    does 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, unowned
    delivery. Confirmed in code: embedder/streams.ts:1079-1095 — try/catch closes
    at :1082, host.writable.drop() at :1095 is outside it; :999 void pump(...);
    host_streams.ts:1050-1054 drop() → activity.pump() → :249-264 rethrows
    after store.hostFailure ??= e; scheduler.ts:779-788 rethrows the trap after
    poisoning.

    Wasmtime: same designated channel, no leak possible. Every host-task
    future (including the producer pump created in new_transmit, :2599-2784, and
    the pipe_to_guest completion, :2387-2411) lives in ConcurrentState::futures
    and is polled by poll_until (concurrent.rs:1275-1340); an Err returns
    straight out of run_concurrent (:1333 Err(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.pump already records hostFailure before rethrowing, so the
    rethrow 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.ts shows default Deno dying after the
    export rejection was already handled; that is a crash induced by a guest
    unreachable, reachable with the public createStream/array-producer API.

    Notes: the fix is the one-liner the issue names (wrap :1095 like
    Stream.drop() at :445-449). The cancelRead/cancelWrite audit item is
    real but distinct: those are void-typed public methods that can throw a guest
    trap synchronously (contract :429 "never throw" is about drop/cancel on
    futures; Stream.cancelRead/StreamWriter.cancelWrite have no explicit
    never-throw clause, so file that half as POLICY-QUESTION if split out).

  3. added
    p0Known crash or major correctness bug
    and removed
    p1Correctness bugs likely to impact consumers; high-priority missing features
    on Sep 12, 2026
  4. lannbot commented on Sep 12, 2026

    @lannbot
    CollaboratorAuthor

    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.

  5. lannbot commented on Sep 13, 2026

    @lannbot
    CollaboratorAuthor

    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 prints still alive. Both committed regression modes pass as part of 57 focused tests. Remains resolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingp0Known crash or major correctness bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions