From 7c27f5db5e63ece176da6a006c1042a951717d8e Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Sun, 6 Sep 2026 23:12:51 -0400 Subject: [PATCH] task core: pending-cancel parity for SuspensionPoint; async subtask.cancel releases its claim at park entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from the correctness review's task-core track. #300 — runtime/src/jspi/bridge.ts. `SuspensionPoint` gets the three changes PR #302 gave `Thread.waitUntil` (definitions.py `Thread.wait_until` 361-373): `ready()` gains the `cancellable && has_pending_cancel()` disjunct (with the live "lock is free" conjunct, `Task.implicitThreadCancellable`, unconditional here because a SuspensionPoint is always a frame of the implicit thread); `resume()` runs `deliverPendingCancel` after the block so a wakeup taken through that disjunct is a cancelled resume; `cancellable` is cleared on resume. Unreachable through today's guests (the only pending-cancel producer never applies to a SuspensionPoint owner) — the point is that the two block paths no longer diverge. Both hooks are feature-detected because some parks carry a non-Task stub. Regression: suspension_point_pending_cancel_test.ts (2 arms, fail pre-fix). #295 — runtime/src/intrinsics/async_builtins.ts. The #92 determinacy park held `hasSyncWaiter` across the whole async-form park; the reference (`canon_subtask_cancel` 2455-2461) clears it before returning BLOCKED, so the claim spans only the synchronous window around `on_cancel()`. A sibling thread running during our park and calling `waitable.join` on the same subtask trapped where the reference succeeds — #92 licenses a reordering, not a new trap. The async form now clears the flag at park entry; the sync form is unchanged (its `wait_until` is inside the window). Regression: subtask_cancel_sync_waiter_window_test.ts. Closes #295. Closes #300. --- runtime/src/intrinsics/async_builtins.ts | 12 ++ runtime/src/jspi/bridge.ts | 45 ++++- .../subtask_cancel_sync_waiter_window_test.ts | 103 ++++++++++ .../suspension_point_pending_cancel_test.ts | 186 ++++++++++++++++++ 4 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 runtime/tests/subtask_cancel_sync_waiter_window_test.ts create mode 100644 runtime/tests/suspension_point_pending_cancel_test.ts diff --git a/runtime/src/intrinsics/async_builtins.ts b/runtime/src/intrinsics/async_builtins.ts index 3aea88e..71d25af 100644 --- a/runtime/src/intrinsics/async_builtins.ts +++ b/runtime/src/intrinsics/async_builtins.ts @@ -526,6 +526,7 @@ export function createSubtaskCancel( // subtask as claimed and trap in `canon_waitable_join` // (`trap_if(w.has_sync_waiter)`) — test/async/reentrance.wast:837. // `parked` hands the clear over to the park's `produce`/`onSettled` + // (the async form clears it at park entry instead — #295, below) // (#106: `abandon` never runs `produce`, so the flag needs the // `onSettled` backstop or it stays set forever and a later // `waitable.join` traps spuriously). @@ -596,6 +597,17 @@ export function createSubtaskCancel( } if (!ready()) { parked = true; + // #295: the ASYNC form's claim ends where the reference's does — + // `canon_subtask_cancel` clears `has_sync_waiter` before returning + // BLOCKED (definitions.py:2459-2461), so the flag is held only + // across the synchronous claim window around `on_cancel()`. The + // #92 determinacy park below is not part of that window; holding + // the flag across it would trap a sibling thread's + // `waitable.join` on this subtask where the reference succeeds + // (#92 licenses a reordering, not a new trap condition). The SYNC + // form keeps it set across its wait, as the reference does + // (`thread.wait_until(subtask.resolved)` precedes the clear). + if (async_) st.hasSyncWaiter = false; return blockCurrentActivation({ store: inst.store, task: currentTask(), diff --git a/runtime/src/jspi/bridge.ts b/runtime/src/jspi/bridge.ts index 38f604e..03b3b6e 100644 --- a/runtime/src/jspi/bridge.ts +++ b/runtime/src/jspi/bridge.ts @@ -431,7 +431,14 @@ export class SuspensionPoint implements SchedulableThread { readonly task: any, /** Resumable once this holds; `null` = only an explicit resume. */ readonly readyFunc: (() => boolean) | null, - readonly cancellable: boolean, + /** + * definitions.py `Thread.cancellable` — set for the duration of this + * park, cleared when the point resumes (parity with task/thread.ts's + * `Thread.cancellable`, which the reference evaluates as a live + * predicate: a point that is no longer parked is never a + * `request_cancellation` candidate). + */ + public cancellable: boolean, /** Produces the value to hand back to wasm at resume time. */ private readonly produce: (cancelled: Cancelled) => T, // deno-lint-ignore no-explicit-any @@ -475,7 +482,30 @@ export class SuspensionPoint implements SchedulableThread { } ready(): boolean { - return !this.#done && this.readyFunc !== null && this.readyFunc(); + if (this.#done) return false; + if (this.readyFunc !== null && this.readyFunc()) return true; + // definitions.py `ready_or_cancelled` (`Thread.wait_until` line 369), + // ported to task/thread.ts:waitUntil: a cancel that arrived while this + // task was not cancellable (parked as `pending-cancel`) makes the block + // point ready on its own, otherwise the wakeup is lost until some + // unrelated event happens to satisfy `readyFunc` — possibly never. + // A `SuspensionPoint` is a frame OF the implicit thread, so the "and the + // lock is free" conjunct (`Task.implicitThreadCancellable`, the live + // `lock_available` of the reference's callback loop) applies here + // unconditionally — the same exclusion `Task.requestCancellation` puts on + // its scan of `store.waiting`. + return this.cancellable && this.#taskHasPendingCancel() && + this.task.implicitThreadCancellable() === true; + } + + /** + * `task` is untyped here and some parks carry a stub (no `Task` at all — + * instantiation-time built-ins, and the tests that stand in for them), so + * both cancel hooks are feature-detected. No task, no pending cancel. + */ + #taskHasPendingCancel(): boolean { + return typeof this.task?.hasPendingCancel === "function" && + this.task.hasPendingCancel() === true; } /** Settle the import's Promise; the engine resumes the wasm activation. */ @@ -491,6 +521,17 @@ export class SuspensionPoint implements SchedulableThread { console.error(`[sp] resume ${dbgId(this)} owner=${dbgId(this.owner)}\n${(new Error().stack ?? "").split("\n").slice(2, 5).join("\n")}`); } this.#done = true; + // AFTER the block (definitions.py `Thread.wait_until` line 372, ported to + // task/thread.ts:waitUntil): a plain wakeup taken through the + // pending-cancel disjunct in `ready()` becomes a cancelled resume, and + // that delivery wins over any event that became pending meanwhile. + if ( + typeof this.task?.deliverPendingCancel === "function" && + this.task.deliverPendingCancel(this.cancellable) === true + ) { + cancelled = true; + } + this.cancellable = false; this.#store.stopWaiting(this); try { this.#resumeInner(cancelled); diff --git a/runtime/tests/subtask_cancel_sync_waiter_window_test.ts b/runtime/tests/subtask_cancel_sync_waiter_window_test.ts new file mode 100644 index 0000000..33e91c9 --- /dev/null +++ b/runtime/tests/subtask_cancel_sync_waiter_window_test.ts @@ -0,0 +1,103 @@ +// #295: the async form of `subtask.cancel` must not hold `hasSyncWaiter` +// across the #92 determinacy park. +// +// definitions.py `canon_subtask_cancel` (:2453-2461) sets `has_sync_waiter` +// before `on_cancel()` and clears it BEFORE returning BLOCKED — the flag is +// held only across the synchronous claim window. Our jspi implementation adds +// a park (named divergence #92, docs/architecture.md §6) that waits for the +// callee to become determinate before answering; #92 licenses a *reordering*, +// not a new trap condition, so the async form clears the flag at park entry. +// +// Pinned here: with the callee hop-parked (a thread neither done nor in +// `store.waiting`, so `determinate()` is false), a sibling thread's +// `waitable.join` on the same subtask handle succeeds during the park. + +import { assertEq } from "./support/asserts.ts"; +import { + BLOCKED, + createSubtaskCancel, + createWaitableJoin, +} from "../src/intrinsics/async_builtins.ts"; +import { + ComponentInstanceState, + popCurrentThread, + pushCurrentThread, + Store, + Subtask, + Task, + type TaskOptions, + Thread, + WaitableSet, +} from "../src/task/mod.ts"; +import type { FuncType } from "../src/cabi/types.ts"; + +const FT: FuncType = { params: [], results: [], async: true }; +const OPTS: TaskOptions = { + async_: true, + callback: true, + stringEncoding: "utf8", + memory: null, +}; + +Deno.test( + "#295: async subtask.cancel clears hasSyncWaiter when the #92 determinacy " + + "park begins, so a sibling waitable.join on the same subtask succeeds", + async () => { + const store = new Store(); + const inst = new ComponentInstanceState(0, store); + + const subtask = new Subtask(); + subtask.onCancel = () => {}; // does not resolve: the park's shape. + const subtaski = inst.handles.add(subtask); + + // A callee task whose only thread is hop-parked: not done, and not + // registered in `store.waiting` — exactly the mid-hop state the #92 park + // exists to wait out, so `determinate()` is false and the cancel parks. + const calleeTask = new Task(FT, OPTS, inst, () => [], () => {}); + const calleeThread = new Thread(calleeTask, (function* () {})()); + calleeTask.threads.push(calleeThread); + subtask.calleeTask = calleeTask; + assertEq(calleeThread.done(), false); + + const callerTask = new Task(FT, OPTS, inst, () => [], () => {}); + const callerThread = new Thread(callerTask, (function* () {})()); + const asGuest = (fn: () => T): T => { + pushCurrentThread(callerThread); + try { + return fn(); + } finally { + popCurrentThread(callerThread); + } + }; + + const cancel = createSubtaskCancel({ async: true }, inst, "jspi"); + const pending = asGuest(() => cancel(subtaski)) as unknown as Promise< + number + >; + // The park is entered synchronously, before the Promise is handed back. + assertEq(subtask.hasSyncWaiter, false); + + // The reference has already returned BLOCKED with the flag clear by now, + // so a sibling thread joining this subtask into a set must succeed. + const wset = new WaitableSet(); + const seti = inst.handles.add(wset); + const siblingTask = new Task(FT, OPTS, inst, () => [], () => {}); + const siblingThread = new Thread(siblingTask, (function* () {})()); + pushCurrentThread(siblingThread); + try { + createWaitableJoin(inst)(subtaski, seti); + } finally { + popCurrentThread(siblingThread); + } + assertEq(subtask.inWaitableSet(), true); + + // Unwind: make the callee determinate and let the park answer BLOCKED + // (the subtask never resolved). + // (the park captured the callee task object, so drop its threads rather + // than clearing `subtask.calleeTask`). + calleeTask.threads.length = 0; + store.tick(); + assertEq(await pending, BLOCKED); + assertEq(subtask.hasSyncWaiter, false); + }, +); diff --git a/runtime/tests/suspension_point_pending_cancel_test.ts b/runtime/tests/suspension_point_pending_cancel_test.ts new file mode 100644 index 0000000..a2a355b --- /dev/null +++ b/runtime/tests/suspension_point_pending_cancel_test.ts @@ -0,0 +1,186 @@ +// `SuspensionPoint`/`blockCurrentActivation` carried the pre-#302 shape of +// `Thread.waitUntil`: it parked on the raw `readyFunc`, returned the raw +// cancelled flag, and left `cancellable` set after resuming. The reference +// (definitions.py `Thread.wait_until` 361-373) parks on `ready_func() or +// (cancellable() and task.has_pending_cancel())` and re-runs +// `deliver_pending_cancel` AFTER the block; task/thread.ts:waitUntil ports +// that, and this pins the jspi block path to the same behavior. +// +// The producing shape: a cancel that arrives while a sibling activation of the +// instance holds `exclusiveThread` parks as `pending-cancel` +// (`Task.requestCancellation` excludes the implicit thread — and hence its +// suspension points — while the lock is held). When the slot frees, the +// reference wakes the parked block point and hands it Cancelled.TRUE. +// +// Issue #300 notes this is unreachable through real guests today: the only +// producer of a pending-cancel against a live cancellable wait is the +// exclusive-slot exclusion, which no real `SuspensionPoint` owner meets. So +// the point is driven directly, with fake threads and no wasm, in the style of +// wait_until_pending_cancel_test.ts. + +import { assertEq } from "./support/asserts.ts"; +import { + type BlockRequest, + type Cancelled, + ComponentInstanceState, + Store, + Task, + type TaskOptions, + Thread, +} from "../src/task/mod.ts"; +import { SuspensionPoint } from "../src/jspi/mod.ts"; +import type { FuncType } from "../src/cabi/types.ts"; + +const ASYNC_FT: FuncType = { params: [], results: [], async: true }; + +/** Async-typed, callback ABI: `needsExclusive()` is true. */ +const CALLBACK_OPTS: TaskOptions = { + async_: true, + callback: true, + stringEncoding: "utf8", + memory: null, +}; + +function spawn( + task: Task, + body: (t: Thread) => Generator, +): Thread { + let thread!: Thread; + thread = new Thread( + task, + (function* (): Generator { + yield* body(thread); + })(), + ); + return thread; +} + +function mkTask(inst: ComponentInstanceState, opts: TaskOptions): Task { + return new Task(ASYNC_FT, opts, inst, () => [], () => {}); +} + +/** + * Drive the store to quiescence; bounded so a lost wakeup is not a hang. + * + * Settling a suspension point takes a `pendingResumptions` entry against the + * resumed activation, and `tick` refuses to run while one is outstanding. In a + * real jspi run the engine resumes the wasm activation, which closes its own + * window by parking again or finishing; here nothing does, so the loop plays + * that edge (`Store.releasePendingOf`, the settle-side half) itself. + */ +function runToQuiescence(store: Store, task: Task): void { + for (let i = 0; i < 50; i++) { + if (!store.tick()) break; + store.releasePendingOf(task.implicitThread); + } +} + +/** Sibling B: takes the exclusive slot, parks non-cancellably until `gate`. */ +function spawnSibling( + inst: ComponentInstanceState, + gate: { open: boolean }, +): Thread { + const b = mkTask(inst, CALLBACK_OPTS); + const tb = spawn(b, function* (thread) { + yield* b.enterImplicitThread(thread); + b.start(); + yield* thread.waitUntil(() => gate.open, false); + b.return_([]); + b.exitImplicitThread(thread); + }); + tb.resume(); + return tb; +} + +/** + * Task A blocking the jspi way: a cancellable `SuspensionPoint` sits in + * `store.waiting` while the Thread that owns the activation parks + * non-cancellably on the settle of that point (the `awaitValue` seam, modelled + * here as a plain flag rather than a Promise). + */ +function spawnJspiBlocker( + inst: ComponentInstanceState, + readyFunc: (() => boolean) | null, + observed: boolean[], +): Task { + const a = mkTask(inst, CALLBACK_OPTS); + const ta = spawn(a, function* (thread) { + yield* a.enterImplicitThread(thread); + a.start(); + inst.exclusiveThread = null; // released across the block, as the loop does + const settled = { done: false }; + new SuspensionPoint( + inst.store, + a, + readyFunc, + true, // cancellable block point + (cancelled) => { + observed.push(cancelled === true); + settled.done = true; + return 0; + }, + thread, + ); + yield* thread.waitUntil(() => settled.done, false); + inst.exclusiveThread = thread; // retake, as the loop does + if (observed[0]) a.cancel(); + else a.return_([]); + a.exitImplicitThread(thread); + }); + ta.resume(); + return a; +} + +Deno.test("jspi block: a cancel pending behind a sibling's exclusive slot resumes the suspension point as cancelled", () => { + const store = new Store(); + const inst = new ComponentInstanceState(0, store); + const observed: boolean[] = []; + + // `readyFunc` mirrors the callback loop's "the lock is free" condition, so + // the point IS woken pre-fix — what it was handed is the question. + const a = spawnJspiBlocker( + inst, + () => inst.exclusiveThread === null, + observed, + ); + + const gate = { open: false }; + const tb = spawnSibling(inst, gate); + assertEq(inst.exclusiveThread === tb, true, "B holds the exclusive slot"); + + a.requestCancellation(null); + // Agreed by both: A is not cancellable while B holds the lock, and its + // suspension point is a frame of A's implicit thread. + assertEq(a.state, "pending-cancel"); + + gate.open = true; + runToQuiescence(store, a); + + // Reference: the post-block `deliver_pending_cancel` converts the + // Cancelled.FALSE resumption into Cancelled.TRUE. Pre-fix: a spurious false. + assertEq(observed, [true]); + assertEq(a.state, "resolved"); +}); + +Deno.test("jspi block: a suspension point whose readyFunc never holds is still woken by the pending cancel", () => { + const store = new Store(); + const inst = new ComponentInstanceState(0, store); + const observed: boolean[] = []; + + // Nothing this point waits for will ever happen (an empty waitable set): + // only the reference's `cancellable() and has_pending_cancel()` disjunct + // can make it ready. + const a = spawnJspiBlocker(inst, () => false, observed); + + const gate = { open: false }; + spawnSibling(inst, gate); + a.requestCancellation(null); + assertEq(a.state, "pending-cancel"); + + gate.open = true; + runToQuiescence(store, a); + + // Pre-fix: never woken at all — `observed` stays empty and A never resolves. + assertEq(observed, [true]); + assertEq(a.state, "resolved"); +});