Repository navigation
async: cancellation continuation double-consumes resolution delivered to a sibling #345
Description
Summary
Async subtask.cancel's determinacy park clears its synchronous claim
(hasSyncWaiter = false) before the callee's cancellation actually
becomes determinate. During that window a sibling waitable.join +
waitable-set.poll is legal and can consume the subtask's SUBTASK
resolution event first. When the cancellation continuation resumes, it
tries to consume the same event again and hits an assertion — the
resolution is delivered to two different consumers.
Baseline: 1a4f5e1c6f3fc906ce576acdc4499ef2745d6e41.
Where
runtime/src/intrinsics/async_builtins.ts:462-480—
the async determinacy park:st.hasSyncWaiter = falseruns at park
entry (line 468) and again inproduce/onSettled(lines 475, 479),
releasing the claim while the subtask may still resolve and deliver
its event to whichever consumer polls first.runtime/src/intrinsics/async_builtins.ts:344-360—
finishSubtaskCancel's tail (finish, invoked fromproduceat line
476) callsst.getPendingEvent()at line 349 unconditionally once
st.resolved()is true, without checking whether the event was
already consumed by another waiter.runtime/src/task/waitable.ts:62-65—
getPendingEvent()assertspendingEvent !== null, which is what
actually fires.
Authorities
-
third_party/component-model/design/mvp/canonical-abi/definitions.py,
canon_subtask_cancel: setshas_sync_waiterbeforeon_cancel()
and clears it once the claim ends, for both the sync and async forms.
The claim exists precisely so a subtask cannot be consumed by two
waiters at once (mirrored by thetrap_if(subtask.in_waitable_set())
check at lines 393-401 ofasync_builtins.ts, and by
canon_waitable_join'strap_if(w.has_sync_waiter)). -
docs/architecture.md§6 —
names the async determinacy park as a deliberate non-atomicity
divergence from the reference (issue scheduler/docs: async subtask.cancel determinacy park weakens built-in atomicity — record divergence + seeded regression test #92): "other ready threads may
run before this built-in returns." That divergence is scoped to
reordering which thread runs first, not to permitting a second
consumer of the same resolution event. The comment at
async_builtins.ts:464-467reads scheduler/docs: async subtask.cancel determinacy park weakens built-in atomicity — record divergence + seeded regression test #92 as license for the claim release
itself:Async cancellation releases its synchronous claim before the
determinacy park, allowing a sibling'swaitable.join. Issue scheduler/docs: async subtask.cancel determinacy park weakens built-in atomicity — record divergence + seeded regression test #92
permits reordering, not a new trap condition.But scheduler/docs: async subtask.cancel determinacy park weakens built-in atomicity — record divergence + seeded regression test #92's scope is reordering of thread execution order, not
reordering of who is allowed to consume this subtask's resolution.
ReleasinghasSyncWaitermid-park hands the resolution to whichever
waiter polls first, and the cancellation continuation has no way to
discover it lost the race — it unconditionally callsfinish(),
which asserts.
Existing coverage that documents "legal" but doesn't consume
runtime/tests/subtask_cancel_sync_waiter_window_test.ts asserts that a
sibling waitable.join is legal once hasSyncWaiter is cleared during
the async park window, but the sibling in that test never actually
calls waitable-set.poll/consumes the event before the cancellation
continuation resumes. It documents the window is open without
exercising what happens if something walks through it.
Reproduction (standalone, repo-relative)
From runtime/, given the same import-map (deno.json) as the
package's own tests:
// runtime/repro_cancel_race.ts
const root = new URL("./src/", import.meta.url).href;
const m = await import(root + "task/mod.ts");
const a = await import(root + "intrinsics/async_builtins.ts");
const store = new m.Store();
const inst = new m.ComponentInstanceState(0, store);
const ft = { params: [], results: [], async: true };
const opts = { async_: true, callback: false, stringEncoding: "utf8", memory: null };
// Caller task performing the cancellation.
const caller = new m.Task(ft, opts, inst, () => [], () => {});
const thread = new m.Thread(caller, (function* () {})());
// Subtask being cancelled, with a callee task that has not yet gone
// idle (so the determinacy park is entered) and an onCancel hook.
const st = new m.Subtask();
const i = inst.handles.add(st);
st.state = m.SubtaskState.STARTED;
const callee = new m.Task(ft, opts, inst, () => [], () => {});
callee.threads.push(new m.Thread(callee, (function* () {})()));
st.calleeTask = callee;
st.onCancel = () => {};
// Enter subtask.cancel (JSPI async form): it hits the determinacy
// park because the callee thread is not yet done and not yet parked.
const pending = m.withActivation(thread, () =>
a.createSubtaskCancel({ async: true }, inst, "jspi")(i));
const result = pending.then(
(value: unknown) => ({ value }),
(e: unknown) => ({ error: String(e) }),
);
// Sibling: with hasSyncWaiter cleared at park entry, joining a
// waitable set on the same handle is accepted per definitions.py.
const set = new m.WaitableSet();
const si = inst.handles.add(set);
a.createWaitableJoin(inst)(i, si);
// Resolve the subtask and deliver its SUBTASK event.
st.resolve(m.SubtaskState.CANCELLED_BEFORE_RETURNED, []);
st.setSubtaskPendingEvent(i);
// The sibling consumes the resolution event first.
console.log("sibling event:", set.poll(caller, false));
// Callee threads finish -> determinate; cancellation continuation
// resumes on the next tick and tries to consume the SAME event.
callee.threads.length = 0;
store.tick();
console.log("cancel:", await result);
store.releasePendingOf(thread);
console.log("remaining waiters:", store.waiting.length);Run:
cd runtime
deno run --config deno.json --allow-read repro_cancel_race.tsExpected
Either:
subtask.cancel's continuation observes that its event was already
claimed by the sibling and reports a defined outcome (e.g. by
retaining the claim through the determinacy wait, matching the sync
form's behavior at line 467's own comment: "Sync cancellation retains
the claim through its resolution wait, as in the reference"), or- The sibling's
waitable.join/poll is rejected while the cancellation
claim is outstanding for this resolution.
In either case, the resolution event is consumed by exactly one waiter
and subtask.cancel never crashes.
Actual
sibling event: [ 1, 1, 4 ]
cancel: { error: "AssertionError: getPendingEvent with no pending event" }
remaining waiters: 0
The sibling's set.poll consumes the SUBTASK event first
(sibling event). The cancellation continuation then runs finish()
(async_builtins.ts:349), which calls st.getPendingEvent()
unconditionally since st.resolved() is true, and hits the
pendingEvent !== null assertion in
runtime/src/task/waitable.ts:62-65 — an internal AssertionError,
not a defined component-level trap.
Scope note
This reproduction drives the runtime's internal state machine directly
(Task/Subtask/WaitableSet/withActivation) rather than through a
compiled component, the same seam the existing
subtask_cancel_sync_waiter_window_test.ts uses. That's a deliberate
choice to isolate the two-consumer race precisely, not a claim that the
bug is unreachable from compiled wasm — hasSyncWaiter and
waitable.join's legality are checked by the async-lowering internals
regardless of caller.
Activity
lannbot commentedon Sep 12, 2026
Triage: P1 / bug / spec-divergence — second in this batch
Independently reran the supplied internal-state repro on
mainat1a4f5e1: the sibling receives[1, 1, 4], then cancellation rejects withAssertionError: getPendingEvent with no pending event; no waiter remains. This confirms the double-consumption failure at the same seam as the existing regression test. A compiled-guest reproduction is still needed to establish the complete scheduling path; this is not evidence of a process crash.P1: unlike malformed-host-input cases, the runtime currently permits the sibling operation and then violates its own single-consumer invariant. Keep this ahead of boundary hardening.
Do not apply the proposed flag-retention fix without resolving #295. That closed issue and
subtask_cancel_sync_waiter_window_test.tsdeliberately requirewaitable.jointo succeed during the async determinacy park. Simply holdinghasSyncWaiterthrough the park reintroduces that rejected-operation bug.The pinned
definitions.py(7c676115,canon_subtask_cancel/canon_waitable_join) has no async determinacy suspension: it either returns BLOCKED or consumes the event within the built-in. Architecture §6 / #92 introduces interleaving, but does not define a second resolution consumer. The implementation must reconcile that extra window with #295—by a defined single-owner outcome or a reviewed narrowing/change of the scheduling divergence, not merely suppressing the assertion.Acceptance: a guest/FACT-path regression with sibling join and consumption, both relevant scheduling orders, no double delivery, correct cancellation result, and lender/waiter cleanup. Preserve sync cancellation semantics and run seeded scheduling coverage. Scheduler changes should have one implementation owner.
Batch order: #342 → #345 → #343 → #346 → #347 → #344. The semantic dependency is the #92/#295 reconciliation, not another issue in this batch.
lannbot commentedon Sep 12, 2026
Spec verification — correction to the previous triage's #295 guidance
P1 / spec-divergence confirmed. The #295 early-release test is not an authority and may need correction.
The pinned Canonical ABI prose states explicitly that arbitrary code can run during
on_cancel/wait_until, and holdinghas_sync_waiterprevents reentrant threads from “stealing” an event or otherwise invalidating the conditions guarded at the beginning ofsubtask.cancel. The algorithm sets the claim beforeon_cancelfor both async and sync forms. Async means no subsequent wait for unresolved cancellation; it does not mean cancellation delivery is unclaimed.Pinned Wasmtime
4675ee16agrees:subtask_canceltemporarily places the subtask in the caller'ssync_call_setwhile yielding to the cancelled callee. A reentrantwaitable.jointhen traps. After that delivery returns, unresolved async cancellation returns BLOCKED (lines 4332–4338).Executed native evidence: ran pinned
test/async/reentrance.wast, including its final synchronous cancellation/reentrant-join trap assertion. Then extracted that final component and changed exactly its canonical definition tosubtask.cancel async; the same trap assertion also passed. Three existing compiled-guest cancellation tests passed as secondary sanity checks. This verifies the async claim rule through guest code; it is not an exact native reproduction of the JSPI-only determinacy park.Revised fix guidance: a JSPI microtask return from the
onCancelshim is not necessarily completion of the logical referenceon_cancel. Preserve the claim over the delivery interval being emulated, or constrain the extra scheduler window so it cannot admit an event thief. Review/replace #295's early-release expectation where it conflates “JS callback returned” with “cancellation delivery completed.” Once async cancellation really returns BLOCKED, the claim must be released and ordinary join/poll must work. Do not merely suppress the assertion or introduce arbitrary fallback results.My earlier wording that retaining the claim necessarily “reintroduces #295” was too deferential to that test: the observed conflict is real, but the spec—not the test—decides whether it is a regression. Priority order is unchanged.