From ee99949b7c95759538b701caa8a59f7679fca9c7 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Mon, 10 Aug 2026 21:39:56 -0400 Subject: [PATCH] lift atomicity: gate host entry on jspi hop quiescence; retire poisoned late-settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In jspi mode a lifted export's entry is promising-wrapped, so even a guest turn that completes synchronously settles a microtask later (pin (j)) — a HOP between the guest's core return and the host-side result lift. The invoke wrapper releases the reentrance bracket when the first segment parks (including hop-parks), so a second host call could enter and run a full guest turn against memory the pending lift was about to read. definitions.py canon_lift (sync options) runs core + lift atomically inside one bracket; the hop window has no counterpart there. Found by the wosh consumer within minutes of bumping past the parking kernel (whose marked wasi imports auto-detect plain sync components into jspi mode): the mosh engine's tick -> list> lift read a concurrent feed-keys turn's reallocated memory — Trap: list too long — poisoning the instance; every later settle of the instance's parked threads then died on resumeWith's enterability assert, burying the real trap under an assert cascade. Fix, two halves: - exec/boundary.ts: THE HOP-QUIESCENCE GATE — a host call into a jspi-mode instance defers (awaitHopQuiescence) while the instance has a hop-parked activation: an awaiting thread with no owning SuspensionPoint, the same (b)/(c) discriminator hasRunnableWork uses. Genuine JSPI suspensions keep the documented interleaving divergence (host-import re-entry relies on it); plain mode keeps its synchronous fast path untouched. Progress is guaranteed — hops settle on the engine's schedule independent of other activations. - task/thread.ts + scheduler.ts: poisoned-instance late settles are RETIRED quietly instead of assert-cascading (poisonedInstances WeakSet beside the #66 seam; boundary's invoke-path poison now routes through notifyInstancePoisoned so the marker is recorded). The abandoned call's driver reports via its deadlock trap naming the export; the ORIGINAL trap stays the loud one. Regression pin: runtime/tests/jspi/hop_atomicity_test.ts over fixtures/hop-atomicity.wat — tick() -> list> plus clobber() that overwrites the return area, flipped into jspi mode by a suspending()-marked import the guest never calls (the exact wosh mechanism; asserts !planNeedsSuspension so the provenance stays honest). Deterministic: no timing. Fails pre-fix with the wild symptom (list too long), passes post-fix incl. a self-heal round 2. Gates: test-runtime 382/0, sched-seeds (1, 4242), conformance 1254/0, test-wasi-shims 52/0, smoke-c0 4/4, websocket-conformance 55/55. Consumer verification: the wosh browser-pump reproducer (bundled, real mosh traffic) green 3/3 (failed first-try pre-fix). --- runtime/src/exec/boundary.ts | 103 ++++++++- runtime/src/task/scheduler.ts | 11 + runtime/src/task/thread.ts | 10 + .../tests/jspi/fixtures/hop-atomicity.wasm | Bin 0 -> 459 bytes runtime/tests/jspi/fixtures/hop-atomicity.wat | 141 +++++++++++++ runtime/tests/jspi/hop_atomicity_test.ts | 197 ++++++++++++++++++ 6 files changed, 454 insertions(+), 8 deletions(-) create mode 100644 runtime/tests/jspi/fixtures/hop-atomicity.wasm create mode 100644 runtime/tests/jspi/fixtures/hop-atomicity.wat create mode 100644 runtime/tests/jspi/hop_atomicity_test.ts diff --git a/runtime/src/exec/boundary.ts b/runtime/src/exec/boundary.ts index 8172a0b..6f2d1fe 100644 --- a/runtime/src/exec/boundary.ts +++ b/runtime/src/exec/boundary.ts @@ -40,7 +40,7 @@ import { setResumingThread, packSubtaskResult, PendingCapability, - retireInstanceAsyncEnds, + notifyInstancePoisoned, Store, Subtask, WaitableSet, @@ -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`). @@ -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 }, + e, + ); + } }; /** @@ -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>`) 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 | null }[] { + if (store.awaiting.size === 0) return []; + const suspended = new Set(); + 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 | null }[] = []; + for (const t of store.awaiting) { + const tt = t as unknown as { + task: { inst: unknown }; + awaiting: Promise | 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 { + 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(); + } } /** diff --git a/runtime/src/task/scheduler.ts b/runtime/src/task/scheduler.ts index b0cc163..9757c11 100644 --- a/runtime/src/task/scheduler.ts +++ b/runtime/src/task/scheduler.ts @@ -145,9 +145,20 @@ export function notifyInstancePoisoned( inst: { handles: Iterable }, 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(); + +export function isInstancePoisoned(inst: object): boolean { + return poisonedInstances.has(inst); +} + // --------------------------------------------------------------------------- // Deterministic choice // --------------------------------------------------------------------------- diff --git a/runtime/src/task/thread.ts b/runtime/src/task/thread.ts index dfb0836..83383d4 100644 --- a/runtime/src/task/thread.ts +++ b/runtime/src/task/thread.ts @@ -24,6 +24,7 @@ import { CANCELLED_TRUE, NeedsJspi, notifyInstancePoisoned, + isInstancePoisoned, PendingCapability, popCurrentThread, pushCurrentThread, @@ -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", diff --git a/runtime/tests/jspi/fixtures/hop-atomicity.wasm b/runtime/tests/jspi/fixtures/hop-atomicity.wasm new file mode 100644 index 0000000000000000000000000000000000000000..e5d8f97a644e0664308d5c2768dab4bed709f9ff GIT binary patch literal 459 zcmXw0yH3ME5S-n6*okFCEI|AL6{4d^#fBe=6Il>}V=J+QC=dowP*8*)^F9&dRL=~;Sv&5#ck4%l1ieH>UZ z0Y9zm;^4~;3@V|6`Z|oHWekb z&&4N9G^m!Z{BBowj4W6A1>BH_0 tkED9Dqh(XK&AM85m%$Z{RSzy);(|t)sJZCUqadOcr7Ez$limT*>` 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>` +;; 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>` 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` stores (begin: i32, length: i32); +;; `elem_size(list)` 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` 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")))) diff --git a/runtime/tests/jspi/hop_atomicity_test.ts b/runtime/tests/jspi/hop_atomicity_test.ts new file mode 100644 index 0000000..a48c6fb --- /dev/null +++ b/runtime/tests/jspi/hop_atomicity_test.ts @@ -0,0 +1,197 @@ +// Regression pin: a host call must not run a guest turn INSIDE another +// export's jspi entry hop, between that export's core return and its result +// LIFT. +// +// In jspi mode a SYNC-lifted export's core entry is `promising`-wrapped, so +// the engine returns a Promise that settles a microtask later even when the +// guest completed synchronously (jspi "pin (j)"). That opens a HOP between +// two steps the reference performs atomically inside ONE enter/leave bracket +// (definitions.py `canon_lift`, line 2213: the sync path lowers the args, +// calls the core function and lifts the results with the callee instance +// entered throughout): +// +// 1. the guest core function returns its i32 result pointer, and +// 2. the host lifts the result through that pointer — outer (ptr,len), +// each inner (ptr,len), then the bytes. +// +// `exec/boundary.ts` releases the reentrance bracket at the FIRST park +// (`leave()` runs before `drive`, and the lift happens later still, in +// `finishHostEntry`). A hop-park is a park, so pre-fix a SECOND host call +// could enter and run a full guest turn in that window — and if that turn +// mutates the memory the pending lift is about to read, the lift reads +// whatever the intruder left. +// +// In the wild: the wosh mosh engine's `tick`, returning `list>`, +// died under real traffic with `Trap: list too long` — a clobbered outer +// length word of 0xFFFFFFFF against `MAX_LIST_BYTE_LENGTH` = 2^28-1 +// (runtime/src/cabi/load.ts:31) — and the instance was poisoned behind it. +// +// The pin is DETERMINISTIC and timing-free: `clobber()` is issued +// unconditionally while `tick()`'s promise is still pending, and the pre-fix +// outcome is a hard trap rather than a value that might accidentally compare +// equal. Genuine JSPI suspensions (SuspensionPoint-owned parks) keep their +// documented interleaving; only hop-parks are covered here. +// +// See `fixtures/hop-atomicity.wat` for the memory layout and the +// definitions.py line references for every encoding it relies on. +import { assert, assertEquals } from "./asserts.ts"; +import { Translator } from "../../src/shim/mod.ts"; +import { instantiate, suspending } from "../../src/embedder/mod.ts"; +import { planNeedsSuspension } from "../../src/jspi/bridge.ts"; +import { isSupported } from "../../src/jspi/mechanics.ts"; + +const root = new URL("../../../", import.meta.url); + +async function readIfPresent(rel: string): Promise { + try { + return await Deno.readFile(new URL(rel, root)); + } catch { + return null; + } +} + +// Same skip-when-absent discipline as the neighbours: the shim is a build +// artifact, not a checked-in one. +const shimWasm = await readIfPresent( + "target/wasm32-unknown-unknown/release/translator_shim.wasm", +); +if (shimWasm === null) { + console.warn( + "SKIP hop atomicity: missing translator_shim.wasm " + + "(cargo build -p translator-shim --release --target wasm32-unknown-unknown)", + ); +} +const componentWasm = await Deno.readFile( + new URL("./fixtures/hop-atomicity.wasm", import.meta.url), +); + +/** The value `tick` builds on every call (fixture layout: two inner lists). */ +function assertTickValue(actual: unknown, where: string): void { + assert(Array.isArray(actual), `${where}: expected an array, got ${Deno.inspect(actual)}`); + const outer = actual as unknown[]; + assertEquals(outer.length, 2, `${where}: outer list length`); + // contracts/embedder-api.md / docs/architecture.md §7: `list` lifts as a + // Uint8Array, the outer `list<...>` as a plain array. + const expected = [[1, 2, 3], [4, 5]]; + for (let i = 0; i < 2; i++) { + const inner = outer[i]; + assert( + inner instanceof Uint8Array, + `${where}: inner[${i}] should lift as Uint8Array, got ${Deno.inspect(inner)}`, + ); + assertEquals( + Array.from(inner).join(","), + expected[i].join(","), + `${where}: inner[${i}] bytes`, + ); + } +} + +type Settled = + | { ok: true; value: unknown } + | { ok: false; error: unknown }; + +/** Observe BOTH promises' outcomes before asserting anything: a pre-fix run + * rejects one of them, and an unobserved rejection would abort the whole test + * process as an uncaught rejection instead of failing this test with a + * readable message. */ +function settle(p: unknown): Promise { + return Promise.resolve(p).then( + (value): Settled => ({ ok: true, value }), + (error): Settled => ({ ok: false, error }), + ); +} + +function valueOf(s: Settled, where: string): unknown { + if (!s.ok) { + throw new Error( + `${where}: expected fulfilment, got rejection: ${ + s.error instanceof Error ? s.error.message : Deno.inspect(s.error) + }`, + ); + } + return s.value; +} + +Deno.test({ + name: + "hop atomicity: a second host call must not run a guest turn inside a " + + "pending export's jspi entry hop (result lift stays atomic)", + ignore: shimWasm === null, + fn: async () => { + const translator = await Translator.create(shimWasm!); + const { plan, adapters } = translator.translate(componentWasm); + + // Self-documenting, and the reason the fixture carries an import it never + // calls: this component has NO blocking declaration of its own — no async + // lift, no blocking built-in — so the plan alone would run it in plain + // mode, where the entry is a direct call and no hop exists at all. The + // mode is flipped by the OTHER `chooseMode` input (executor.ts:385-392): + // a `suspending()`-marked host import in the imports record. That mirrors + // how the wosh engine got flipped (marked wasi imports it never called). + assert( + !planNeedsSuspension(plan), + "fixture's plan must NOT need suspension on its own — the jspi mode " + + "here comes from the suspending()-marked import, and a plan that " + + "needed suspension would make that provenance untestable", + ); + assert( + isSupported(), + "this pin requires an engine with JSPI (the plain path has no hop)", + ); + + const instance = await instantiate( + { plan, componentBytes: componentWasm, adapters }, + // Never called by the guest; the brand is the whole point. `instantiate` + // preserves it through the facade's import wrappers (embedder/ + // instantiate.ts:520,589), which is what `anySuspendingImport` sees. + { "test:hop/gate": { wait: suspending(() => 7) } }, + ); + const handle = instance.handle; + + // Belt and braces: if nothing got promising-wrapped there is no hop and + // the pin would be vacuous. Both core exports (`tick`, `clobber`) are + // classified suspendable because the core instance imports a + // suspendable lowering. + assert( + handle.coreInstances.some((i) => + Object.values(i.exports).some((e) => + typeof e === "function" && + handle.suspendableFuncs.has(e as unknown as object) + ) + ), + "expected the core exports to be classified suspendable (no wrap => no hop)", + ); + + const exports = instance.exports as { + tick: () => Promise; + clobber: () => Promise; + }; + + // ---- round 1: the race, issued with zero timing dependence ----------- + const a = exports.tick(); // pending: hop open, result NOT yet lifted + assert( + a instanceof Promise, + "tick must return a Promise in jspi mode (that promise IS the hop)", + ); + const b = exports.clobber(); // the second host call, inside that window + + const aSettled = await settle(a); + const bSettled = await settle(b); + + // Pre-fix this rejects with `Trap: list too long`: `clobber` ran a full + // guest turn in the hop and overwrote the outer (ptr,len) with + // 0xFFFFFFFF/0xFFFFFFFF, so `loadListFromRange` trapped on the length + // before it could even reject the bogus pointer (load.ts:120). + // Post-fix `clobber` is deferred until `tick`'s lift has completed. + assertTickValue(valueOf(aSettled, "round 1 tick()"), "round 1 tick()"); + assertEquals(valueOf(bSettled, "round 1 clobber()"), 1, "clobber() result"); + + // ---- round 2: the instance survived ---------------------------------- + // `tick` rewrites its whole layout on every call, so the component + // self-heals after a clobber. A second round-trip therefore proves the + // instance is still healthy — in particular that no trap poisoned it and + // left it un-enterable (the wild failure's second act). + assertTickValue(await exports.tick(), "round 2 tick()"); + }, +});