镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

boundary: a NeedsJspi capability exit from a callback-ABI task leaks exclusiveThread; every later async export of the un-poisoned instance hangs silently #348

Open

Description

@lannbot

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

  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 on capability exits (the reference has no "refuse to block":
    canon_lower sync arm simply does thread.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 in canon_lift
    the 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): "the
    Task.exit_implicit_thread method is called before a task's implicit thread
    returns to reverse the effects of Task.enter_implicit_thread. In particular,
    if the exclusive_thread lock was acquired, it is released." The only other
    way an implicit thread ends in the reference is a Trap, which is fatal to
    the 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 own
    model, an implicit thread ending without task.return is a trap. So the
    spec 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 is
    refused at the call site (NeedsJspi), never degraded"), :226-228
    (deferCancel sync form "parks under jspi (NeedsJspi without it)"), and the
    sync() ladder :273 "1–3 are non-poisoning", :282 "A blocking built-in
    through the plain entry: NeedsJspi". intrinsics.md:20-22 ("Trap and
    capability failures differ") and :60-61 ("a failed call releases the lenders
    it 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 refusal
    does not poison") checks only a sync ping, which never touches the exclusive
    slot (needs_exclusive asserts ft.async_; sync-typed exports skip the lock
    entirely, enter_implicit_thread if self.ft.async_:). Nothing in the
    contract qualifies the clause for callback-ABI tasks. Code confirms: unwind
    (runtime/src/exec/boundary.ts:1189-1208) releases lender scopes and sibling
    mayLeave only; exitImplicitThread (runtime/src/task/mod.ts:215-227) is
    the sole release; the capability path boundary.ts:1240-1243 calls unwind
    and rethrows without it. unwind also never unregisters the thread from
    inst.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 fn Err is a trap and
    kills the store (see shared facts). Instructively, wasmtime also skips
    exit_instance on an error unwind (concurrent.rs:2791-2801, :2839-2865:
    let storage = call(store)?; precedes store.exit_instance(...)), leaving
    do_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, silent
    hang 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's
    trap_if(state != RESOLVED) says an implicit thread leaving without a return
    is 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_thread does: release exclusiveThread and unregister the
    thread from inst.threads, and the task's outstanding subtask handles need a
    story too. Either way the contract text at embedder-api.md:273 should be
    reconciled with whichever is chosen. The host_settlement_test.ts pin is not
    evidence for the callback case; say so in the issue.

  3. added
    policyDecision owed on deltic-owned semantics
    on Sep 12, 2026
  4. lannbot commented on Sep 12, 2026

    @lannbot
    CollaboratorAuthor

    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.
    • The reference's Task.unregister_thread traps when an implicit thread ends with state != RESOLVED: an activation that exits without task.return is a trap in the model this runtime tracks.
    • A JS exception unwinding a wasm frame skips the guest's epilogue (shadow-stack pointer, wit-bindgen callback state); the instance is already in an undefined state before the leak observed here.

    "Non-poisoning" is honest only for refusals made before entry — the poisoned-instance refusal, SyncEntryBusy, NeedsJspi raised by the sync() 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 leaked exclusiveThread becoming moot. Labeled policy; decide the clause before fixing code to it.

  5. lannbot commented on Sep 12, 2026

    @lannbot
    CollaboratorAuthor

    Re-evaluated on current main 5616bce (after #366), Deno 2.9.5, with freshly built shim/fixtures. Keep P1 / policy. All three routes still leave exclusiveThread held with poisoned: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.

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 workingp1Correctness bugs likely to impact consumers; high-priority missing featurespolicyDecision owed on deltic-owned semantics

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions