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
103 changes: 95 additions & 8 deletions runtime/src/exec/boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
setResumingThread,
packSubtaskResult,
PendingCapability,
retireInstanceAsyncEnds,
notifyInstancePoisoned,
Store,
Subtask,
WaitableSet,
Expand Down Expand Up @@ -984,12 +984,7 @@ export function createLiftedFunction(input: {
);
}

return (...hostArgs: ComponentValue[]): unknown => {
if (hostArgs.length !== ft.params.length) {
throw new TypeError(
`${name}: expected ${ft.params.length} argument(s), got ${hostArgs.length}`,
);
}
const invokeNow = (hostArgs: ComponentValue[]): unknown => {
stats.liftedCalls++;
// A trap remembered during an earlier call must never be attributed to
// this one (see intrinsics `HostTrapState`).
Expand Down Expand Up @@ -1138,7 +1133,15 @@ export function createLiftedFunction(input: {
*/
const poison = (e: unknown): void => {
entered = false; // consumed: the lock is now permanent
for (const i of enteredSet) retireInstanceAsyncEnds(i, e);
// Through the seam (not retireInstanceAsyncEnds directly) so the
// poison marker is recorded too — `Thread.resumeWith` retires this
// instance's late settles against it instead of assert-cascading.
for (const i of enteredSet) {
notifyInstancePoisoned(
i as unknown as { handles: Iterable<unknown> },
e,
);
}
};

/**
Expand Down Expand Up @@ -1267,6 +1270,90 @@ export function createLiftedFunction(input: {
throw e;
});
};

return (...hostArgs: ComponentValue[]): unknown => {
if (hostArgs.length !== ft.params.length) {
throw new TypeError(
`${name}: expected ${ft.params.length} argument(s), got ${hostArgs.length}`,
);
}
// THE HOP-QUIESCENCE GATE (jspi mode only; hop_atomicity_test.ts).
//
// A promising-wrapped entry settles a microtask AFTER the guest's core
// call returns, even when nothing suspended (jspi pin (j)) — so there
// is a hop between core return and the host-side result LIFT, and the
// reentrance bracket has already been released by then (`leave()` runs
// when the first segment parks). In the reference no such window
// exists: `canon_lift` for sync options runs core + lift atomically
// inside one entered bracket. Admitting another host call into the
// window lets a full guest turn mutate the memory the pending lift
// will read — observed as `Trap: list too long` lifting the wosh
// engine's `tick` (`list<list<u8>>`) after a concurrent `feed-keys`
// turn reused the return area.
//
// The gate: defer this call until the instance has no HOP-parked
// activation. A hop-park is an `awaiting` thread with no owning
// `SuspensionPoint` — the same discriminator `hasRunnableWork` uses;
// genuinely JSPI-suspended activations (SuspensionPoint-owned) keep
// today's documented interleaving (the wasmtime-tracking divergence in
// jspi/bridge.ts), which host-import re-entry patterns rely on.
// Plain mode has no hops and keeps its synchronous fast path exactly.
if (mode === "jspi" && entryHopThreads(store, inst).length > 0) {
return awaitHopQuiescence(store, inst).then(() => invokeNow(hostArgs));
}
return invokeNow(hostArgs);
};
}

/**
* 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.
*/
function entryHopThreads(
store: Store,
inst: unknown,
): { awaiting: Promise<unknown> | null }[] {
if (store.awaiting.size === 0) return [];
const suspended = new Set<unknown>();
for (const w of store.waiting) {
const owner = (w as { owner?: unknown }).owner;
if (owner !== undefined && owner !== null) suspended.add(owner);
}
const out: { awaiting: Promise<unknown> | null }[] = [];
for (const t of store.awaiting) {
const tt = t as unknown as {
task: { inst: unknown };
awaiting: Promise<unknown> | null;
};
if (tt.task.inst === inst && !suspended.has(t)) out.push(tt);
}
return out;
}

/**
* Wait until `inst` has no hop-parked activation. Each settled hop is
* serviced synchronously (`serviceSettled` runs the lift segment), after
* which the activation either completed or re-parked; re-derive and
* repeat. Progress is guaranteed: a hop promise settles on the engine's
* own schedule, independent of any other activation of the instance, and
* a settled-but-unserviced hop resolves the race instantly. Multiple
* gated callers re-derive independently (no strict FIFO; starvation-free
* in practice because hops are sub-microtask).
*/
async function awaitHopQuiescence(store: Store, inst: unknown): Promise<void> {
for (;;) {
const hops = entryHopThreads(store, inst);
if (hops.length === 0) return;
await Promise.race(
hops.map((t) => (t.awaiting ?? Promise.resolve()).then(
() => undefined,
() => undefined,
)),
);
store.serviceSettled();
}
}

/**
Expand Down
11 changes: 11 additions & 0 deletions runtime/src/task/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,20 @@ export function notifyInstancePoisoned(
inst: { handles: Iterable<unknown> },
cause: unknown,
): void {
poisonedInstances.add(inst);
onInstancePoisoned?.(inst, cause);
}

/** Poisoned instances, for late-settle retirement (`Thread.resumeWith`):
* a WeakSet mirror of streams.ts's `retiredInstances`, kept here because
* thread.ts cannot import streams.ts (the same evaluation-order
* constraint that made `setOnInstancePoisoned` an injection seam). */
const poisonedInstances = new WeakSet<object>();

export function isInstancePoisoned(inst: object): boolean {
return poisonedInstances.has(inst);
}

// ---------------------------------------------------------------------------
// Deterministic choice
// ---------------------------------------------------------------------------
Expand Down
10 changes: 10 additions & 0 deletions runtime/src/task/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
CANCELLED_TRUE,
NeedsJspi,
notifyInstancePoisoned,
isInstancePoisoned,
PendingCapability,
popCurrentThread,
pushCurrentThread,
Expand Down Expand Up @@ -155,6 +156,15 @@ export class Thread implements SchedulableThread {
// Capability signals release the lock, for the same reason as in `tick`:
// they mark the RUNTIME incomplete, not the component faulted.
const inst = this.task.inst;
// A poisoned instance's parked segments never run again: this settle
// belongs to an activation that was in flight when a SIBLING activation
// trapped (the trap kept the reentrance lock — CM poisoning — and #66
// retired the handle tables). Resuming would re-enter the corpse, and
// asserting turned one legible trap into an assert cascade (the
// wosh-M2 shape: `list too long`, then this assert as second victim).
// Retire quietly: the abandoned call's own driver reports, via its
// deadlock trap naming the export.
if (isInstancePoisoned(inst)) return;
assert_(
inst.mayEnterFrom(null),
"resumeWith: parked thread's instance is not enterable from the host",
Expand Down
Binary file added runtime/tests/jspi/fixtures/hop-atomicity.wasm
Binary file not shown.
141 changes: 141 additions & 0 deletions runtime/tests/jspi/fixtures/hop-atomicity.wat
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
;; hop-atomicity.wat — regression fixture for HOST-CALL ATOMICITY across the
;; jspi entry hop (runtime/src/exec/boundary.ts, the `leave()` that precedes
;; `drive`/`finishHostEntry`).
;;
;; THE SHAPE THIS PINS
;;
;; In jspi mode a SYNC-lifted export's core entry is `promising`-wrapped, so
;; even when the guest completes synchronously the engine hands back a Promise
;; that settles one microtask later (jspi "pin (j)"). That inserts a HOP
;; between two steps the reference performs atomically:
;;
;; 1. the guest core function returns its i32 result pointer, and
;; 2. the host LIFTS the result out of linear memory through that pointer
;; (reading the outer (ptr,len) pair, then each inner (ptr,len), then the
;; bytes).
;;
;; `canon_lift` in definitions.py (line 2213) runs both inside ONE
;; enter/leave bracket: `lower_flat_values`/`lift_flat_values` happen while the
;; callee instance is still entered, so no other host call can observe — let
;; alone mutate — the half-consumed result. Our boundary releases the
;; reentrance bracket at the FIRST park, and a hop-park is a park, so a second
;; host call could enter and run a FULL guest turn in that window. If that turn
;; overwrites the memory the pending lift is about to read, the lift reads
;; whatever the intruder left behind.
;;
;; Observed in the wild as `Trap: list too long` while lifting a
;; `list<list<u8>>` result (the wosh mosh engine's `tick` under real traffic),
;; followed by instance poisoning: a clobbered outer length word of
;; 0xFFFFFFFF exceeds `MAX_LIST_BYTE_LENGTH` (2^28-1,
;; runtime/src/cabi/load.ts:31) by four orders of magnitude.
;;
;; ENCODINGS USED HERE (verified against definitions.py, not memory)
;;
;; * SYNC-LIFT RESULT POINTER (`flatten_functype`, line 1844-1856): for a
;; non-async lift, `flat_results = flatten_types(result_type)`, and
;; `if len(flat_results) > MAX_FLAT_RESULTS` (= 1, line 1842) then
;; `case 'lift': flat_results = [opts.memory.ptr_type()]`. `list<list<u8>>`
;; flattens to (i32, i32) — two, so `tick`'s CORE signature is
;; `(result i32)` and the single i32 it returns is a pointer the CALLEE
;; chose, not a caller-supplied out-param. (The out-param spelling is the
;; 'lower' case on the very next line: there the retptr is appended to
;; flat_params instead. Getting these two backwards is the classic error;
;; this fixture is the 'lift' side.)
;; * WHAT THE HOST READS AT THAT POINTER (`lift_flat_values`, line 2118):
;; the over-max branch does `load(cx, ptr, TupleType(ts))` — so for the
;; single result type `list<list<u8>>` the host loads a 1-tuple, i.e. the
;; 8 bytes at `ptr` are exactly the outer list's (begin, length) pair
;; (`load_list`, and runtime/src/cabi/load.ts `loadList`). It also traps
;; on a misaligned or out-of-bounds `ptr` — both satisfied here (0x100 is
;; 4-aligned, one page of memory is reserved).
;; * LIST REPRESENTATION: a `list<T>` stores (begin: i32, length: i32);
;; `elem_size(list<u8>)` is 8 and its alignment 4, so the inner element
;; array is 2 * 8 = 16 bytes of (ptr,len) pairs.
;;
;; No realloc is declared: lifting only READS guest memory. Realloc is the
;; lowering direction (host -> guest), and neither export takes parameters.
;;
;; LAYOUT (all addresses fixed and 4-aligned, page 0 of a 1-page memory)
;;
;; 0x100 outer list (begin=0x200, length=2) <- `tick` returns 0x100
;; 0x200 inner[0] = (begin=0x300, length=3)
;; 0x208 inner[1] = (begin=0x310, length=2)
;; 0x300 bytes 01 02 03
;; 0x310 bytes 04 05
;;
;; so `tick` lifts as [[1,2,3],[4,5]] — `list<u8>` arrives host-side as a
;; Uint8Array and the outer list as an array (contracts/embedder-api.md;
;; docs/architecture.md §7).
;;
;; `tick` WRITES the whole layout on every call rather than relying on a data
;; segment, so the component self-heals: round 2 of the test can assert the
;; same value and thereby prove the instance stayed healthy after a clobber.
;;
;; `clobber` fills 0x100..0x400 with 0xFF. Against a PENDING un-lifted `tick`
;; result that turns the outer (begin,length) into (0xFFFFFFFF, 0xFFFFFFFF);
;; the length alone makes `loadListFromRange` trap `list too long` before it
;; can even consider the bogus pointer (load.ts:120 precedes the alignment and
;; bounds checks). So the pre-fix failure is a deterministic TRAP, not a
;; garbage value that might accidentally compare equal.
;;
;; THE IMPORT IS NEVER CALLED, but it IS lowered and linked into the core
;; module — the translator's import list is derived from actual LOWERINGS, so
;; an import that is merely declared at the component level is dead-code
;; eliminated and never reaches the embedder facade as a leaf (verified
;; empirically: `requiredImports` returned [] for the declaration-only
;; spelling, and the instantiation stayed in plain mode). Its only job is to
;; give the test somewhere to
;; hand a `suspending()`-marked host function, which is what flips this
;; instantiation into jspi mode: `chooseMode` takes
;; `planNeedsSuspension(plan) || anySuspendingImport(imports)`
;; (exec/executor.ts:385-392), and this plan has no blocking declaration at all
;; — no async lift, no blocking built-in. That mirrors how the wosh engine got
;; flipped (marked wasi imports it never called). Declared as an INTERFACE
;; import so the brand is found through `anySuspendingImport`'s one level of
;; interface-record members (jspi/suspending.ts).
(component
(import "test:hop/gate" (instance $gate
(export "wait" (func (result u32)))))
(alias export $gate "wait" (func $wait))
;; Lowered so the translator emits an import leaf; the core module takes it
;; and never calls it.
(canon lower (func $wait) (core func $wait'))

(core module $Core
(import "gate" "wait" (func $wait (result i32)))
(memory (export "mem") 1)

;; Write the known layout, then hand back the result pointer. Writing on
;; every call is what makes the component self-healing after a clobber.
(func (export "tick") (result i32)
;; outer list -> 2 elements starting at 0x200
(i32.store (i32.const 0x100) (i32.const 0x200))
(i32.store (i32.const 0x104) (i32.const 2))
;; inner[0] = 3 bytes at 0x300
(i32.store (i32.const 0x200) (i32.const 0x300))
(i32.store (i32.const 0x204) (i32.const 3))
;; inner[1] = 2 bytes at 0x310
(i32.store (i32.const 0x208) (i32.const 0x310))
(i32.store (i32.const 0x20c) (i32.const 2))
;; the bytes themselves: 01 02 03 / 04 05
(i32.store8 (i32.const 0x300) (i32.const 1))
(i32.store8 (i32.const 0x301) (i32.const 2))
(i32.store8 (i32.const 0x302) (i32.const 3))
(i32.store8 (i32.const 0x310) (i32.const 4))
(i32.store8 (i32.const 0x311) (i32.const 5))
;; The sync-lift result POINTER (definitions.py line 1850).
(i32.const 0x100))

;; Overwrite the whole results area — outer pair, inner pairs and byte
;; regions alike — with 0xFF.
(func (export "clobber") (result i32)
(memory.fill (i32.const 0x100) (i32.const 0xff) (i32.const 0x300))
(i32.const 1)))

(core instance $i (instantiate $Core
(with "gate" (instance (export "wait" (func $wait'))))))

(func (export "tick") (result (list (list u8)))
(canon lift (core func $i "tick") (memory $i "mem")))
(func (export "clobber") (result u32)
(canon lift (core func $i "clobber"))))
Loading
Loading