Repository navigation
streams: invalid direct callback diagnostics reject the peer and leave the session parked #346
Description
Activity
- addedbugSomething isn't workingSomething isn't workingp2Minor bugs; desirable lower-priority featuresMinor bugs; desirable lower-priority features
on Sep 12, 2026 Triage: P2 / bug — fourth in this batch
Independently reproduced via
Stream.create,writeDirect(() => 1n), andread(1)onmainat1a4f5e1: the direct writer remains pending while the reader rejects with the JSON serializationTypeError. Source review confirms diagnostic construction escapes before#fail.This breaks failure attribution and leaves the session busy, so it deserves a fix. P2 reflects the malformed direct callback verdict needed to trigger it. The change is small and can be picked up independently of the scheduler work.
Fix-direction correction:
String(verdict)is not guaranteed safe;Symbol.toPrimitive,toString, or Proxy behavior can throw. Even a fallback afterJSON.stringifymust not perform another unguarded conversion. A fixed message or non-coercingtypeofdescription is sufficient; formatting the invalid value is not worth extra failure paths.Acceptance: invalid verdicts reject the direct session, preserve the peer operation for a later valid rendezvous, discard marks, and leave no busy session. Cover BigInt, cycles, throwing coercion/
toJSON, and both read-direct/write-direct orientations. Verify peer reuse, not just which promise rejects.Batch order: #342 → #345 → #343 → #346 → #347 → #344; no hard dependencies.
Component Model / Wasmtime verification
P2 unchanged; this is polyengine's direct-session error contract.
Checked Component Model
7c676115and Wasmtime4675ee16. The CM buffer/copy model does not containwriteDirect/readDirect, a"more" | "done"callback verdict, JSON diagnostics, or a recoverable direct-session failure that preserves the peer's pending operation. These are embedding API choices.Wasmtime uses
StreamResult, a Rust enum, returned asPoll<Result<StreamResult>>. Safe host code cannot return a BigInt, cyclic object, or other arbitrary runtime verdict. Its direct source/destination views are lifetime-bound buffer accessors, not polyengine's callback protocol.The error semantics also differ:
StreamProducerdocuments thatErr(_)traps the guest and renders its component instance unusable (lines 733–740). Invalid producer progress combinations return errors using fixed diagnostic text (lines 2680–2688 and 2719–2737). Wasmtime therefore does not corroborate “only the direct session rejects and the peer survives”; polyengine's own contract requires that behavior.No exact native malformed-verdict test is meaningful for this typed API. The source comparison reinforces the narrow fix: diagnostic construction must not interrupt the already-defined local failure route. Avoid unguarded
String()as well asJSON.stringify().Reverified on current main ae86a4a after the host-boundary preparation/lifecycle refactor (#369). Both orientations reproduce: writeDirect returning 1n leaves the direct writer pending and rejects the arriving reader with the JSON BigInt TypeError; readDirect returning 1n symmetrically leaves the direct reader pending and rejects the arriving writer. The callback itself is caught, but JSON.stringify(verdict) at host_streams.ts:555–563 still runs outside that catch and before #fail. Consequently the shared stream never receives the failed outcome needed by #routeDirectNoCopy to retain/repark the peer correctly. The new host-value preparation machinery does not cover direct byte callbacks (they intentionally operate at the rendezvous without intermediate value conversion). Recommended fix remains deletion of invalid-value serialization: use a fixed diagnostic, or typeof only. Regression coverage should check both orientations and arrival orders, invalid values including BigInt/cycles/throwing coercion, zero committed progress, direct-session rejection, and completion of the original peer operation after a later valid rendezvous. No product changes made; P2 unchanged.
Summary
When a direct-access byte-edge callback returns an invalid verdict that
itself is unserializable (e.g.
bigint), the diagnostic-messageconstruction throws before the session is failed. The result violates the
documented contract for invalid-verdict handling
(
contracts/embedder-api.md§"Streams and futures" ("Direct-access byteedges")): instead of failing the direct session and leaving the peer's
parked operation undisturbed, the peer (the side that happened to be
arriving) is rejected with a raw serialization
TypeError, and thedirect-session side is left permanently pending — never resolved, never
rejected — until the caller manually cancels and drops the stream.
Where
runtime/src/exec/host_streams.ts:555-563(
#runDirect, baseline1a4f5e1):JSON.stringify(verdict)is evaluated as part of building the argument tonew TypeError(...), i.e. beforethis.#fail(...)is called. Ifverdictis abigint(or contains abigint, a circular reference, etc.— anything
JSON.stringifyitself throws on), theTypeErrorallocationthrows first. That throw is not caught by the
try { verdict = this.invoke(scope) } catch (e) { ... }block a few lines above (lines 545-553) — it only wraps the callback
invocation itself, not the subsequent verdict-validation code — so it
escapes
#runDirectentirely, uncaught, and propagates throughruntime/src/task/streams.ts:269-270(
rendezvousCopy'ssrc.runDirect(...)/dst.runDirect(...)call) intowhichever
SharedStreamImpl.read/.write(
runtime/src/task/streams.ts:417-518)call happened to trigger the rendezvous — i.e. into the arriving peer's
call frame, not the direct session's.
Contract
Per
#routeDirectNoCopy's own doc comment(
runtime/src/task/streams.ts:520-526):and the
"failed"DirectOutcomevariant's own doc(
runtime/src/task/streams.ts:238-239):The contract is: an invalid-callback-verdict failure rejects the direct
session and leaves the peer's parked operation untouched (to be
re-satisfied normally later). This bug instead: (1) never rejects the
direct session (
#failis never reached), and (2) rejects the peerwith an unrelated internal
TypeError("Do not know how to serialize aBigInt") that has nothing to do with the peer's own operation.
Expected vs actual
Expected (per contract above): the direct-callback side's pending
operation rejects with the
"a direct-access callback must return..."diagnostic; the peer's parked operation is left undisturbed.
Actual, reproduced against
1a4f5e1c6f3fc906ce576acdc4499ef2745d6e41:rejects with a raw JS serialization error instead of any documented
diagnostic.
on its own: a same-session retry write (
writer.write(...)) throws"stream busy", confirming the session is stuck pending, not failed.
It only resolves — with 0 progress — after the caller calls
cancelWrite()anddrop()from outside.Repro
Standalone, repo-relative (run from repo root):
(Or save the block above as a
.tsfile anywhere and rundeno run --allow-read --config runtime/deno.json <file>— the importpath is relative to wherever the file lives, so adjust
./runtime/src/embedder/streams.tsaccordingly.)Verified twice against the same commit; deterministic both runs.
Suggested fix direction
Build the diagnostic
TypeErrormessage using a serialization thatcannot itself throw — e.g. guard
JSON.stringify(verdict)with atry/catch (falling back to
String(verdict)ortypeof verdict), orString(verdict)directly, since the message only needs to name what wasreturned, not round-trip it. That keeps the existing
this.#fail(...); return "failed";path reachable for any invalidverdict, matching the "misuse or a throwing callback; the session has
already rejected" contract for all misuse shapes, not just the ones whose
verdict happens to be JSON-serializable.