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
95 changes: 87 additions & 8 deletions runtime/src/exec/boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
withActivation,
hasRealHostCall,
hasResumingThread,
dispatchableTail,
type EventTuple,
NeedsJspi,
needsJspi,
Expand Down Expand Up @@ -218,7 +219,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 222 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 222 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 @@ -834,15 +835,18 @@
// to the top the moment an activation tail lands.
if (store.awaiting.size > 0) {
await Promise.resolve();
if (store.settled.length > 0) break;
if (store.hasServiceableSettled()) break;
}
}
if (store.hostFailure !== undefined) throw takeHostFailure(store);
if (done()) {
traceDrive("driveAsync", store, done, "EXIT-done");
return;
}
if (store.settled.length > 0 || hasResumingThread()) {
// Only a SERVICEABLE tail is a reason to loop again: a queue holding
// only tails DEFERRED on a non-enterable instance (issue #156) would
// spin this loop hot — nothing in the cycle awaits.
if (store.hasServiceableSettled() || hasResumingThread()) {
continue;
}
// Service promise-parked threads (jspi).
Expand Down Expand Up @@ -881,7 +885,14 @@
// was lit.
if (store.pendingHostCalls.size === 0 && !hasResumingThread()) {
traceDrive("driveAsync", store, done, "deadlock-probe");
const parked = [...store.awaiting] as AwaitWinner["t"][];
// Exclude threads whose settle is already QUEUED in `store.settled`
// (issue #156): their promise has settled, so racing them wins
// instantly off the memoized `tagAwait` tag, forever, in an unbounded
// microtask chain — the tail is `serviceSettled`'s to run.
const queued = new Set(store.settled.map((s) => s.t));
const parked = ([...store.awaiting] as AwaitWinner["t"][]).filter(
(t) => !queued.has(t),
);
const progressed = await Promise.race([
...parked.map((t) => tagAwait(t).then(() => true)),
new Promise<boolean>((r) => setTimeout(() => r(false), 0)),
Expand All @@ -899,7 +910,18 @@
// routine) was not raced, and its promise may already be settled;
// trapping now would declare a deadlock one iteration before the
// loop would have serviced it. Membership change ⇒ re-probe.
const fresh = [...store.awaiting] as AwaitWinner["t"][];
//
// `fresh` gets the SAME queued-entry filter `parked` got (issue
// #156), against a RECOMPUTED queued set — the settled queue can
// change across the probe's await. Comparing a filtered snapshot
// against an unfiltered one would read "changed" on every turn in
// the all-deferred wedge state, so the verdict below could never
// be reached and the wedge would present as a silent
// macrotask-paced busy idle instead of a trap.
const freshQueued = new Set(store.settled.map((s) => s.t));
const fresh = ([...store.awaiting] as AwaitWinner["t"][]).filter(
(t) => !freshQueued.has(t),
);
const changed = fresh.length !== parked.length ||
fresh.some((t, i) => t !== parked[i]);
if (changed) continue;
Expand All @@ -915,7 +937,21 @@
// Observed on wasi-shims' A5 poll (sync fast path): probe sampled
// hostCalls=0 between a settled park and the next one, then
// trapped a live workload with hostCalls=1. Re-check ⇒ re-probe.
if (store.pendingHostCalls.size > 0 || hasResumingThread()) {
// Likewise a SERVICEABLE settled entry (issue #156): dispatching
// it is progress, so this is not a deadlock verdict — re-probe.
// A deferred-only queue deliberately does NOT re-probe: nothing
// can dispatch it while the lock is held, and if no host call is
// outstanding nothing will ever release that lock, so it falls
// THROUGH to the verdict below — the same loud-wedge treatment the
// servicing race's own all-deferred fallthrough gets. Per the #156
// analysis that state is unreachable (a lock spanning this loop's
// await always has a `pendingHostCalls` entry, which fails this
// probe's precondition); keeping it loud is what makes it an
// internal-wedge detector rather than dead code.
if (
store.pendingHostCalls.size > 0 || hasResumingThread() ||
store.hasServiceableSettled()
) {
continue;
}
if (store.readyCandidates().length === 0) {
Expand Down Expand Up @@ -950,15 +986,52 @@
// `TypeError: ... (reading 'awaiting')` into `store.hostFailure`, where
// it poisoned a later unrelated call (C0 finding R-2). Nothing to
// service ⇒ go back to the top and re-evaluate `done`.
if (store.awaiting.size === 0) continue;
// Same re-check for the settled queue, and for the same reason: the
// probe's macrotask turn can land a fresh, SERVICEABLE activation tail
// (that is exactly what "progress IS possible" above usually means).
// The queue owns those threads — the race below deliberately excludes
// them (issue #156) — so the way forward is the top of the loop, where
// `serviceSettled` dispatches them. Without this, filtering the
// just-settled thread out of the race left the loop awaiting promises
// that only its dispatch could settle (observed: tests/jspi/
// handshake_test.ts stalled, then tripped the claim assert).
if (store.awaiting.size === 0 || store.hasServiceableSettled()) continue;
// Claim the ambient for ONE parked thread and await its promise -- as
// before, so pin (i)'s window is covered exactly as it was -- but race
// that promise against every other outstanding promise so this loop can
// never be held hostage by it. The claimed thread's promise may only be
// settleable by further scheduler progress (a promising-wrapped nested
// activation whose own suspension points this loop must still resume);
// blocking on it alone is the pure-microtask stall of M2 phase 3l.
const parked = [...store.awaiting] as AwaitWinner["t"][];
// Same exclusion as the probe (issue #156): a thread whose tail is
// already queued in `store.settled` must not be raced — its tag is
// settled, so it re-wins instantly and livelocks the event loop,
// starving the very host-call settle that would release the lock.
const queued = new Set(store.settled.map((s) => s.t));
const parked = ([...store.awaiting] as AwaitWinner["t"][]).filter(
(t) => !queued.has(t),
);
if (parked.length === 0) {
// Every awaiting thread's settle is deferred on a non-enterable
// instance. The way out is the lock holder finishing, and the only
// await-spanning host-entry lock is the async-dtor bracket, which
// registers in `pendingHostCalls` — so park on those.
if (store.pendingHostCalls.size > 0) {
await Promise.race([...store.pendingHostCalls]).catch(() => {});
continue;
}
// Per the issue #156 analysis this is unreachable (a spanning lock
// always has a `pendingHostCalls` entry; a synchronous lock cannot
// span this loop's await). An internal-wedge detector, not expected
// behavior.
traceDrive("driveAsync", store, done, "DEADLOCK-TRAP-deferred");
trapIf(
true,
`wasm trap: deadlock detected: event loop cannot make further ` +
`progress (${what}: every settled activation tail is deferred ` +
`on a non-enterable instance and no host call is outstanding)`,
);
}
const chosen = parked[0];
const chosenTag = tagAwait(chosen);
const others: Promise<AwaitWinner | null>[] = parked.slice(1).map(tagAwait);
Expand Down Expand Up @@ -992,7 +1065,13 @@
// has already consumed. Compare promise identity too.
if (
winner !== null && store.awaiting.has(winner.t) &&
winner.t.awaiting === winner.p
winner.t.awaiting === winner.p &&
// Dispatch guard, the same predicate `Store.serviceSettled` uses
// (issue #156): never resume into an instance that is not
// host-enterable. The entry is (also) queued in `store.settled` by
// `noteAwaiting`'s continuation, and `serviceSettled` owns it once
// the lock releases.
dispatchableTail(winner.t)
) {
winner.t.resumeWith(winner.value, winner.failure);
}
Expand Down Expand Up @@ -1633,7 +1712,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 1715 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 1715 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 @@ -1679,7 +1758,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 1761 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 1761 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
98 changes: 87 additions & 11 deletions runtime/src/task/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,23 @@ export function isInstancePoisoned(inst: object): boolean {
return poisonedInstances.has(inst);
}

/**
* May a settled activation tail for parked thread `t` be DISPATCHED now
* (issue #156)? True iff its instance is host-enterable — `Thread.resumeWith`
* brackets the resumption with `enterFrom(null)` — or POISONED, in which case
* `resumeWith`'s early return retires it and deferring would leak forever.
*
* CONTRACT: a parked entry without a reachable `task.inst` (the partial
* thread doubles the host-pump tests park in `Store.awaiting`) holds no
* reentrance state, so there is nothing to defer on: dispatchable.
*/
// deno-lint-ignore no-explicit-any
export function dispatchableTail(t: any): boolean {
const inst = t?.task?.inst;
if (inst === undefined || inst === null) return true;
return isInstancePoisoned(inst) || inst.mayEnterFrom(null);
}

/**
* The recorded cause of an instance's poisoning: the original trap that
* broke the enter/leave bracket (deltic#145). `undefined` when the instance
Expand Down Expand Up @@ -818,25 +835,77 @@ export class Store {
}

/**
* Service every settled activation tail, in settle order. Returns whether
* anything ran. EVERY driving loop must call this before (and interleaved
* with) `tick` — the queue gates `tick`, so a driver that never services
* it wedges the store (observed: host-stream pumping between export
* calls). A `resumeWith` may throw (trap unwinding); callers propagate or
* park it exactly as they do for `tick`.
* Service settled activation tails. Returns whether anything ran. EVERY
* driving loop must call this before (and interleaved with) `tick` — the
* queue gates `tick`, so a driver that never services it wedges the store
* (observed: host-stream pumping between export calls). A `resumeWith` may
* throw (trap unwinding); callers propagate or park it exactly as they do
* for `tick`.
*
* A tail whose instance is NOT host-enterable is DEFERRED IN PLACE — left
* in the queue, skipped here — until the lock releases (issue #156).
* `resumeWith` brackets the resumption with `enterFrom(null)`, and under
* the shared synthetic per-instantiation root a host entry into ANY
* instance of the graph locks the root, so while one instance is entered a
* sibling's tail cannot be dispatched: dispatching it tripped
* `resumeWith`'s enterability assert (which, mutating before asserting,
* also stranded the thread and lost the settle).
*
* Deferral is safe because `!inst.mayEnterFrom(null)` is EXACTLY `tick`'s
* candidate-filter predicate on the same instance: while a tail of `inst`
* is deferred, `tick` cannot resume any thread of `inst` either, so the
* phantom-state gate the queue exists to enforce is preserved per-instance
* by construction.
*
* The ordering discipline is therefore per-instance settle order. Cross-
* instance order relaxes only when enterability defers a tail, which is
* conforming schedule nondeterminism: in definitions.py the tail runs
* atomically inside the entered bracket, so a host entry admitted during a
* park necessarily orders before the parked activation's tail there.
*
* A POISONED instance's tail is still dispatched: `resumeWith`'s poison
* early-return retires it, and deferring it would leak forever — a
* poisoned leaf keeps its lock permanently.
*/
serviceSettled(): boolean {
let did = false;
while (this.settled.length > 0) {
const s = this.settled.shift()!;
if (this.awaiting.has(s.t)) {
// Rescan from the head after every dispatch: a dispatched tail runs guest
// code synchronously, which can change lock/poison state and can re-enter
// `serviceSettled` (mutating the queue under us).
scan: for (;;) {
for (let i = 0; i < this.settled.length; i++) {
const s = this.settled[i];
// Stale: the thread was resumed elsewhere (driveAsync's race-winner
// path). Drop it regardless of enterability; it is not progress.
if (!this.awaiting.has(s.t)) {
this.settled.splice(i, 1);
continue scan;
}
if (!dispatchableTail(s.t)) continue;
this.settled.splice(i, 1);
(s.t as {
resumeWith(v: unknown, f?: { error: unknown }): void;
}).resumeWith(s.value, s.failure);
did = true;
continue scan;
}
// A full scan found nothing stale and nothing serviceable.
return did;
}
return did;
}

/**
* "Would a `serviceSettled` call make progress right now?" — i.e. some
* entry is stale (would be removed) or serviceable (would be dispatched).
* A queue holding ONLY deferred tails (issue #156) answers false: `tick`
* must not be gated by them, and the driving loops must not spin on them.
*/
hasServiceableSettled(): boolean {
for (const s of this.settled) {
if (!this.awaiting.has(s.t)) return true;
if (dispatchableTail(s.t)) return true;
}
return false;
}

/**
Expand Down Expand Up @@ -931,7 +1000,14 @@ export class Store {
// Same discipline, other edge: a settled-but-unserviced activation tail
// (see `settled`) is mid-"atomic resume" from the reference's point of
// view; scheduling anything before servicing it acts on phantom state.
if (this.settled.length > 0) return false;
//
// Only a SERVICEABLE tail gates: a tail DEFERRED on a non-enterable
// instance (issue #156) cannot be dispatched now, and gating on it would
// wedge the store (and hot-spin the drivers). It does not need to gate,
// because its instance is self-excluded from the candidate set by the
// enterability filter below — the same predicate on the same instance —
// so no thread of that instance can be resumed while its tail waits.
if (this.hasServiceableSettled()) return false;
// Ready is not sufficient: the thread's instance must also be enterable
// from the host. The reference *asserts* this in `Store.tick` — a waiting
// thread's instance is always re-enterable there, because its host entry
Expand Down
8 changes: 8 additions & 0 deletions runtime/src/task/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,14 @@ export class Thread implements SchedulableThread {
// Retire quietly: the abandoned call's own driver reports, via its
// deadlock trap naming the export.
if (isInstancePoisoned(inst)) return;
// The enterability check below is an internal BACKSTOP, not a live gate:
// every dispatch site (`Store.serviceSettled`, `driveAsync`'s race-winner
// path) now guards enterable-or-poisoned before calling and DEFERS the
// tail otherwise (issue #156) — under the shared synthetic root, a host
// entry into any instance of the graph makes every sibling
// non-enterable, so this assert was reachable, and (mutating before
// asserting) it stranded the thread and lost the settle. It stays to
// protect the invariant for any future caller.
assert_(
inst.mayEnterFrom(null),
"resumeWith: parked thread's instance is not enterable from the host",
Expand Down
Loading
Loading