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
Activity
- addedbugSomething isn't workingSomething isn't workingp1Correctness bugs likely to impact consumers; high-priority missing featuresCorrectness bugs likely to impact consumers; high-priority missing features
on 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)synchronously
runs the parked reader'son_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-1054drop()→activity.pump()→:249-264rethrows
afterstore.hostFailure ??= e;scheduler.ts:779-788rethrows the trap after
poisoning.Wasmtime: same designated channel, no leak possible. Every host-task
future (including the producer pump created innew_transmit, :2599-2784, and
thepipe_to_guestcompletion, :2387-2411) lives inConcurrentState::futures
and is polled bypoll_until(concurrent.rs:1275-1340); anErrreturns
straight out ofrun_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 the
rethrow out of avoid-ed promise is pure duplicate delivery.
wasmtime: same behavior — host-task errors return throughrun_concurrent; nothing escapes.Severity: keep P1.
p2c_process_crash.tsshows default Deno dying after the
export 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
:1095like
Stream.drop()at:445-449). ThecancelRead/cancelWriteaudit item is
real but distinct: those arevoid-typed public methods that can throw a guest
trap synchronously (contract :429 "never throw" is about drop/cancel on
futures;Stream.cancelRead/StreamWriter.cancelWritehave no explicit
never-throw clause, so file that half as POLICY-QUESTION if split out).- addedp0Known crash or major correctness bugKnown crash or major correctness bugand removedp1Correctness bugs likely to impact consumers; high-priority missing featuresCorrectness bugs likely to impact consumers; high-priority missing features
on 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.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.
producer pump's terminal
writable.drop()is outside the try/catch — a guest trap during it is an unhandled rejection that kills the processSeverity: 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.pumprethrows after recordinghostFailure);runtime/src/task/scheduler.ts:779-789(tickrethrows 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 ofdrop(), out ofpump(), andvoid 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-passconsumeThenTrap([], 1): guest parks on read, empty producer ends, drop wakes it, it traps):p2c_process_crash.ts(nounhandledrejectionlistener, export rejection caught by the host): printsexport rejected (handled): Trap: …then Deno dies witherror: Uncaught (in promise) Trap: guest trapped: unreachable; the trailingconsole.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 inlowerFutureSourceis already wrapped) in a try/catch that only records (store.hostFailure ??=is already done byHostActivity.pump) — the same patternStream.drop()at streams.ts:445-449 already uses. Also auditStream.cancelRead()(432-434) andStreamWriter.cancelWrite()(662-668), which callactivity.pump()without a catch and can throw a guest trap synchronously from avoid-typed method.Running the repro
Scripts below were run from the repository root at baseline
1a4f5e1withthe shim and fixtures built (
just shim fixtures):<REPO>/in the scripts is the absolute path of the checkout (the review ranthem 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.tsp2_pump_drop_unhandled.tsp2c_process_crash.ts