Repository navigation
embedder: result-conversion failure of a host import skips releaseAsyncArgs — stream/future arguments stay parked forever #356
Description
Activity
- addedbugSomething isn't workingSomething isn't workingp2Minor bugs; desirable lower-priority featuresMinor bugs; desirable lower-priority 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:
canon_lower.on_resolve(definitions.py 2243-2255):flat_results = lower_flat_values(cx, max_flat_results, result, ft.result_type(), flat_args)— a failure lowering the host's result is atrap(), which ends the store; the guest's stream/future ends and their peers die with it. "Peer waits forever" is unreachable in the reference. The spec is silent on the polyengine-specific question (a non-trapping failure class for the async arm,store.hostFailure) — that is #118's territory.
Contract: §"Streams and futures": "A trapping host import drops abandoned top-level stream/future arguments; this cleanup does not traverse compound arguments. Their peers can then settle rather than waiting on abandoned arguments." §"Error model": "An unbranded throw from a host import is a host bug and traps". The issue quotes these accurately. The sync arm does poison the instance on conversion failure (observedpoisoned:true), so by the runtime's own treatment it is a trapping import and the drop obligation applies verbatim; the code path (ok(out)at instantiate.ts:806 andconvertat :798-801) bypassesonReject(:767-769). For the async arm the contract does not decide whether a conversion failure is a trap (that is #118), but the "peers can then settle" obligation is the same either way once the call is failed.
Wasmtime: same outcome class, different mechanism.call_sync_lower(func/host.rs:384-420):Self::lower_raw(&mut lower, ty, ret, dst)returnsErron a result-lowering failure, propagates as a trap into the wasm frame,catch_traps→set_trapped()(func.rs:1476-1479); the whole store is dead, so no peer can wait. Wasmtime has no "typed host value fails to convert" class —R: Loweris checked at compile time; the only runtime result-lowering failures are realloc/memory bounds, which are traps. No test pins the peer-settlement question because it cannot arise.
Verdict: CONTRACT-ONLY — spec: whole-store failure; contract clause is explicit and the sync arm already satisfies its precondition ("trapping host import") while skipping the obligation.
wasmtime: same behavior in effect (a result-lowering failure is a trap that kills the store, so peers never wait), by a coarser mechanism.
Severity: keep P2 — a hostwriteAll/writepends indefinitely after a failure the runtime already classifies as poisoning; deterministic; contract-explicit.
Notes: Keep the fix independent of #118: route bothok()andfail()conversion failures throughonReject(e)regardless of whether the async arm is later made trapping. The fallible-payload variant (fail(e)throwing while converting aComponentExceptionpayload) is correctly included — it is the same site.Re-evaluated on current main
5616bce, Deno 2.9.5. Keep P2. Both malformed-result variants strand the peer writes; thrown/rejected import controls settle them. Unlike #354/#355's transient refusal path, the main trigger requires a malformed host return. Cleanup can be fixed independently of #118's error-classification decision.Route failures converting both success and error payloads through abandonment cleanup. Do not mistake a valid
ComponentExceptionreturn for a trapping import, and do not let cleanup replace the original conversion error.Correction to the appended case E: the original probe's Future materializes during the awaited second instantiation. A diagnostic replay reports successful
RETURNED(2), not a swallowed conversion failure. A guaranteed-deferred follow-up surfacesTypeErrornormally and still leaks the stream prefix. Thus partial import-result construction needs cleanup, but the claim that this probe proves lost error reporting is unsupported. See the matching correction on #355.Rechecked current main
ae86a4aafter #369, Deno 2.9.5. Still reproducible; keep P2/open.f1_conversion_failure_args.ts: both synchronous non-u32 return and asynchronously fulfilled non-u32 return leave the stream writer pending and future write timing out. Synchronous throw / async rejection controls still release the arguments. The synchronous malformed-result case poisons; the asynchronous case reports the TypeError throughhostFailureonce and leaves the instance live.The new lifecycle fixes invocation/thenable-observation failure cleanup, but
finishHostCallcallshooks.finishwithout routing preparation/conversion errors throughhooks.reject. Prepared-value custody owns newly acquired result values, not the top-level async arguments already given to the host import. Those arguments still require the failure cleanup this issue asks for.The partial-result construction subcase from #355 is fixed by #369; it does not close this import-argument abandonment defect. #118's error-classification policy remains separate.
result-conversion failure of a host import skips
releaseAsyncArgs— stream/future arguments stay parked foreverSeverity: P2 Confidence: high
Location:
runtime/src/embedder/instantiate.ts:805-806(sync return:scope.end(); return ok(out);),:798-801(convert→ok(settlement.value)/fail(settlement.error)),:767-769(onRejectis reached only from the throw/rejection arms),:1287-1298(releaseAsyncArgs); consumed atruntime/src/exec/boundary.ts:1888-1892(async armconvertunder try/catch →store.hostFailure) and:1921(sync arm, throw propagates through wasm). (baseline 1a4f5e1)Authority:
contracts/embedder-api.md§"Streams and futures" — "A trapping host import drops abandoned top-level stream/future arguments; this cleanup does not traverse compound arguments. Their peers can then settle rather than waiting on abandoned arguments." §"Error model" — "An unbranded throw from a host import is a host bug and traps".Expected: A host import that fails by returning a value the adapter cannot convert (a
TypeErrorfromfromHost, e.g."not a number"for au32result) fails the call exactly as a thrown host bug does: in the sync arm it traps and poisons the instance — and in both arms the top-levelStream/Futurearguments handed to that import are torn down so their peers (here the host writer'swriteAlland the host future'swrite) settle.Actual: Conversion failure happens after
dispatch(args)returned, inok(out)(sync) or inconvertinside the boundary's settlement continuation (async). Neither path runsonReject→releaseAsyncArgs. The sync variant poisons the instance (so it is a trapping import by the runtime's own treatment) yet leaves the argument ends alive and orphaned in the host's dead closure; the async variant recordsstore.hostFailure(surfaced once on the next unrelated entry,ping) and likewise never releases the arguments. In both cases the peer writer stays pending indefinitely; the throw controls settle immediately.Repro:
f1_conversion_failure_args.ts(fixtureruntime/tests/embedder/host-settlement.wasm, exportconsume(stream, future, 0)):Distinct from known issues: #343 is the throwing
thengetter at theisThenableprobe (instantiate.ts:778); this is the result-conversion site (ok()/convert, :806/:800), a different throw origin that #343's proposed fix (wrap theisThenablecheck) does not cover, and it also has an async-arm variant that #343 lacks. #118 asks whether conversion failures should trap; here the sync arm already traps and poisons — the missing piece is the trapping-import cleanup obligation, independent of how #118 is settled.Fix direction: route
ok()/fail()conversion failures through the sameonReject(e)teardown as a thrown import body — in the sync arm by wrappingok(out)in the existing try/catch shape, in the deferred arm by callingonReject(e)insideconvert's failure path (or catching aroundok(settlement.value)before rethrowing). The fallible-payload variant (fail(e)throwing aTypeErrorwhile converting aComponentExceptionpayload) sits on the same path and should be included.Related observation from the embedder-handles track (
p1_partial_lower.tscase E, embedded in the sibling issue on export-side argument orphaning): for an import whose result is a record containing a stream and a still-deferredFuture, the conversionTypeErrordid not surface anywhere (call resolved,store.hostFailureundefined) and the sourceReadableStreamstayed locked — the sameok(out)site, with the failure lost entirely rather than recorded.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.support.tsf1_conversion_failure_args.ts