Repository navigation
boundary: a NeedsJspi capability exit from a callback-ABI task leaks exclusiveThread; every later async export of the un-poisoned instance hangs silently #348
Description
a NeedsJspi capability exit from a callback-ABI task leaks exclusiveThread; every later async export of the un-poisoned instance hangs silently
Severity: P1 Confidence: high
Location: runtime/src/exec/boundary.ts:1189-1208 (unwind releases lender scopes and mayLeave only), :1226-1227 + :1240-1244 (capability signals unwind without poisoning; nothing releases the task's exclusivity); the leaked slot is runtime/src/task/mod.ts:190-193 (enterImplicitThread takes inst.exclusiveThread), released only by exitImplicitThread at :215-226, which the unwound task never reaches; later tasks wait at :171-181. Trigger sites in this territory: boundary.ts:1818-1836 (sync-typed import returned a Promise: unmarked in any mode, or marked with jspi:false) and runtime/src/intrinsics/async_builtins.ts:452-458 (sync-form subtask.cancel on a deferCancel() import in plain mode). (baseline 1a4f5e1)
Authority: contracts/embedder-api.md §"Import marks" — "on a non-JSPI engine a returned Promise is refused at the call site (NeedsJspi), never degraded"; deferCancel(): "the sync form parks under jspi (NeedsJspi without it)"; §"sync()" failure ladder: "1–3 are non-poisoning", item 3 "A blocking built-in through the plain entry: NeedsJspi". The runtime's own tests pin "capability refusal does not poison" (host_settlement_test.ts:155-159). definitions.py Task.exit_implicit_thread / enter_implicit_thread: the exclusive slot is bracketed on the whole core invocation and must be released when the task ends.
Expected: The refused call rejects with NeedsJspi, the instance is not poisoned, and it stays usable — the whole point of a non-poisoning refusal.
Actual: The NeedsJspi unwinds a needs-exclusive (callback-ABI, i.e. every Rust wit-bindgen async export) task's wasm frame; unwind() never releases inst.exclusiveThread. The instance is reported healthy (poisoned:false, sync ping works) but every subsequent callback-ABI export parks at enterImplicitThread forever (numWaitingToEnter climbs, promises never settle — Deno reports "Top-level await promise never resolved" when nothing else is pending). No diagnostic anywhere. Three routes reproduce it: (A) sync-form subtask.cancel on a deferCancel() import with jspi:false — exactly the outcome the contract documents; (B) suspending() import returning a Promise with jspi:false; (C) JSPI mode, unmarked import returning a Promise (host forgot the mark).
Repro: f3_needsjspi_exclusive_leak.ts (guest examples/guests/cancel-import):
control: jspi=true cancelDefer {"after":{"trigger":"<none>","exclusiveThreadHeld":false,...},"nextAsyncExport":"ok","secondAsyncExport":"ok"}
control: plain cancelInflight {"after":{"trigger":"<none>","exclusiveThreadHeld":false,...},"nextAsyncExport":"ok","secondAsyncExport":"ok"}
route A: plain cancelDefer {"after":{"trigger":"NeedsJspi: ... synchronous subtask.cancel whose callee did not resolve immediately","poisoned":false,"exclusiveThreadHeld":true,"syncPing":"<none>"},"nextAsyncExport":"TIMEOUT 800ms","secondAsyncExport":"TIMEOUT 800ms","exclusiveThreadHeldAtEnd":true,"numWaitingToEnterAtEnd":2}
route B: plain blockFor(susp.) {"after":{"trigger":"NeedsJspi: ... synchronous lower of import 'block', whose host implementation returned a Promise (the guest's wasm frame must block)","poisoned":false,"exclusiveThreadHeld":true},"nextAsyncExport":"TIMEOUT 800ms","secondAsyncExport":"TIMEOUT 800ms","numWaitingToEnterAtEnd":2}
route C: jspi=true blockFor(unmarked) {"after":{"trigger":"NeedsJspi: ... a sync-typed import may only park the frame when declared with su...","poisoned":false,"exclusiveThreadHeld":true},"nextAsyncExport":"TIMEOUT 800ms","secondAsyncExport":"TIMEOUT 800ms","numWaitingToEnterAtEnd":2}
Distinct from known issues: no open issue covers it; #212 (jspi trapping cancellation delivery poisons via settled-tail) is about a poisoning path, this is the non-poisoning one leaving the instance wedged. Not #249 (sync-call scheduling).
Fix direction: either (a) make the capability exit release the task's exclusivity in unwind() (release inst.exclusiveThread when it is the unwound task's implicit thread, and unregister the thread) so the refusal is genuinely non-poisoning, or (b) drop the pretence and poison on capability exits reached from inside a live guest activation (a wasm frame unwound by an exception has already lost its shadow-stack pointer and any in-flight subtasks; "non-poisoning" is only honest for exits that happen before entering). Either way the current state — healthy-looking instance, silent permanent hang of all async exports — is the worst of both.
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.
support.ts
// Shared fixture plumbing for the import-boundary review probes.
// Run from the repo root:
// deno run --config runtime/deno.json --allow-read --allow-env=POLYENGINE_SCHED_SEED <script>
export const REPO = "<REPO>/";
import { Translator } from "<REPO>/runtime/src/shim/mod.ts";
import {
type EmbedderOptions,
instantiate,
} from "<REPO>/runtime/src/embedder/mod.ts";
const shim = await Deno.readFile(
REPO + "target/wasm32-unknown-unknown/release/translator_shim.wasm",
);
export const translator = await Translator.create(shim);
export async function fixture(
rel: string,
imports: Record<string, unknown>,
opts: EmbedderOptions = {},
) {
const componentBytes = await Deno.readFile(REPO + rel);
const { plan, adapters } = translator.translate(componentBytes);
return await instantiate({ plan, adapters, componentBytes }, imports, opts);
}
export const turn = () => new Promise<void>((r) => setTimeout(r, 0));
export const micro = () => Promise.resolve();
export async function caught(f: () => unknown): Promise<unknown> {
try {
await f();
} catch (e) {
return e;
}
return undefined;
}
export function describeErr(e: unknown): string {
if (e === undefined) return "<none>";
if (e instanceof Error) return `${e.name}: ${e.message.slice(0, 160)}`;
return String(e);
}
export function guest(name: string): string {
return `examples/guests/build/${name}.component.wasm`;
}f3_needsjspi_exclusive_leak.ts
// F3: a NeedsJspi capability exit from a callback-ABI task leaks the
// instance's exclusive-thread slot; every later needs-exclusive export of the
// (unpoisoned) instance then waits forever at enterImplicitThread.
//
// Route A (documented contract outcome): sync-form subtask.cancel on a
// deferCancel() import in plain mode -> "NeedsJspi without it".
// Route B: suspending() import returning a Promise with jspi:false
// -> "a returned Promise is refused at the call site (NeedsJspi)".
import { caught, describeErr, fixture, guest, turn } from "./support.ts";
import { abortable, deferCancel, suspending } from "<REPO>/protocol/src/mod.ts";
import { hasRealHostCall, isInstancePoisoned } from "<REPO>/runtime/src/task/mod.ts";
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const withTimeout = (p: Promise<unknown>, ms: number) =>
Promise.race([p.then(() => "ok", (e) => "rejected: " + describeErr(e)), delay(ms).then(() => `TIMEOUT ${ms}ms`)]);
async function make(jspi: boolean, markBlock = true) {
const blockImpl = (ms: bigint) => delay(Number(ms));
return await fixture(guest("cancel-import"), {
sleep: (ms: bigint) => delay(Number(ms)),
block: markBlock ? suspending(blockImpl) : blockImpl,
"sleep-defer": deferCancel((ms: bigint) => delay(Number(ms))),
timers: { "sleep-defer": deferCancel((ms: bigint) => delay(Number(ms))) },
"sleep-abort": abortable((ms: bigint, _s: AbortSignal) => delay(Number(ms))),
}, { jspi });
}
async function probe(label: string, jspi: boolean, trigger: (c: any) => Promise<unknown>, markBlock = true) {
const c = await make(jspi, markBlock);
const inst = c.handle.componentInstances[0];
const store = inst.store;
const trig = await caught(() => trigger(c));
await turn();
const after = {
trigger: describeErr(trig),
poisoned: isInstancePoisoned(inst),
exclusiveThreadHeld: inst.exclusiveThread !== null,
numWaitingToEnter: inst.numWaitingToEnter,
pendingHostCalls: store.pendingHostCalls.size,
hostFailure: describeErr(store.hostFailure),
syncPing: describeErr(await caught(() => c.exports.ping())),
};
// Any needs-exclusive (callback-ABI async) export afterwards:
const next = await withTimeout(c.exports.cancelInflight(20n), 800);
const next2 = await withTimeout(c.exports.runAbortable(20n), 800);
await delay(250); // let the deferred host timers settle
console.log(label, JSON.stringify({
after, nextAsyncExport: next, secondAsyncExport: next2,
exclusiveThreadHeldAtEnd: inst.exclusiveThread !== null,
numWaitingToEnterAtEnd: inst.numWaitingToEnter,
pendingHostCallsAtEnd: store.pendingHostCalls.size, realAtEnd: hasRealHostCall(store),
}));
}
await probe("control: jspi=true cancelDefer ", true, (c) => c.exports.cancelDefer(100n));
await probe("control: plain cancelInflight ", false, (c) => c.exports.cancelInflight(100n));
await probe("route A: plain cancelDefer ", false, (c) => c.exports.cancelDefer(100n));
await probe("route B: plain blockFor(susp.) ", false, (c) => c.exports.blockFor(100n));
await probe("route C: jspi=true blockFor(unmarked)", true, (c) => c.exports.blockFor(100n), false);
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 on capability exits (the reference has no "refuse to block":
canon_lowersync arm simply doesthread.wait_until(subtask.resolved)).What the spec does say about lock lifetime is unambiguous:
Task.enter_implicit_thread—if self.needs_exclusive(): ... self.inst.exclusive_thread = self.implicit_thread;Task.exit_implicit_thread—
if self.ft.async_ and self.needs_exclusive(): assert(self.inst.exclusive_thread is self.implicit_thread); self.inst.exclusive_thread = None; and incanon_liftthe callback loop re-takes the lock before each callback invocation
(
assert(inst.exclusive_thread is None); inst.exclusive_thread = task.implicit_thread). CanonicalABI.md prose (line 765-767): "theTask.exit_implicit_threadmethod is called before a task's implicit threadreturns to reverse the effects of
Task.enter_implicit_thread. In particular,if the
exclusive_threadlock was acquired, it is released." The only otherway an implicit thread ends in the reference is a
Trap, which is fatal tothe whole computation (CanonicalABI.md:3647 "or else trap and tear down the
whole store"). Note also
Task.unregister_thread:trap_if(self.state != Task.State.RESOLVED)when the last thread leaves — in the reference's ownmodel, an implicit thread ending without
task.returnis a trap. So thespec offers exactly two consistent end states: lock released (normal exit) or
store dead (trap). A live-but-locked instance is neither.
Contract:
embedder-api.md:213-214("on a non-JSPI engine a returned Promise isrefused at the call site (
NeedsJspi), never degraded"),:226-228(
deferCancelsync form "parks under jspi (NeedsJspiwithout it)"), and thesync()ladder:273"1–3 are non-poisoning",:282"A blocking built-inthrough the plain entry:
NeedsJspi".intrinsics.md:20-22("Trap andcapability failures differ") and
:60-61("a failed call releases the lendersit registered, including non-poisoning capability failures") make the unwind
obligation explicit for lenders but never mention exclusivity or thread
registration. The issue's reading of the contract is accurate: "non-poisoning"
plus "instance stays usable" is what the contract promises, and the pinned test
runtime/tests/embedder/host_settlement_test.ts:155-159("capability refusaldoes not poison") checks only a sync
ping, which never touches the exclusiveslot (
needs_exclusiveassertsft.async_; sync-typed exports skip the lockentirely,
enter_implicit_threadif self.ft.async_:). Nothing in thecontract qualifies the clause for callback-ABI tasks. Code confirms:
unwind(
runtime/src/exec/boundary.ts:1189-1208) releases lender scopes and siblingmayLeaveonly;exitImplicitThread(runtime/src/task/mod.ts:215-227) isthe sole release; the capability path
boundary.ts:1240-1243callsunwindand rethrows without it.
unwindalso never unregisters the thread frominst.threads(task/mod.ts:229-239), a second leak the issue's fix (a)mentions in passing.
Wasmtime: no equivalent. A sync-lowered import whose host fn is async is
handled by suspending the fiber (
func/host.rs:398-403→poll_and_block,concurrent.rs:858); nothing is ever "refused". A host fnErris a trap andkills the store (see shared facts). Instructively, wasmtime also skips
exit_instanceon an error unwind (concurrent.rs:2791-2801,:2839-2865:let storage = call(store)?;precedesstore.exit_instance(...)), leavingdo_not_enter = true— harmless only because the store is already trapped.That is precisely the state polyengine ends in, minus the poisoning.
Verdict: CONTRACT-ONLY (spec corroborates the invariant: lock lifetime ==
implicit-thread lifetime, and the only non-releasing exit is store death).
wasmtime: no equivalent — no capability refusal exists; the nearest event
(host error) poisons the whole store, so a live-but-locked instance is
unreachable there.
Severity: keep P1 — route A is the exact outcome the contract documents for
deferCancel+ sync cancel in plain mode; the result is a permanent, silenthang of every callback-ABI export with
poisoned:false.Notes: The issue's fix (b) ("poison on capability exits reached from inside a
live guest activation") is the spec-aligned option:
Task.unregister_thread'strap_if(state != RESOLVED)says an implicit thread leaving without a returnis a trap, and a JS exception through a wasm frame skips the guest's epilogue
(shadow-stack pointer, wit-bindgen callback state), so "non-poisoning" is
already a fiction for mid-activation exits. If the contract's non-poisoning
promise is kept instead (fix a), the emulated exit must cover everything
exit_implicit_threaddoes: releaseexclusiveThreadand unregister thethread from
inst.threads, and the task's outstanding subtask handles need astory too. Either way the contract text at
embedder-api.md:273should bereconciled with whichever is chosen. The
host_settlement_test.tspin is notevidence for the callback case; say so in the issue.
lannbot commentedon Sep 12, 2026
Contract clause co-suspect. This issue's authority is the contract alone (spec silent), and the contract shares authors with the code — so the fault may be in the clause, not the implementation. Independent evidence points at the clause:
embedder-api.md§"sync()" ladder ("1–3 are non-poisoning") and §"Import marks" ("refused at the call site (NeedsJspi)") together promise a non-poisoning refusal thrown through a live guest frame.Task.unregister_threadtraps when an implicit thread ends withstate != RESOLVED: an activation that exits withouttask.returnis a trap in the model this runtime tracks."Non-poisoning" is honest only for refusals made before entry — the poisoned-instance refusal,
SyncEntryBusy,NeedsJspiraised by thesync()adapter — not for capability failures reached from inside a guest activation. The likely resolution is a contract amendment (mid-activation capability failures poison the instance, i.e. the issue's fix direction (b)), with the leakedexclusiveThreadbecoming moot. Labeledpolicy; decide the clause before fixing code to it.lannbot commentedon Sep 12, 2026
Re-evaluated on current main
5616bce(after #366), Deno 2.9.5, with freshly built shim/fixtures. Keep P1 / policy. All three routes still leaveexclusiveThreadheld withpoisoned:false; two later async calls time out. Controls succeed.Recommend distinguishing pre-entry capability refusal from a capability exception unwinding a live guest frame. Keep pre-entry refusals non-poisoning; amend the contract for mid-activation exits to poison affected instances. Releasing exclusivity alone cannot restore guest epilogues/shadow-stack state. This needs a contract/versioning decision, but the silent wedge is not an acceptable alternative under either policy.