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
14 changes: 14 additions & 0 deletions contracts/embedder-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,20 @@ self-driving. Bounds: an operation waiting on the *embedder's* half of a
host stream/future hangs until the embedder acts (never a trap), and a
settlement-time failure surfaces on the next call into the instance.

**An async-typed export's Promise may stay pending indefinitely** (#292).
When its task parks with no ready thread and no host call outstanding —
the long-poll shape: `next: async func() -> event` woken by a later
`push` — the Promise stays pending; it settles when a later call (any
export, a host stream/future operation) runs the task to completion, and
rejects with the poisoning cause if the instance is poisoned first. This
is definitions.py `canon_lift`, whose trapping driving loop runs only for
sync-typed exports (line 2189), and wasmtime `call_concurrent` under
`run_concurrent`; the embedder's event loop is always dwelling, so the
blocking `call_async` shape (trap on idle) has no JS analogue and is not
offered. An async guest that genuinely can never progress therefore
hangs rather than traps, as it does under `run_concurrent`. **Sync-typed
exports keep the spec's deadlock trap** in every mode.

### Import marks

Three marks, each a `Symbol.for` brand defined in and imported from
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ Mapping the reference model onto the web platform:
| scheduler | JS event loop + explicit ready queues; cooperative, matching the CM model — no preemption exists or is needed |
| `Waitable` / `WaitableSet` | host-side event structures; `wait` = suspension (stackful) or the callback return-code protocol (stackless) |
| callback ABI | no suspension at all: the scheduler invokes the callback export with events |
| sync `canon_lift` driving loop | same scheduler: pump ready threads until resolved, with the spec's deadlock trap |
| sync `canon_lift` driving loop | same scheduler: pump ready threads until resolved, with the spec's deadlock trap. Async-typed exports have no such loop in the reference (`canon_lift` returns after the first resume, line 2189), so their driver exits on idle and the Promise stays pending for a later driver to settle — wasmtime `call_concurrent`, not `call_async` (#292; contracts/embedder-api.md §"Functions and async") |
| `Subtask`, backpressure, cancellation | direct ports of the reference structures |

JSPI's three roles, precisely:
Expand Down
12 changes: 11 additions & 1 deletion harness/src/runtime-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,17 @@ export class RuntimeExecutor implements CommandExecutor {
}
let handle: ComponentHandle;
try {
handle = await instantiateComponent({ plan, componentBytes: bytes, adapters });
handle = await instantiateComponent({
plan,
componentBytes: bytes,
adapters,
// The wast `invoke` directive is a BLOCKING call: an async-typed
// export that goes idle with its task unresolved is a deadlock for
// this runner, not a Promise to leave pending (#292). wasmtime's
// wast runner takes the same route — `[Typed]Func::call_async`, i.e.
// `run_concurrent_trap_on_idle`.
trapOnIdle: true,
});
} catch (e) {
if (e instanceof Trap) {
if (expect === "trap") throw new TrapError(e.message);
Expand Down
189 changes: 184 additions & 5 deletions runtime/src/exec/boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
driveSyncLift,
EventCode,
withActivation,
addInstancePoisonedListener,
hasRealHostCall,
isInstancePoisoned,
type EventTuple,
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 @@ -435,17 +436,45 @@
);
}

/**
* What a driving loop does when it runs out of moves with `done()` still
* false — the reference's empty-candidate-set state.
*
* `"trap"` is definitions.py `canon_lift`'s `trap_if(not candidates)` (line
* 2189) and the default for every driver in this runtime: the sync-lift
* paths, the destructor entry, and the pumps (which cannot reach the verdict
* anyway — see `driveStoreAsync`).
*
* `"exit"` returns instead, leaving `done()` false for the caller to notice.
* It exists for ONE caller: an **async-typed** lifted export (#292). The
* reference's driving loop is guarded by `if not ft.async_`, so for such an
* export `canon_lift` returns right after the first `thread.resume()` and the
* driving — with it, the idle verdict — belongs to the embedder's
* `Store.tick`, which never traps. wasmtime draws the same line:
* `run_concurrent` is `poll_until(trap_on_idle=false)` (a `call_concurrent`
* future simply stays pending on idle), and the trapping variant
* `run_concurrent_trap_on_idle` is `pub(super)`, backing only the blocking
* `[Typed]Func::call_async`. Polyengine's Promise-shaped export is
* `call_concurrent` under an always-live `run_concurrent` (docs/architecture.md
* §"Mapping the reference model"), hence "exit".
*/
type IdlePolicy = "trap" | "exit";

/**
* Pump `store` until `done()` holds. Returns `undefined` if that was achieved
* synchronously, or a Promise that settles when it has been.
*
* Under `idle: "exit"` it may also return with `done()` still false; see
* `IdlePolicy`.
*/
function drive(
store: Store,
done: () => boolean,
what: string,
idle: IdlePolicy = "trap",
): void | Promise<void> {
try {
return driveLoop(store, done, what);
return driveLoop(store, done, what, idle);
} catch (e) {
// EXIT BY EXCEPTION IS STILL AN EXIT. A trap unwinds this call, but the
// sibling work this loop already started — a registered host call, a
Expand All @@ -461,6 +490,7 @@
store: Store,
done: () => boolean,
what: string,
idle: IdlePolicy,
): void | Promise<void> {
for (;;) {
traceDrive("drive", store, done, "top");
Expand Down Expand Up @@ -493,9 +523,19 @@
// activation has not run yet (see `Store.tick`).
if (store.awaiting.size > 0 || store.hasPendingResumptions()) {
traceDrive("drive", store, done, "->async(awaiting/pending)");
return driveAsync(store, done, what);
return driveAsync(store, done, what, idle);
}
if (store.pendingHostCalls.size === 0) {
// The idle verdict. Under "exit" (an async-typed lift, #292) this is
// not a fault at all: the task simply has nothing to run right now and
// the export's Promise stays pending until a later driver finishes it.
if (idle === "exit") {
traceDrive("drive", store, done, "EXIT-idle");
// Same hand-off as the `done()` exit above: work this loop started
// outlives it.
ensureSettlementPump(store);
return;
}
traceDrive("drive", store, done, "DEADLOCK-TRAP");
trapIf(
true,
Expand All @@ -505,7 +545,7 @@
);
}
traceDrive("drive", store, done, "->async(hostcalls)");
return driveAsync(store, done, what);
return driveAsync(store, done, what, idle);
}
}

Expand Down Expand Up @@ -960,6 +1000,7 @@
store: Store,
done: () => boolean,
what: string,
idle: IdlePolicy = "trap",
): Promise<void> {
const depth = storeDriverDepth(store) + 1;
driverDepth.set(store, depth);
Expand Down Expand Up @@ -1152,6 +1193,10 @@
continue;
}
if (store.readyCandidates().length === 0) {
if (idle === "exit") {
traceDrive("driveAsync", store, done, "EXIT-idle");
return;
}
trapIf(
true,
`wasm trap: deadlock detected: event loop cannot make ` +
Expand Down Expand Up @@ -1336,6 +1381,10 @@
continue;
}
if (store.pendingHostCalls.size === 0) {
if (idle === "exit") {
traceDrive("driveAsync", store, done, "EXIT-idle");
return;
}
traceDrive("driveAsync", store, done, "DEADLOCK-TRAP");
trapIf(
true,
Expand Down Expand Up @@ -1439,6 +1488,46 @@
*/
export const SYNC_ENTRY: unique symbol = Symbol("polyengine.syncEntry");

// ---------------------------------------------------------------------------
// Pending async-typed lifts, and their poisoning (#292)
// ---------------------------------------------------------------------------
//
// An async-typed export whose driver exited idle (see `IdlePolicy`) leaves a
// host-visible Promise settled by nothing but the task itself finishing. If
// the task instead dies — a LATER driver runs it and traps — the instance is
// poisoned and that task's threads will never unregister, so the Promise
// would hang forever. That is precisely the failure #66 fixed for parked
// stream/future ends, and it gets the same treatment: a poisoning listener
// that rejects every pending lift of the instance with the poisoning cause.
//
// Registered on the extra-listener seam rather than `setOnInstancePoisoned`
// (which streams.ts owns) — see `addInstancePoisonedListener` for the
// evaluation-order reason both are seams.
const pendingLifts = new WeakMap<object, Set<(cause: unknown) => void>>();

function registerPendingLift(inst: object, reject: (c: unknown) => void): void {
let s = pendingLifts.get(inst);
if (s === undefined) pendingLifts.set(inst, (s = new Set()));
s.add(reject);
}

function unregisterPendingLift(
inst: object,
reject: (c: unknown) => void,
): void {
pendingLifts.get(inst)?.delete(reject);
}

addInstancePoisonedListener((inst, cause) => {
const s = pendingLifts.get(inst as object);
if (s === undefined || s.size === 0) return;
// Drained before dispatch: a rejection handler running synchronously must
// not see, or re-enter, this set.
const waiters = [...s];
s.clear();
for (const r of waiters) r(cause);
});

export function createLiftedFunction(input: {
name: string;
ft: FuncType;
Expand Down Expand Up @@ -1497,6 +1586,17 @@
* is nothing to poison — the same structural safety as `entryRefusal`.
*/
refuseOnEntryHops?: boolean;
/**
* Make **async-typed** exports trap on idle instead of leaving their
* Promise pending (#292). Default false; see `IdlePolicy`.
*
* `InstantiateInput.trapOnIdle`'s only consumer is the conformance harness,
* whose `invoke` directive is a *blocking* call — the wast semantics
* wasmtime serves with `run_concurrent_trap_on_idle` behind
* `[Typed]Func::call_async`, not with `call_concurrent`. It is deliberately
* absent from the embedder layer's options.
*/
trapOnIdle?: boolean;
}): (...args: ComponentValue[]) => unknown {
const {
name,
Expand All @@ -1516,6 +1616,9 @@
// built-in, so it is `promising`-wrapped exactly when the imports are
// `Suspending`-wrapped.
const enteredCore = enterWasm(core, mode);
// See the comment at the `drive` call in `invokeNow` and `IdlePolicy`.
const idlePolicy: IdlePolicy =
ft.async === true && input.trapOnIdle !== true ? "exit" : "trap";
const taskOpts: TaskOptions = {
async_: opts.async,
callback: opts.callback !== null,
Expand Down Expand Up @@ -1751,7 +1854,60 @@
throw e;
}

/**
* The task outlived its driver (#292): hand the host a Promise settled by
* the task itself.
*
* Resolution rides `finishHostEntry` unchanged — it already holds
* `completed`/`resultsToHost` — fired from `Task.onFinished`, i.e. the
* moment this task's last thread unregisters. That point is safe for the
* old `done` predicate's task-scoped clauses: `resolvedSeen` is
* guaranteed (`unregisterThread`'s own `trapIf(state !== "resolved")`
* fires first otherwise), and this task's threads are gone from
* `store.awaiting`, so `midWasmCall()` is false by construction.
* `hopParked()` is deliberately NOT re-tested: a hop is the obligation of
* the driver that put it in flight (#280), never of a Promise waiting on
* another task.
*
* Rejection has two sources: `finishHostEntry` itself throwing, and the
* instance being poisoned by a later driver that ran this task into a
* trap. Neither calls `unwind()`. A trap on the background path happens
* under ANOTHER driver's `invokeNow`, whose own `catch` already unwinds
* the FACT sync-call scopes and restores `may_leave` — running it twice
* would restore sibling instances' `may_leave` from underneath a lift
* that driver is still mid-flight in. This mirrors what already happens
* to a post-`task.return` producer thread that traps later.
*/
const backgroundCompletion = (): Promise<unknown> =>
new Promise((resolve, reject) => {
// Degenerate case: the task is already over (its threads unregistered
// during the drive) and only a foreign hop kept `done` false. Nothing
// will fire `onFinished`, so settle now.
if (task.threads.length === 0) {
try {
resolve(finishHostEntry());
} catch (e) {
reject(e);
}
return;
}
const onPoison = (cause: unknown): void => {
task.onFinished = null;
reject(cause);
};
registerPendingLift(inst, onPoison);
task.onFinished = () => {
unregisterPendingLift(inst, onPoison);
try {
resolve(finishHostEntry());
} catch (e) {
reject(e);
}
};
});

let pending: void | Promise<void>;
let driveDone: () => boolean = () => true;
try {
// Completion is "the task resolved AND its threads have drained", not
// merely "resolved". `task.return` resolves the task, but the activation
Expand Down Expand Up @@ -1810,24 +1966,47 @@
// covers this task's own suspended thread.
const midWasmCall = () => task.threads.some((t) => store.awaiting.has(t));
const hopParked = () => entryHopThreads(store).length > 0;
driveDone = () => resolvedSeen && !midWasmCall() && !hopParked();
// ASYNC-TYPED EXPORTS DO NOT TRAP ON IDLE (#292). definitions.py
// `canon_lift` runs the driving loop — and with it the
// empty-candidate-set `trap_if` — only `if not ft.async_` (line 2189);
// for an async-typed export it returns right after the first
// `thread.resume()` and driving is the embedder's `Store.tick`, which
// never traps. wasmtime splits the same way (`run_concurrent` =
// `poll_until(trap_on_idle=false)` vs the `pub(super)`
// `run_concurrent_trap_on_idle` behind the blocking `call_async`), and
// polyengine's Promise-shaped export is the `call_concurrent` side.
// So the driver EXITS — it does not park and does not trap — and the
// task is left live for whichever driver next runs the store (the next
// export call, the settlement pump, a host stream op): exactly the
// between-calls liveness that already services post-`task.return`
// producer threads. Repro: an async export parked WAITing on an
// intra-component future a later export call writes.
// Sync-typed exports are unchanged: their loop traps on idle in every
// mode, which is what the paragraphs above describe.
pending = drive(
store,
() => resolvedSeen && !midWasmCall() && !hopParked(),
driveDone,
`export '${name}'`,
idlePolicy,
);
} catch (e) {
unwind();
throw e;
}
if (pending === undefined) {
try {
if (idlePolicy === "exit" && !driveDone()) return backgroundCompletion();
return finishHostEntry();
} catch (e) {
unwind();
throw e;
}
}
return pending.then(finishHostEntry, (e) => {
return pending.then(() => {
if (idlePolicy === "exit" && !driveDone()) return backgroundCompletion();
return finishHostEntry();
}, (e) => {
unwind();
throw e;
});
Expand Down Expand Up @@ -2188,7 +2367,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 2370 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 2370 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 @@ -2234,7 +2413,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 2416 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 2416 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
Loading
Loading