diff --git a/runtime/src/exec/boundary.ts b/runtime/src/exec/boundary.ts index a2fc404..b0005ef 100644 --- a/runtime/src/exec/boundary.ts +++ b/runtime/src/exec/boundary.ts @@ -35,6 +35,7 @@ import { withActivation, hasRealHostCall, hasResumingThread, + dispatchableTail, type EventTuple, NeedsJspi, needsJspi, @@ -834,7 +835,7 @@ async function driveAsync( // 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); @@ -842,7 +843,10 @@ async function driveAsync( 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). @@ -881,7 +885,14 @@ async function driveAsync( // 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((r) => setTimeout(() => r(false), 0)), @@ -899,7 +910,18 @@ async function driveAsync( // 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; @@ -915,7 +937,21 @@ async function driveAsync( // 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) { @@ -950,7 +986,16 @@ async function driveAsync( // `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 @@ -958,7 +1003,35 @@ async function driveAsync( // 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[] = parked.slice(1).map(tagAwait); @@ -992,7 +1065,13 @@ async function driveAsync( // 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); } diff --git a/runtime/src/task/scheduler.ts b/runtime/src/task/scheduler.ts index 71a747f..ff3b869 100644 --- a/runtime/src/task/scheduler.ts +++ b/runtime/src/task/scheduler.ts @@ -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 @@ -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; } /** @@ -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 diff --git a/runtime/src/task/thread.ts b/runtime/src/task/thread.ts index fc74fc4..96bbbdd 100644 --- a/runtime/src/task/thread.ts +++ b/runtime/src/task/thread.ts @@ -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", diff --git a/runtime/tests/settled_deferral_test.ts b/runtime/tests/settled_deferral_test.ts new file mode 100644 index 0000000..fd5eead --- /dev/null +++ b/runtime/tests/settled_deferral_test.ts @@ -0,0 +1,108 @@ +// Driver-level coverage for issue #156: a settled activation tail whose +// instance is not host-enterable is DEFERRED IN PLACE, and `driveAsync` must +// park (not spin) until the lock releases. +// +// Shape manufactured below — the reachable one from the issue's analysis: +// +// * instance B's thread is parked on an `awaitValue` promise that has +// already settled, so its tail sits in `store.settled`; +// * sibling instance A is entered from the host (`enterFrom(null)`), which +// under the shared synthetic per-instantiation root locks B too; +// * the only way out is an outstanding host call whose settle releases the +// lock — the async-dtor bracket's shape, registered in +// `store.pendingHostCalls` with its `.then` attached BEFORE insertion, +// mirroring `callDtorGated`. +// +// Pre-fix this either crashed (`resumeWith`'s enterability assert, reached +// through `serviceSettled`) or spun the driver hot with no await in the +// cycle. The host promise resolves from a `setTimeout(0)`, so passing +// requires the loop to genuinely park across a macrotask. + +import { assertEq } from "./support/asserts.ts"; +import { driveStoreAsync } from "../src/exec/boundary.ts"; +import { + type BlockRequest, + type Cancelled, + ComponentInstanceState, + Store, + Task, + type TaskOptions, + Thread, +} from "../src/task/mod.ts"; +import type { FuncType } from "../src/cabi/types.ts"; + +const SYNC_FT: FuncType = { params: [], results: [], async: false }; +const SYNC_OPTS: TaskOptions = { + async_: false, + callback: false, + stringEncoding: "utf8", + memory: null, +}; + +function spawn( + task: Task, + body: (t: Thread) => Generator, +): Thread { + let thread!: Thread; + thread = new Thread( + task, + (function* (): Generator { + yield* body(thread); + })(), + ); + return thread; +} + +Deno.test("driveAsync: a deferred tail parks the loop until the host entry leaves", async () => { + const store = new Store(); + const a = new ComponentInstanceState(0, store); + const b = new ComponentInstanceState(1, store); + + // B: parked on an awaitValue promise that settles immediately. + const parkPromise = Promise.resolve(undefined); + const order: string[] = []; + const bTask = new Task(SYNC_FT, SYNC_OPTS, b, () => [], (() => {}) as never); + const bThread = spawn(bTask, function* (thread) { + yield* bTask.enterImplicitThread(thread); + bTask.start(); + yield { readyFunc: null, cancellable: false, awaitValue: parkPromise }; + order.push("b tail ran"); + bTask.return_([]); + bTask.exitImplicitThread(thread); + }); + bThread.resume(); + // Let `noteAwaiting`'s eager continuation queue the tail. + await Promise.resolve(); + await Promise.resolve(); + assertEq(store.settled.length, 1, "B's tail is queued"); + + // A holds a host entry, which locks the shared root (and therefore B). + a.enterFrom(null); + assertEq(b.mayEnterFrom(null), false); + + // The outstanding host call whose settle releases the lock. `.then` is + // registered BEFORE insertion, as `callDtorGated` does, so the driver's + // race sees an entry that self-removes. + let releaseHostCall!: () => void; + const hostCall = new Promise((r) => { + releaseHostCall = r; + }); + const gated = hostCall.then(() => { + a.leaveTo(null); + store.pendingHostCalls.delete(gated); + }); + store.pendingHostCalls.add(gated); + // Demonstrably a macrotask away: the driver must park, not spin. + setTimeout(() => releaseHostCall(), 0); + + await driveStoreAsync( + store, + () => bTask.state === "resolved", + "settled-deferral test", + ); + + assertEq(order.join(","), "b tail ran"); + assertEq(bTask.state, "resolved"); + assertEq(store.settled.length, 0); + assertEq(store.awaiting.size, 0); +}); diff --git a/runtime/tests/task_test.ts b/runtime/tests/task_test.ts index 6d5df38..af2af0c 100644 --- a/runtime/tests/task_test.ts +++ b/runtime/tests/task_test.ts @@ -18,6 +18,7 @@ import { packSubtaskResult, schedulerPolicy, schedulerSeedForTesting, + notifyInstancePoisoned, Store, Subtask, SubtaskState, @@ -311,6 +312,180 @@ Deno.test("root: tick skips a sibling whose instance is locked by a host entry", assertEq(store.waiting.length, 0); }); +// --- issue #156: settled activation tails defer while the root is locked ---- +// +// `Store.settled` tails are dispatched through `Thread.resumeWith`, which +// brackets the resumption with `enterFrom(null)`. Under the shared synthetic +// root a host entry into ANY instance locks every sibling, so dispatching a +// sibling's tail in that window used to trip `resumeWith`'s enterability +// assert (and, mutating before asserting, strand the thread and lose the +// settle). The fix defers such tails IN PLACE. + +/** Settle a park promise and let `noteAwaiting`'s eager continuation run. */ +async function queueSettledTail(settle: () => void): Promise { + settle(); + // Two hops: `noteAwaiting`'s `.then` pushes onto `settled`. + await Promise.resolve(); + await Promise.resolve(); +} + +Deno.test("root: serviceSettled defers a sibling tail while a host entry holds the root", async () => { + const store = new Store(); + const a = new ComponentInstanceState(0, store); + const b = new ComponentInstanceState(1, store); + + let settle!: () => void; + const p = new Promise((r) => { + settle = r; + }); + const order: string[] = []; + const bTask = mkTask(b, SYNC_FT, SYNC_OPTS); + const bThread = spawn(bTask, function* (thread) { + yield* bTask.enterImplicitThread(thread); + bTask.start(); + const v = yield { readyFunc: null, cancellable: false, awaitValue: p }; + void v; + order.push("b tail ran"); + bTask.return_([]); + bTask.exitImplicitThread(thread); + }); + bThread.resume(); + assertEq(store.awaiting.has(bThread), true, "B is promise-parked"); + + await queueSettledTail(settle); + assertEq(store.settled.length, 1, "the tail is queued"); + + // A host entry into the sibling locks the shared root. + a.enterFrom(null); + assertEq(b.mayEnterFrom(null), false); + assertEq(store.serviceSettled(), false, "deferred: no dispatch, no throw"); + assertEq(store.settled.length, 1, "the entry stays queued, in place"); + assertEq(store.awaiting.has(bThread), true, "and the thread is not stranded"); + assertEq(order.length, 0); + assertEq(store.tick(), false, "a deferred-only queue does not gate tick open"); + + a.leaveTo(null); + assertEq(store.serviceSettled(), true, "the lock released: dispatch"); + assertEq(order.join(","), "b tail ran"); + assertEq(bTask.state, "resolved"); + assertEq(store.settled.length, 0); +}); + +Deno.test("root: the phantom-state gate holds for a serviceable tail", async () => { + // The tick gate relaxes ONLY for deferred tails: a serviceable unserviced + // tail still refuses tick, preserving the reference's atomic-resume + // discipline. + const store = new Store(); + const a = new ComponentInstanceState(0, store); + const b = new ComponentInstanceState(1, store); + + let settle!: () => void; + const p = new Promise((r) => { + settle = r; + }); + const bTask = mkTask(b, SYNC_FT, SYNC_OPTS); + const bThread = spawn(bTask, function* (thread) { + yield* bTask.enterImplicitThread(thread); + bTask.start(); + yield { readyFunc: null, cancellable: false, awaitValue: p }; + bTask.return_([]); + bTask.exitImplicitThread(thread); + }); + bThread.resume(); + + // A ready sibling thread, exactly as the #155 test constructs one. + let flag = false; + const order: string[] = []; + const aTask = mkTask(a, SYNC_FT, SYNC_OPTS); + const aThread = spawn(aTask, function* (thread) { + yield* aTask.enterImplicitThread(thread); + aTask.start(); + yield* thread.waitUntil(() => flag, false); + order.push("a ran"); + aTask.return_([]); + aTask.exitImplicitThread(thread); + }); + aThread.resume(); + flag = true; + assertEq(aThread.ready(), true); + + await queueSettledTail(settle); + assertEq(store.settled.length, 1); + assertEq(a.mayEnterFrom(null), true, "nothing is entered: the tail is serviceable"); + assertEq(store.tick(), false, "a serviceable tail gates tick"); + assertEq(order.length, 0); + + assertEq(store.serviceSettled(), true); + assertEq(store.tick(), true, "with the queue drained, tick proceeds"); + assertEq(order.join(","), "a ran"); +}); + +Deno.test("root: poisoned tails retire even while the root is locked", async () => { + // A poisoned leaf stays locked forever, so deferring its tail would leak. + // `resumeWith`'s poison early-return retires it instead: the queue drains + // and the body does NOT run. + const store = new Store(); + const a = new ComponentInstanceState(0, store); + const b = new ComponentInstanceState(1, store); + + let settle!: () => void; + const p = new Promise((r) => { + settle = r; + }); + const order: string[] = []; + const bTask = mkTask(b, SYNC_FT, SYNC_OPTS); + const bThread = spawn(bTask, function* (thread) { + yield* bTask.enterImplicitThread(thread); + bTask.start(); + yield { readyFunc: null, cancellable: false, awaitValue: p }; + order.push("b tail ran"); + bTask.return_([]); + bTask.exitImplicitThread(thread); + }); + bThread.resume(); + await queueSettledTail(settle); + assertEq(store.settled.length, 1); + + notifyInstancePoisoned(b, undefined); + a.enterFrom(null); + assertEq(store.serviceSettled(), true, "poisoned tails dispatch, locked or not"); + assertEq(store.settled.length, 0, "and the queue drains"); + assertEq(order.length, 0, "retired quietly: the body never ran"); + a.leaveTo(null); +}); + +Deno.test("root: stale settled entries are removed regardless of enterability", async () => { + // "Stale" = the thread was resumed elsewhere (driveAsync's race-winner + // path), i.e. it is gone from `store.awaiting`. Such entries are dropped + // whenever encountered, and dropping one is not progress. + const store = new Store(); + const a = new ComponentInstanceState(0, store); + const b = new ComponentInstanceState(1, store); + + let settle!: () => void; + const p = new Promise((r) => { + settle = r; + }); + const bTask = mkTask(b, SYNC_FT, SYNC_OPTS); + const bThread = spawn(bTask, function* (thread) { + yield* bTask.enterImplicitThread(thread); + bTask.start(); + yield { readyFunc: null, cancellable: false, awaitValue: p }; + bTask.return_([]); + bTask.exitImplicitThread(thread); + }); + bThread.resume(); + await queueSettledTail(settle); + assertEq(store.settled.length, 1); + + // Simulate the elsewhere-resumption. + store.awaiting.delete(bThread); + a.enterFrom(null); + assertEq(store.serviceSettled(), false, "removing a stale entry is not progress"); + assertEq(store.settled.length, 0, "but it is removed"); + a.leaveTo(null); +}); + Deno.test("root: trap poisoning stays per-instance (documented divergence)", () => { const store = new Store(); const a = new ComponentInstanceState(0, store);