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
47 changes: 40 additions & 7 deletions runtime/src/exec/boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@
stringEncoding: opts.stringEncoding,
memory: opts.memory,
realloc: opts.realloc === null ? null : (o, os, a, n) => {
const realloc = require(opts.realloc, "realloc")!;

Check warning on line 220 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04)

unable to analyze dynamic import

Check warning on line 220 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04-arm)

unable to analyze dynamic import
const p = callCore(realloc, [o, os, a, n]);
trapIf(p.length !== 1 || typeof p[0] !== "number", "realloc result");
return (p[0] as number) >>> 0;
Expand Down Expand Up @@ -1624,10 +1624,36 @@
// satisfy — that is a **background activation**: we return to the host
// and leave the thread live, and later `drive`/`pump` calls (host stream
// writes, the next export call) go on servicing it.
//
// AND THE PARK NEED NOT BE THIS TASK'S (issue #280). Scoping the
// wasm-call test to `task.threads` under-approximates the reference
// embedding, whose loop drains the whole store (`while store.waiting:
// store.tick()`, run_tests.py `lift_and_run`). What it missed is an
// activation THIS DRIVER PUT IN FLIGHT: a background task's host import
// settles on a microtask while this driver is live, this driver's
// `tick` resumes that task's callback activation, the promising entry
// hop-parks it (jspi pin (j) — contracts/intrinsics.md §"JSPI
// integration constraints" 4), and then this predicate — blind to
// another task's threads — declared the driver done. Nobody else owned
// that hop: the settlement pump arms only on outstanding real host
// calls, and the call that caused the resumption had already settled
// and left `pendingHostCalls`. Trace: `EXIT-done ... awaiting=1`, then
// an activation nothing services until an unrelated later call happens
// to drive the store.
//
// So the rule: a driver is not done while ANY thread of ANY task is
// hop-parked. A hop settles on the engine's own schedule, so waiting
// for it is bounded — the driver that caused it must see it land. The
// two tests are complementary and both stay: `hopParked` deliberately
// EXCLUDES genuinely JSPI-suspended activations (SuspensionPoint-owned
// parks, which only the embedder can satisfy and which are exactly the
// "background activation" case above), and `midWasmCall` is what still
// covers this task's own suspended thread.
const midWasmCall = () => task.threads.some((t) => store.awaiting.has(t));
const hopParked = () => entryHopThreads(store).length > 0;
pending = drive(
store,
() => resolvedSeen && !midWasmCall(),
() => resolvedSeen && !midWasmCall() && !hopParked(),
`export '${name}'`,
);
} catch (e) {
Expand Down Expand Up @@ -1690,14 +1716,20 @@
}

/**
* Threads of `inst` parked on a promising-entry hop: in `store.awaiting`
* with no `SuspensionPoint` owner in `store.waiting` (that would be a
* genuine JSPI suspension). Mirrors `Store.hasRunnableWork`'s (b)/(c)
* split.
* Threads parked on a promising-entry hop: in `store.awaiting` with no
* `SuspensionPoint` owner in `store.waiting` (that would be a genuine JSPI
* suspension). Mirrors `Store.hasRunnableWork`'s (b)/(c) split.
*
* `inst` narrows the result to one component instance — what the
* hop-quiescence gate needs, since the memory a pending lift will read
* belongs to that instance. Omitted, the result is store-wide: what the
* export driver's `done` predicate needs (issue #280), where the question is
* not whose memory is at risk but whether any activation this driver put in
* flight is still mid-hop.
*/
function entryHopThreads(
store: Store,
inst: unknown,
inst?: unknown,
): { awaiting: Promise<unknown> | null }[] {
if (store.awaiting.size === 0) return [];
const suspended = new Set<unknown>();
Expand All @@ -1711,7 +1743,8 @@
task: { inst: unknown };
awaiting: Promise<unknown> | null;
};
if (tt.task.inst === inst && !suspended.has(t)) out.push(tt);
if (inst !== undefined && tt.task.inst !== inst) continue;
if (!suspended.has(t)) out.push(tt);
}
return out;
}
Expand Down Expand Up @@ -1996,7 +2029,7 @@
task.return_(results);
// Post-return runs after the results were read out of guest memory,
// with may_leave cleared (reference canon_lift).
const postReturn = require(opts.postReturn, `${name} post-return`);

Check warning on line 2032 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04)

unable to analyze dynamic import

Check warning on line 2032 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04-arm)

unable to analyze dynamic import
if (postReturn !== null) {
assert_(inst.mayLeave, "post-return with may_leave already false");
inst.mayLeave = false;
Expand Down Expand Up @@ -2042,7 +2075,7 @@
// *mixed* activation, which pin (c) punishes: the first Suspending import
// it reached would trap.
const callback = enterWasm(
require(opts.callback, `${name} callback`)!,

Check warning on line 2078 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04)

unable to analyze dynamic import

Check warning on line 2078 in runtime/src/exec/boundary.ts

View workflow job for this annotation

GitHub Actions / core (ubuntu-24.04-arm)

unable to analyze dynamic import
input.mode,
);
const [packed] = normalizeCoreValues(
Expand Down
89 changes: 89 additions & 0 deletions runtime/tests/integration/e2e_cancel_import_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,3 +444,92 @@ Deno.test(
);
},
);

// ---------------------------------------------------------------------------
// Issue #280: an activation this driver put in flight, abandoned mid-hop.
//
// The export driver's exit predicate was "the task resolved AND no thread OF
// THIS TASK is mid-wasm-call". A BACKGROUND task's callback activation —
// resumed by this driver's own `tick`, hop-parked in `store.awaiting` while
// the promising entry settles — is not a thread of this task, so `done()`
// went true and the driver walked away from work it had just started.
// Nothing else owned it: the settlement pump arms only on outstanding real
// host calls (`hasRealHostCall`), and the host call whose settlement caused
// the resumption is gone from `pendingHostCalls` by then. The activation sat
// in `store.awaiting` until some unrelated later call happened to drive the
// store — issue #280's trace `#25 EXIT-done awaiting=1`, reproduced verbatim
// by the pre-fix runtime here.
// ---------------------------------------------------------------------------

Deno.test(
"cancel-import #280: an export driver may not exit leaving a hop-parked activation of another task",
async () => {
// Every `sleep` call hands back a promise this test settles by hand, so
// the settlement lands exactly where the issue puts it: on a microtask
// inside a LATER export call's driver.
const gates: Array<() => void> = [];
const imports = {
sleep: (_ms: bigint) => new Promise<void>((r) => gates.push(() => r())),
block: suspending((ms: bigint) => delay(Number(ms))),
"sleep-defer": deferCancel((ms: bigint) => delay(Number(ms))),
timers: { "sleep-defer": deferCancel((ms: bigint) => delay(Number(ms))) },
"sleep-abort": abortable((ms: bigint, _signal: AbortSignal) =>
delay(Number(ms))
),
};
const component = await instantiateComponent({
plan,
componentBytes: guestWasm,
adapters,
imports,
});
const e = component.exports as Exports;
const store = component.componentInstances[0].store;

// `start-poll-drop` returns as soon as its detached task parks, so from
// here on the guest task is BACKGROUND: resolved, no driver of its own.
// Per src/lib.rs it issues S1 (polled once, in flight), then awaits S2.
await e["start-poll-drop"](1n, 1n);
assertEq(gates.length, 2, "S1 and S2 should both be in flight");

// Retire S1 so no real host call is left to arm the settlement pump for
// the window under test (the guest's own drop of S1 is a discard, which
// is about delivery, not about the host promise).
gates[0]();
await delay(20);

// S2 settles with no export call outstanding: the pump drives it, the
// detached task drops S1 and issues S3. S3 is now the ONLY host call.
gates[1]();
await delay(20);
assertEq(gates.length, 3, "the detached task should be parked on S3");

// THE WINDOW. `ping()` starts the second export call; resolving S3
// synchronously right after makes it settle on a microtask while that
// driver is live. The driver resumes the background task's callback
// activation — a promising entry, so it hop-parks — and the activation's
// tail (which retires the resumed task) is the driver's to see land.
const pong = e.ping() as Promise<number>;
gates[2]();
assertEq(await pong, 42, "ping must still answer");

// Nothing else drives the store from here: no host call is outstanding,
// so the settlement pump does not arm. A hop still in `store.awaiting`
// after this point is owned by nobody.
await delay(50);
assertTrue(
store.awaiting.size === 0,
`issue #280 regression: ping()'s driver exited leaving ` +
`${store.awaiting.size} hop-parked activation(s) of a background ` +
`task in store.awaiting, with no host call outstanding to arm the ` +
`settlement pump — the trace's "EXIT-done ... awaiting=1". The ` +
`driver must not report done while any thread, of any task, is ` +
`hop-parked.`,
);

// Leak hygiene: nothing is waiting on the remaining gate, but settle it
// so no promise is left dangling behind the test.
for (const g of gates) g();
await delay(20);
},
);
Loading