Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions runtime/src/intrinsics/async_builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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(),
Expand Down
45 changes: 43 additions & 2 deletions runtime/src/jspi/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,14 @@ export class SuspensionPoint<T = unknown> 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
Expand Down Expand Up @@ -475,7 +482,30 @@ export class SuspensionPoint<T = unknown> 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. */
Expand All @@ -491,6 +521,17 @@ export class SuspensionPoint<T = unknown> 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);
Expand Down
103 changes: 103 additions & 0 deletions runtime/tests/subtask_cancel_sync_waiter_window_test.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(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);
},
);
186 changes: 186 additions & 0 deletions runtime/tests/suspension_point_pending_cancel_test.ts
Original file line number Diff line number Diff line change
@@ -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<BlockRequest, void, Cancelled>,
): Thread {
let thread!: Thread;
thread = new Thread(
task,
(function* (): Generator<BlockRequest, void, Cancelled> {
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<number>(
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");
});
Loading