@polyengine/runtime 0.3.1-pre.gf4b740b → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  // canonical options — docs/architecture.md §4.3 items 2 and 5, degenerate sync case.
5
5
  import { coreFuncTypeEquals, CoreValueIter, flattenFunctype, liftFlatValues, LiftLowerContext, lowerFlatValues, MAX_FLAT_ASYNC_PARAMS, MAX_FLAT_PARAMS, MAX_FLAT_RESULTS, trap, trapIf, } from "../cabi/mod.js";
6
6
  import { AssertionError, assert_ } from "../cabi/trap.js";
7
- import { driveSyncLift, clearResumingThread, EventCode, withActivation, hasRealHostCall, hasResumingThread, dispatchableTail, NeedsJspi, needsJspi, setResumingThread, packSubtaskResult, PendingCapability, notifyInstancePoisoned, realHostCalls, storeQuiescent, Subtask, WaitableSet, SubtaskState, Task, Thread, withPoisonCause, } from "../task/mod.js";
7
+ import { driveSyncLift, EventCode, withActivation, hasRealHostCall, dispatchableTail, NeedsJspi, needsJspi, packSubtaskResult, PendingCapability, notifyInstancePoisoned, realHostCalls, storeQuiescent, Subtask, WaitableSet, SubtaskState, Task, Thread, withPoisonCause, } from "../task/mod.js";
8
8
  import { currentTask } from "../task/scheduler.js";
9
9
  import { PlanError } from "../plan/loader.js";
10
10
  import { blockCurrentActivation, enterWasm, } from "../jspi/mod.js";
@@ -290,7 +290,7 @@ function traceDrive(loop, store, done, branch) {
290
290
  `waiting=${store.waiting.length}{${waiters}} ` +
291
291
  `awaiting=${store.awaiting.size} ` +
292
292
  `hostCalls=${store.pendingHostCalls.size} ` +
293
- `awaiters={${awaiters}} claim=${hasResumingThread()} done=${doneVerdict}`);
293
+ `awaiters={${awaiters}} pending=${store.pendingResumptions.size} done=${doneVerdict}`);
294
294
  }
295
295
  /**
296
296
  * Pump `store` until `done()` holds. Returns `undefined` if that was achieved
@@ -326,10 +326,10 @@ function drive(store, done, what) {
326
326
  }
327
327
  // A thread parked on a Promise (jspi) can only progress after a microtask
328
328
  // turn, exactly like an outstanding host call. So can an outstanding
329
- // ambient claim: a suspension has been settled and its activation has not
330
- // run yet (see `Store.tick`).
331
- if (store.awaiting.size > 0 || hasResumingThread()) {
332
- traceDrive("drive", store, done, "->async(awaiting/claim)");
329
+ // pending resumption of THIS store: a suspension has been settled and its
330
+ // activation has not run yet (see `Store.tick`).
331
+ if (store.awaiting.size > 0 || store.hasPendingResumptions()) {
332
+ traceDrive("drive", store, done, "->async(awaiting/pending)");
333
333
  return driveAsync(store, done, what);
334
334
  }
335
335
  if (store.pendingHostCalls.size === 0) {
@@ -407,9 +407,15 @@ export async function driveStoreAsync(store, done, what) {
407
407
  * racing the same parked thread await the *same* tag object and see one
408
408
  * settlement, not two independent ones. This is what makes (a)'s
409
409
  * "queued at tag settlement" premise hold across loops.
410
- * (c) The ambient resume claim (`setResumingThread`) serializes the claim
411
- * path: a second claimant while one is live is asserted against, and
412
- * every loop yields at its top while `hasResumingThread()`.
410
+ * (c) The store's pending-resumption set (`Store.pendingResumptions`)
411
+ * serializes the resumption path WITHIN a store: every loop driving
412
+ * that store yields at its top while `store.hasPendingResumptions()`,
413
+ * so a settled activation runs before anything else is scheduled.
414
+ * (Until 2026-08-22 this was a module-global single slot with a
415
+ * one-claimant assert; per-store multi-entry replaced it — issues #158
416
+ * mechanism B and #210. Overlapping loops in the sense meant here are
417
+ * loops on the SAME store, which is exactly what (c) still covers;
418
+ * loops on different stores never shared a settlement to race for.)
413
419
  *
414
420
  * (a) is the guarantee; (b) and (c) are what make (a) apply across loops
415
421
  * rather than only within one. The one corner (a) does NOT cover — a thread
@@ -610,22 +616,29 @@ async function driveAsync(store, done, what) {
610
616
  store.serviceSettled();
611
617
  if (store.hostFailure !== undefined)
612
618
  throw takeHostFailure(store);
613
- // A live claim is an engine-driven resumption in flight: its activation
614
- // has not yet parked again or finished. It will die on its own — parking
615
- // consumes it (`blockCurrentActivation`), finishing releases it
616
- // (`Store.noteAwaiting`'s settle continuation) — so yield microtasks
617
- // until it does. The driver must NOT blanket-clear here: the claim may
618
- // have been taken by a guest built-in settling another activation's
619
- // suspension (`subtask.cancel` delivering a cancellation), and clearing
620
- // it before that activation runs re-opens the mis-attribution window the
621
- // claim exists to close.
622
- if (hasResumingThread()) {
623
- traceDrive("driveAsync", store, done, "yield-claim");
624
- // Bounded: a claim that never dies is an internal bug (every path out
625
- // of a resumed activation releases it park, finish, trap), and a
626
- // pure-microtask wait would otherwise starve the event loop and every
627
- // stall timer with it. Interleave macrotask hops so timers stay alive,
628
- // and fail loudly rather than spin forever.
619
+ // A pending resumption of THIS store is an engine-driven resumption in
620
+ // flight: its activation has not yet parked again or finished. It will
621
+ // die on its own — parking consumes it (`blockCurrentActivation`),
622
+ // finishing releases it (`Store.noteAwaiting`'s settle continuation) — so
623
+ // yield microtasks until it does. The driver must NOT blanket-clear here:
624
+ // an entry may have been taken by a guest built-in settling another
625
+ // activation's suspension (`subtask.cancel` delivering a cancellation),
626
+ // and clearing it before that activation runs re-opens the
627
+ // mis-attribution window the entry exists to close.
628
+ //
629
+ // PER-STORE (issue #210): this gate used to read a module-global slot, so
630
+ // an idle store's driver spun here and died at the hop bound below in
631
+ // ~311ms merely because ANOTHER store's guest was dwelling on a slow
632
+ // host import. Activations never cross stores; another store's pending
633
+ // resumption is none of this loop's business.
634
+ if (store.hasPendingResumptions()) {
635
+ traceDrive("driveAsync", store, done, "yield-pending");
636
+ // Bounded: a pending entry that never dies is an internal bug (every
637
+ // path out of a resumed activation releases it — park, finish, trap),
638
+ // and a pure-microtask wait would otherwise starve the event loop and
639
+ // every stall timer with it. Interleave macrotask hops so timers stay
640
+ // alive, and fail loudly rather than spin forever. Scoped per store,
641
+ // this is again the internal-bug detector it was meant to be.
629
642
  claimHops++;
630
643
  assert_(claimHops < 10_000, "driveAsync: a resumed-activation claim was never released " +
631
644
  "(the activation neither parked, finished, nor trapped)");
@@ -663,7 +676,7 @@ async function driveAsync(store, done, what) {
663
676
  // Only a SERVICEABLE tail is a reason to loop again: a queue holding
664
677
  // only tails DEFERRED on a non-enterable instance (issue #156) would
665
678
  // spin this loop hot — nothing in the cycle awaits.
666
- if (store.hasServiceableSettled() || hasResumingThread()) {
679
+ if (store.hasServiceableSettled() || store.hasPendingResumptions()) {
667
680
  continue;
668
681
  }
669
682
  // Service promise-parked threads (jspi).
@@ -700,7 +713,7 @@ async function driveAsync(store, done, what) {
700
713
  // without this check it presents as a silent stall instead -- which is
701
714
  // exactly what `tests/jspi/deadlock_test.ts` caught the moment site 2
702
715
  // was lit.
703
- if (store.pendingHostCalls.size === 0 && !hasResumingThread()) {
716
+ if (store.pendingHostCalls.size === 0 && !store.hasPendingResumptions()) {
704
717
  traceDrive("driveAsync", store, done, "deadlock-probe");
705
718
  // Exclude threads whose settle is already QUEUED in `store.settled`
706
719
  // (issue #156): their promise has settled, so racing them wins
@@ -757,7 +770,7 @@ async function driveAsync(store, done, what) {
757
770
  // await always has a `pendingHostCalls` entry, which fails this
758
771
  // probe's precondition); keeping it loud is what makes it an
759
772
  // internal-wedge detector rather than dead code.
760
- if (store.pendingHostCalls.size > 0 || hasResumingThread() ||
773
+ if (store.pendingHostCalls.size > 0 || store.hasPendingResumptions() ||
761
774
  store.hasServiceableSettled()) {
762
775
  continue;
763
776
  }
@@ -838,25 +851,31 @@ async function driveAsync(store, done, what) {
838
851
  for (const h of store.pendingHostCalls) {
839
852
  others.push(h.then(() => null, () => null));
840
853
  }
841
- // A SPECULATIVE claim: the chosen thread is a promising-wrapped
854
+ // A SPECULATIVE entry: the chosen thread is a promising-wrapped
842
855
  // activation, and the engine may run its wasm during this await (pin
843
- // (i)). It is released unconditionally on the way out — if the
844
- // activation is genuinely mid-resumption its own exact claim (minted by
845
- // `SuspensionPoint.resume`) is what carries it, and releasing a claim
846
- // that names a thread already gone from the queue is a no-op.
847
- setResumingThread(chosen);
856
+ // (i)). It is dropped on the way out — if the activation is genuinely
857
+ // mid-resumption its own exact entry (minted by
858
+ // `SuspensionPoint.resume`) is what carries it, and dropping an entry
859
+ // that names a thread already gone from the set is a no-op.
860
+ //
861
+ // ONLY ITS OWN ENTRY (issue #158): the `finally` used to blanket-clear
862
+ // the single global slot, so a guest-synchronous delivery during the
863
+ // await — which takes a fresh entry of its own — had that entry
864
+ // clobbered early, re-opening the window it exists to close. With a set
865
+ // we can name exactly what we added.
866
+ store.addPendingResumption(chosen);
848
867
  let winner;
849
868
  try {
850
869
  winner = await Promise.race([chosenTag, ...others]);
851
870
  }
852
871
  finally {
853
- clearResumingThread();
872
+ store.removePendingResumption(chosen);
854
873
  }
855
874
  // Resume whichever thread actually settled -- not necessarily the one we
856
875
  // claimed. Resuming only the claimed thread would spin: its promise may
857
876
  // never settle, the same thread would be chosen again next turn, and the
858
877
  // already-settled tags would win the race instantly forever (observed as
859
- // an OOM, not a hang). The claim is cleared above before any resumption,
878
+ // an OOM, not a hang). Our own entry is dropped above before any resumption,
860
879
  // exactly as on the original single-promise path, so this does not widen
861
880
  // the ambient window; it only ensures the loop always makes progress.
862
881
  // Membership is not enough: the corner it misses is a thread the OTHER
@@ -82,8 +82,26 @@ class Executor {
82
82
  * another.
83
83
  */
84
84
  store = new Store();
85
- /** Memoized `unsafe-intrinsic` core functions, by symbol. */
85
+ /**
86
+ * Memoized `unsafe-intrinsic` core functions, by DECLARING COMPONENT
87
+ * INSTANCE and symbol.
88
+ *
89
+ * The instance is part of the key because `context.{get,set}` resolve their
90
+ * thread against it (`currentThreadForInstance`, task/scheduler.ts): the
91
+ * declaring instance is the one whose core frame is executing when the
92
+ * intrinsic is called, which is what keeps a JSPI continuation chunk's
93
+ * `context.set` out of a sibling task's slots. `null` keys the shared
94
+ * adapter/instance-less flavour (the plan records `instance: null` for FACT
95
+ * adapter modules).
96
+ */
86
97
  unsafeIntrinsics = new Map();
98
+ /**
99
+ * The component instance of the core module currently being instantiated
100
+ * (`instantiate-module`'s `instance` field; null for a FACT adapter). Read
101
+ * by `unsafeIntrinsic` while its import list is being resolved — the one
102
+ * place a core instance's owning component instance is stated by the plan.
103
+ */
104
+ #declaringInstance = null;
87
105
  /** The single in-flight FACT `prepare-call` state (intrinsics/fact_calls.ts). */
88
106
  preparedCall = { current: null };
89
107
  /**
@@ -353,6 +371,13 @@ class Executor {
353
371
  // suspension point; every function it exports is therefore
354
372
  // potentially-blocking, and everything else is not.
355
373
  this.sawBlockingImport = false;
374
+ // Which component instance this core module belongs to — the plan
375
+ // states it here and nowhere else (`instance: null` = FACT adapter,
376
+ // contracts/plan-format.md). `unsafeIntrinsic` reads it while the
377
+ // import list below is resolved.
378
+ this.#declaringInstance = init.instance === null
379
+ ? null
380
+ : this.componentInstance(init.instance);
356
381
  // ISSUE #88: core wasm permits two imports with the same
357
382
  // (module, field) pair (trusted wasmtime-environ 47.0.3 info.rs
358
383
  // :438-445 gives one flat positional CoreDef per import slot, but
@@ -390,6 +415,10 @@ class Executor {
390
415
  {})[imp.name] =
391
416
  value;
392
417
  });
418
+ // Scoped strictly to the import list above: a CoreDef resolved by
419
+ // any other initializer (extract-*, resource dtors) names no core
420
+ // module, so it must not inherit this one's instance.
421
+ this.#declaringInstance = null;
393
422
  let instance;
394
423
  try {
395
424
  instance = await WebAssembly.instantiate(module, importObject);
@@ -670,10 +699,12 @@ class Executor {
670
699
  return m;
671
700
  }
672
701
  unsafeIntrinsic(symbol) {
673
- let fn = this.unsafeIntrinsics.get(symbol);
702
+ const inst = this.#declaringInstance;
703
+ const key = `${inst === null ? "-" : inst.index}\0${symbol}`;
704
+ let fn = this.unsafeIntrinsics.get(key);
674
705
  if (fn === undefined) {
675
- fn = createUnsafeIntrinsic(symbol);
676
- this.unsafeIntrinsics.set(symbol, fn);
706
+ fn = createUnsafeIntrinsic(symbol, inst);
707
+ this.unsafeIntrinsics.set(key, fn);
677
708
  }
678
709
  return fn;
679
710
  }
@@ -10,7 +10,7 @@
10
10
  // memory access for wasmtime's own internals; they have no portable meaning
11
11
  // in a JS host and are refused at instantiate time.
12
12
  import { assert_, trapIf } from "../cabi/trap.js";
13
- import { currentThread } from "../task/mod.js";
13
+ import { currentThreadForInstance } from "../task/mod.js";
14
14
  import { ambientDebug, dbgId } from "../task/scheduler.js";
15
15
  import { UnsupportedFeatureError } from "./errors.js";
16
16
  /**
@@ -28,8 +28,8 @@ export const NUM_CONTEXT_SLOTS = 2;
28
28
  * in slot 0, which is why this intrinsic is the entry blocker for async
29
29
  * guests.
30
30
  */
31
- export function canonContextGet(i) {
32
- const thread = currentThread();
31
+ export function canonContextGet(i, inst) {
32
+ const thread = currentThreadForInstance(inst);
33
33
  assert_(i < NUM_CONTEXT_SLOTS, `context.get slot ${i} out of range`);
34
34
  const result = thread.storage[i];
35
35
  assert_(result < 2 ** 32, "context.get value out of i32 range");
@@ -53,11 +53,11 @@ export function ctxThreadId(t) {
53
53
  }
54
54
  function trace(msg, thread) {
55
55
  const a = ambientDebug();
56
- console.error(`[ctx] ${ctxThreadId(thread)} ${msg} storage=${JSON.stringify(thread.storage)} | stack=[${a.stack.map(ctxThreadId).join(",")}] claims=[${a.claims.map(ctxThreadId).join(",")}] resuming=${a.resuming === null ? "-" : ctxThreadId(a.resuming)}`);
56
+ console.error(`[ctx] ${ctxThreadId(thread)} ${msg} storage=${JSON.stringify(thread.storage)} | stack=[${a.stack.map(ctxThreadId).join(",")}] claims=[${a.claims.map(ctxThreadId).join(",")}]`);
57
57
  }
58
58
  /** definitions.py `canon_context_set` (line 2358). */
59
- export function canonContextSet(i, v) {
60
- const thread = currentThread();
59
+ export function canonContextSet(i, v, inst) {
60
+ const thread = currentThreadForInstance(inst);
61
61
  assert_(i < NUM_CONTEXT_SLOTS, `context.set slot ${i} out of range`);
62
62
  if (CTX_TRACE)
63
63
  trace(`set[${i}] = ${v >>> 0}`, thread);
@@ -70,7 +70,17 @@ export function canonContextSet(i, v) {
70
70
  * unimplementable symbol fails instantiation rather than the first call
71
71
  * (contracts/plan-format.md "Executor obligations").
72
72
  */
73
- export function createUnsafeIntrinsic(symbol) {
73
+ export function createUnsafeIntrinsic(symbol,
74
+ /**
75
+ * The component instance whose core module declares this import — the
76
+ * instance whose frame is, by construction, the one executing when it is
77
+ * called. `undefined`/`null` (a FACT adapter module, which the plan records
78
+ * with `instance: null`) falls back to the unscoped ambient. See
79
+ * `currentThreadForInstance` (task/scheduler.ts) for why this discriminator
80
+ * is what makes a JSPI continuation chunk's `context.set` land in its own
81
+ * thread's slots.
82
+ */
83
+ inst) {
74
84
  const match = /^context-(get|set)-i32-(\d+)$/.exec(symbol);
75
85
  if (match === null) {
76
86
  throw new UnsupportedFeatureError("M2", `component imports the unsafe intrinsic '${symbol}', which has no ` +
@@ -83,8 +93,8 @@ export function createUnsafeIntrinsic(symbol) {
83
93
  trapIf(slot >= NUM_CONTEXT_SLOTS, `unsafe intrinsic '${symbol}' addresses context slot ${slot}, but a ` +
84
94
  `thread has ${NUM_CONTEXT_SLOTS}`);
85
95
  if (match[1] === "get")
86
- return () => canonContextGet(slot);
96
+ return () => canonContextGet(slot, inst);
87
97
  return (v) => {
88
- canonContextSet(slot, (v ?? 0) >>> 0);
98
+ canonContextSet(slot, (v ?? 0) >>> 0, inst);
89
99
  };
90
100
  }
@@ -42,7 +42,7 @@
42
42
  // results to become Promises where cabi needs a number synchronously.
43
43
  import { assert_ } from "../cabi/trap.js";
44
44
  import { isSupported, makePromising, makeSuspending } from "./mechanics.js";
45
- import { withActivation, claimActivationAmbient, dbgId, consumeClaimIfRunning, maybeCurrentThread, releaseActivationAmbient, setResumingThread, } from "../task/mod.js";
45
+ import { withActivation, claimActivationAmbient, dbgId, maybeCurrentThread, releaseActivationAmbient, } from "../task/mod.js";
46
46
  /**
47
47
  * Decide the mode for one instantiation.
48
48
  *
@@ -474,17 +474,19 @@ export class SuspensionPoint {
474
474
  // and the `assert_trap` rows of `big-interleaving-test.wast` detect
475
475
  // ("exit-sync-call with an empty sync-call stack").
476
476
  //
477
- // Symmetry with the success arm below (issue #158): if the DRIVER's slot
478
- // is live for the activation currently executing, that activation is the
479
- // code delivering this resume (a running guest's `subtask.cancel`, whose
480
- // `produce` computes a trap), so its claim window has closed — consume it
481
- // rather than false-positive the one-claimant assert in
482
- // `setResumingThread`. Without this the assert preempts `#fail(e)` and
483
- // the parked guest receives an AssertionError in place of its trap.
484
- consumeClaimIfRunning();
477
+ // Symmetry with the success arm below (issue #158): if a pending
478
+ // resumption of this store names the activation currently executing,
479
+ // that activation is the code delivering this resume (a running guest's
480
+ // `subtask.cancel`, whose `produce` computes a trap), so its window has
481
+ // closed retire that entry here. Historically this also avoided a
482
+ // false-positive one-claimant assert that preempted `#fail(e)`, handing
483
+ // the parked guest an AssertionError in place of its trap; the assert is
484
+ // gone with the single-slot claim (#158 mechanism B), but the entry must
485
+ // still be retired here or the store stays gated on a finished window.
486
+ this.#store.consumePendingIfRunning();
485
487
  if (maybeCurrentThread() === undefined)
486
488
  claimActivationAmbient(this.owner);
487
- setResumingThread(this.task?.implicitThread ?? null);
489
+ this.#store.addPendingResumption(this.task?.implicitThread ?? null);
488
490
  this.#fail(e);
489
491
  return;
490
492
  }
@@ -492,25 +494,25 @@ export class SuspensionPoint {
492
494
  // settling the import's Promise hands control to wasm, which will call
493
495
  // built-ins with an empty bracket stack. The claim names `owner` — the
494
496
  // activation captured when this point was minted — not a guess derived
495
- // now; see `owner` and `setResumingThread`.
497
+ // now; see `owner` and `Store.pendingResumptions`.
496
498
  //
497
- // If the DRIVER's slot is live for the activation currently executing (it
498
- // is the code that called us — a running guest's `subtask.cancel`
499
- // delivering a cancellation settles the callee's suspension from inside
500
- // its own frame), that claim has served its purpose; consume it rather
501
- // than false-positive the one-claimant assert.
502
- consumeClaimIfRunning();
499
+ // If a pending resumption of this store names the activation currently
500
+ // executing (it is the code that called us — a running guest's
501
+ // `subtask.cancel` delivering a cancellation settles the callee's
502
+ // suspension from inside its own frame), that entry has served its
503
+ // purpose; retire it.
504
+ this.#store.consumePendingIfRunning();
503
505
  // The activation-ambient claim (site (i) in scheduler.ts). Taken only when
504
506
  // NOBODY is running right now: if a guest activation is executing, `owner`
505
507
  // does not run until that activation yields, and pushing onto a
506
508
  // LAST-IN-FIRST-OUT stack now would make `owner` the ambient for the
507
- // caller's remaining frame. In that shape `owner` is picked up either by
508
- // its own first `Suspending` call (site (ii)) or, before that, by the
509
- // driver's `resumingThread` slot at the bottom tier exactly as it always
510
- // was.
509
+ // caller's remaining frame. In that shape `owner` is picked up by its own
510
+ // first `Suspending` call (site (ii)); the retired tier-3 slot used to
511
+ // cover the window before that, and the measurement behind its retirement
512
+ // (#158, see `resolveAmbient`) says nothing ever read it there.
511
513
  if (maybeCurrentThread() === undefined)
512
514
  claimActivationAmbient(this.owner);
513
- setResumingThread(this.task?.implicitThread ?? null);
515
+ this.#store.addPendingResumption(this.task?.implicitThread ?? null);
514
516
  this.#settle(value);
515
517
  }
516
518
  /** Abandon this suspension without resuming the guest (teardown paths). */
@@ -577,11 +579,11 @@ export function blockCurrentActivation(input) {
577
579
  // and strands the point with no owner (measured: `cancellable.wast:322`
578
580
  // then reported `pending-capability: instantiation-time task context`).
579
581
  const owner = maybeCurrentThread() ?? input.task?.implicitThread ?? null;
580
- // The activation is parking: if it still carried the resumed-ambient claim
581
- // from the settle that resumed it, that claim's window closes here (the
582
- // other closing edge — the activation FINISHING — is handled by
582
+ // The activation is parking: if it still carried the pending-resumption
583
+ // entry from the settle that resumed it, that entry's window closes here
584
+ // (the other closing edge — the activation FINISHING — is handled by
583
585
  // `Store.noteAwaiting`'s settle continuation).
584
- consumeClaimIfRunning();
586
+ input.store.consumePendingIfRunning();
585
587
  releaseActivationAmbient(owner);
586
588
  const point = new SuspensionPoint(input.store, input.task, input.readyFunc, input.cancellable, input.produce, owner, input.onSettled);
587
589
  return point.promise;
@@ -269,7 +269,7 @@ export function withActivation(t, fn) {
269
269
  * async-context store held: the store was written by `withActivation` and by
270
270
  * nothing else, so a built-in reached under a scheduler `resume()` bracket
271
271
  * that had not (yet) entered wasm saw NO store, even though `threadStack`
272
- * named a thread. `consumeClaimIfRunning` — the driver-gate
272
+ * named a thread. `Store.consumePendingIfRunning` — the driver-gate
273
273
  * release whose scheduling effects the corpus pins precisely — asked exactly
274
274
  * that question, so it must keep asking exactly that question — measured:
275
275
  * routing it through the full `threadStack` instead moved 64 conformance
@@ -280,7 +280,8 @@ export function withActivation(t, fn) {
280
280
  const entryStack = [];
281
281
  /**
282
282
  * "Whose wasm frame are we lexically inside, or running on behalf of?" — the
283
- * async-context store's replacement, used only by `consumeClaimIfRunning`.
283
+ * async-context store's replacement, used only by
284
+ * `Store.consumePendingIfRunning`.
284
285
  */
285
286
  // deno-lint-ignore no-explicit-any
286
287
  function activationOf() {
@@ -346,8 +347,9 @@ function activationOf() {
346
347
  * The opposite shape — A settles B's suspension so B runs AFTER A — is
347
348
  * deliberately NOT represented here: `SuspensionPoint.resume` pushes only when
348
349
  * nothing is currently running, so B never shadows A. B is picked up by its
349
- * own first `Suspending` call, or before that by the driver's `resumingThread`
350
- * slot at the bottom tier.
350
+ * own first `Suspending` call. (Until 2026-08-22 a third ambient tier — the
351
+ * driver's `resumingThread` slot also named B here; it was retired with the
352
+ * slot, see `resolveAmbient` and `Store.pendingResumptions`.)
351
353
  *
352
354
  * An activation leaves this stack when it parks again
353
355
  * (`blockCurrentActivation`) or finishes (its `awaitValue` promise settles —
@@ -364,7 +366,7 @@ const activationClaims = [];
364
366
  * direct evidence that `t` is running RIGHT NOW (its Suspending import just
365
367
  * returned into its wasm). The previous early-return kept stale order: a
366
368
  * nested callee's claim whose release edge is a promise reaction
367
- * (`Store.noteAwaiting` -> `releaseClaimOf`) outlives the callee by a
369
+ * (`Store.noteAwaiting` -> `Store.releasePendingOf`) outlives the callee by a
368
370
  * microtask, and an outer activation's continuation chunk that resumed in
369
371
  * that window re-claimed itself as a NOOP — leaving the finished callee on
370
372
  * top, so every ambient read in the rest of the chunk (the next hop's
@@ -395,7 +397,8 @@ export function claimActivationAmbient(t) {
395
397
  function traceAmbient(what, t) {
396
398
  // Lazy import avoidance: reuse context.ts's ids via a local map.
397
399
  console.error(`[amb] ${what} ${dbgId(t)} | stack=[${threadStack.map(dbgId).join(",")}] ` +
398
- `claims=[${activationClaims.map(dbgId).join(",")}] resuming=${resumingThread === null ? "-" : dbgId(resumingThread)}\n${(new Error().stack ?? "").split("\n").slice(2, 6).join("\n")}`);
400
+ `claims=[${activationClaims.map(dbgId).join(",")}]` +
401
+ `\n${(new Error().stack ?? "").split("\n").slice(2, 6).join("\n")}`);
399
402
  }
400
403
  const dbgIds = new WeakMap();
401
404
  let nextDbgId = 1;
@@ -437,78 +440,17 @@ export function releaseActivationAmbient(t) {
437
440
  activationClaims.splice(i, 1);
438
441
  }
439
442
  // ---------------------------------------------------------------------------
440
- // The driver's resume claim (a SEPARATE concern from the ambient above)
443
+ // The resumed-but-not-yet-run gate (a SEPARATE concern from the ambient above)
441
444
  // ---------------------------------------------------------------------------
442
- /**
443
- * The activation whose suspension we have just resolved and which has not run
444
- * yet the DRIVER's serialization gate, not an ambient.
445
- *
446
- * Keeping this distinct from `activationClaims` matters. This slot answers
447
- * "may I schedule something else right now?" (`Store.tick` and both driving
448
- * loops refuse while it is live, which is what forces a microtask yield so the
449
- * resumed activation actually runs). `activationClaims` answers "whose code is
450
- * this?". Conflating them — driving off the ambient queue — wedges the loops,
451
- * because an activation that merely hopped (case (ii) above) legitimately
452
- * holds an ambient while the scheduler is free to proceed.
453
- */
454
- // deno-lint-ignore no-explicit-any
455
- let resumingThread = null;
456
- /** Claim the ambient for `t` across an engine-driven resumption. */
457
- // deno-lint-ignore no-explicit-any
458
- export function setResumingThread(t) {
459
- if (AMBIENT_TRACE)
460
- traceAmbient("set-resuming", t);
461
- assert_(resumingThread === null || resumingThread === t, "two activations claim the resumed ambient at once — the " +
462
- "resolve-one-per-turn discipline was violated");
463
- resumingThread = t;
464
- }
465
- /** Is a settled-but-not-yet-run activation holding the ambient? */
466
- export function hasResumingThread() {
467
- return resumingThread !== null;
468
- }
469
- /** Release the claim; called once we are back in our own continuation. */
470
- export function clearResumingThread() {
471
- resumingThread = null;
472
- }
473
- /**
474
- * Release the driver's claim iff its activation is demonstrably RUNNING —
475
- * i.e. the claim names the same thread the ACTIVATION AMBIENT names for the
476
- * code calling us. The claim exists to cover the window between settling a
477
- * suspension and the resumed activation running; once that activation's own
478
- * code is on the stack the window is closed, and holding the claim would
479
- * falsely trip the one-claimant assert when the running activation's built-in
480
- * settles ANOTHER activation's suspension — `subtask.cancel` delivering a
481
- * cancellation to a parked callee (cancellable.wast) is exactly that shape.
482
- * When the two disagree (or no ambient is present) the claim stays, and the
483
- * assert keeps guarding the genuine two-unrun-claimants bug it was built for.
484
- *
485
- * The comparison used to be against the async-context store; it is now
486
- * against `activationOf()`, which is the same statement made explicitly.
487
- */
488
- export function consumeClaimIfRunning() {
489
- if (resumingThread !== null && activationOf() === resumingThread) {
490
- resumingThread = null;
491
- }
492
- }
493
- /**
494
- * Release the claim iff it names `t` — the settle-side half of the claim
495
- * discipline: a claim taken when `t`'s suspension was settled dies when `t`'s
496
- * activation finishes (its `awaitValue` promise settles; `Store.noteAwaiting`
497
- * calls this from the eager settle continuation) or parks again
498
- * (`blockCurrentActivation` consumes via `consumeClaimIfRunning`).
499
- *
500
- * `t` FINISHING also ends its activation ambient, so both are dropped here.
501
- */
502
- // deno-lint-ignore no-explicit-any
503
- export function releaseClaimOf(t) {
504
- releaseActivationAmbient(t);
505
- if (resumingThread !== null &&
506
- (resumingThread === t ||
507
- t?.task?.implicitThread ===
508
- resumingThread)) {
509
- resumingThread = null;
510
- }
511
- }
445
+ //
446
+ // This used to be a module-global single slot, `resumingThread`, doing two
447
+ // jobs: (1) the DRIVER's scheduling gate ("a suspension was settled and its
448
+ // activation has not run yet — do not schedule anything else"), and (2) tier 3
449
+ // of ambient resolution. Job (2) was retired on 2026-08-22 (issue #158) after
450
+ // measurement showed it never decided a read; job (1) is real, but it is
451
+ // per-Store SET semantics, not a global identity slot see
452
+ // `Store.pendingResumptions` below, and `resolveAmbient` for the retirement
453
+ // evidence.
512
454
  const AMBIENT_TRACE = (() => {
513
455
  try {
514
456
  return Deno.env.get("CE_AMBIENT_TRACE") === "1";
@@ -522,14 +464,17 @@ export function ambientDebug() {
522
464
  return {
523
465
  stack: [...threadStack],
524
466
  claims: [...activationClaims],
525
- resuming: resumingThread,
526
467
  };
527
468
  }
528
- /** Diagnostic: module-scope state that must NOT survive a completed call. */
469
+ /**
470
+ * Diagnostic: module-scope AMBIENT state that must NOT survive a completed
471
+ * call. The scheduling gate is no longer module-scope — a store's
472
+ * `pendingResumptions` set is the per-Store analogue and is checked there.
473
+ */
529
474
  export function ambientResidue() {
530
475
  return {
531
476
  stack: threadStack.length,
532
- claim: resumingThread !== null || activationClaims.length > 0,
477
+ claim: activationClaims.length > 0,
533
478
  };
534
479
  }
535
480
  /**
@@ -543,9 +488,9 @@ export function ambientResidue() {
543
488
  * is running outside our frames (a `Suspending` hop or a resumption).
544
489
  * LIFO, because activations nest: an outer activation's built-in can
545
490
  * synchronously enter an inner one's wasm.
546
- * 3. `resumingThread` -- the driver's claim. Last resort: it names whichever
547
- * activation the driver settled or claimed across an await, which is
548
- * right for that one and wrong for every other in-flight activation.
491
+ * (There is no tier 3. A third tier — `resumingThread`, the driver's
492
+ * settle-time claim existed from M3A-1 until 2026-08-22 and was
493
+ * RETIRED, see below.)
549
494
  *
550
495
  * Tier 2 replaced an async-context store (M3A-1). The store held
551
496
  * precisely "the innermost wasm activation currently executing, across the
@@ -565,18 +510,36 @@ export function ambientResidue() {
565
510
  * one reader measured as "no change" because the failing sites used the other.
566
511
  * Do not add a third reader; extend this one. (`activationOf` above is not a
567
512
  * second reader -- it answers a different question, "whose wasm frame are we
568
- * running on behalf of", and is used only by `consumeClaimIfRunning`.)
513
+ * running on behalf of", and is used only by
514
+ * `Store.consumePendingIfRunning`.)
515
+ *
516
+ * TIER 3 RETIRED, 2026-08-22 (issue #158). The bottom tier used to be
517
+ * `resumingThread`, the driver's settle-time claim -- a last resort that named
518
+ * whichever activation was settled or claimed across an await, right for that
519
+ * one and wrong for every other in-flight activation. It was removed on the
520
+ * strength of a re-run of the M3A-1 differential methodology: an instrumented
521
+ * build counted every read where tiers 1-2 were empty and the slot was live,
522
+ * and measured ZERO deciding reads across the conformance corpus (FIFO,
523
+ * 1257/0), both seeded shuffles (`POLYENGINE_SCHED_SEED` 1 and 4242),
524
+ * test-runtime (all jspi pins), and the smoke-tls three-async-component #24
525
+ * corpus. A removal build then ran green on every engine lane we have:
526
+ * test-runtime, test-protocol, conformance (1257/0, no expectation changes),
527
+ * sched-seeds, the shells (sm + node + jsc + bun, all "OK, matches
528
+ * expectation"), the browsers (chromium + firefox), smoke-tls and smoke-c0.
529
+ * The reading: post-#24 the sentinel discipline (tier 2's claim/release edges)
530
+ * always answers first, so the slot's attribution role was vestigial. Its
531
+ * other, live role -- the scheduling gate -- survives as the per-Store
532
+ * `Store.pendingResumptions` set.
569
533
  */
570
534
  function resolveAmbient() {
571
535
  return threadStack[threadStack.length - 1] ??
572
- activationClaims[activationClaims.length - 1] ?? resumingThread ??
536
+ activationClaims[activationClaims.length - 1] ??
573
537
  undefined;
574
538
  }
575
539
  export function currentThread() {
576
540
  if (AMBIENT_TRACE && threadStack.length === 0) {
577
541
  console.error(`[ambient] bracket empty; claims=${activationClaims.length} ` +
578
- `head=${activationClaims[0]?.constructor?.name ?? "none"} ` +
579
- `resuming=${resumingThread?.constructor?.name ?? "none"}`);
542
+ `head=${activationClaims[0]?.constructor?.name ?? "none"}`);
580
543
  }
581
544
  const t = resolveAmbient();
582
545
  if (t === undefined) {
@@ -604,6 +567,76 @@ export function currentThread() {
604
567
  export function maybeCurrentThread() {
605
568
  return resolveAmbient();
606
569
  }
570
+ /**
571
+ * THE ambient, NARROWED BY THE INSTANCE WHOSE CORE FRAME IS EXECUTING.
572
+ *
573
+ * For a built-in whose declaration names a component instance, "who is
574
+ * running" is not an open question about the whole store: the call arrived
575
+ * from a core frame OF THAT INSTANCE, so the running activation is one of
576
+ * that instance's. This narrows `resolveAmbient` accordingly — same tiers,
577
+ * same order, candidates filtered — and falls back to the unscoped answer
578
+ * when the instance has no candidate at all (the instantiation-time shape,
579
+ * and any built-in reached before its instance has a task).
580
+ *
581
+ * WHY IT IS NEEDED (polyengine#24's residue; polyvisor#49 trap 1,
582
+ * `runtime/tests/context_attribution_test.ts`). A JSPI continuation chunk —
583
+ * the tail of a suspended activation, e.g. wit-bindgen's callback epilogue
584
+ * restoring its task pointer with `context.set` (rt/async_support.rs:592) —
585
+ * runs with an EMPTY `threadStack` and, unlike a hop, has no re-anchoring
586
+ * edge of its own. Tier 2 then answers the newest claim, which is whichever
587
+ * SIBLING activation suspended most recently. The attribution sentinels
588
+ * (jspi/bridge.ts) plant that claim one microtask ahead of the chunk, which
589
+ * is exact when the engine queues the resumption while the settle reaction
590
+ * returns (measured so in Deno's V8) — and NOT exact in Chromium, where a
591
+ * wider gap lets a sibling's sentinel land in between. Measured there 3/3:
592
+ * one task's epilogue wrote its state pointer into another task's slots, and
593
+ * the starved task's next callback entry hit `assert!(!state.is_null())`
594
+ * (async_support.rs:578) -> unreachable.
595
+ *
596
+ * Ordering discipline cannot fix that class — engine chunk boundaries are not
597
+ * observable, so every microtask-ordering scheme is a hope. Instance identity
598
+ * is not a hope: it is static (the declaration), and it is decisive because
599
+ * ONE INSTANCE CAN ONLY HAVE ONE ACTIVATION MID-FRAME AT A TIME — a callback
600
+ * invocation holds `inst.exclusiveThread` for its whole extent, suspensions
601
+ * included (definitions.py line 2187 / `runCallbackLoop`), and a sync or
602
+ * stackful-async lift holds the entry gate. Two activations that can race for
603
+ * an unbracketed read are therefore necessarily of different instances, which
604
+ * is exactly what this discriminates.
605
+ *
606
+ * SPEC BASIS. `canon_context_get`/`canon_context_set` (definitions.py 2348 /
607
+ * 2358) read `current_thread().storage`, and in the reference a built-in is
608
+ * only ever reached from inside the activation that called it — the identity
609
+ * is exact by construction, never inferred. This runtime has to reconstruct
610
+ * it; narrowing the reconstruction to the declaring instance moves it TOWARD
611
+ * the reference (it can only ever remove candidates the reference would never
612
+ * have named), never away.
613
+ */
614
+ // deno-lint-ignore no-explicit-any
615
+ export function currentThreadForInstance(inst) {
616
+ const t = resolveAmbientForInstance(inst);
617
+ if (t !== undefined)
618
+ return t;
619
+ // No candidate of this instance: the unscoped ladder, including its
620
+ // `PendingCapability` for the instantiation-time shape.
621
+ return currentThread();
622
+ }
623
+ function resolveAmbientForInstance(inst) {
624
+ if (inst === null || inst === undefined)
625
+ return resolveAmbient();
626
+ const top = threadStack[threadStack.length - 1];
627
+ if (top !== undefined && instOf(top) === inst)
628
+ return top;
629
+ for (let i = activationClaims.length - 1; i >= 0; i--) {
630
+ const c = activationClaims[i];
631
+ if (instOf(c) === inst)
632
+ return c;
633
+ }
634
+ return undefined;
635
+ }
636
+ // deno-lint-ignore no-explicit-any
637
+ function instOf(t) {
638
+ return t?.task?.inst;
639
+ }
607
640
  /** definitions.py `current_task()` (line 309). */
608
641
  // deno-lint-ignore no-explicit-any
609
642
  export function currentTask() {
@@ -648,6 +681,109 @@ export class Store {
648
681
  * is driving the store — which is the call the guest is blocked in.
649
682
  */
650
683
  hostFailure = undefined;
684
+ /**
685
+ * Resumed-but-not-yet-run activations of THIS store — the driver's
686
+ * scheduling gate, not an ambient.
687
+ *
688
+ * Keeping this distinct from `activationClaims` matters. This set answers
689
+ * "may I schedule something else right now?" (`Store.tick` and both driving
690
+ * loops refuse while it is non-empty, which is what forces a microtask yield
691
+ * so the resumed activation actually runs). `activationClaims` answers
692
+ * "whose code is this?". Conflating them — driving off the ambient queue —
693
+ * wedges the loops, because an activation that merely hopped legitimately
694
+ * holds an ambient while the scheduler is free to proceed.
695
+ *
696
+ * PER-STORE and MULTI-ENTRY since 2026-08-22 (issues #158 mechanism B,
697
+ * #210). It was one module-global slot with a one-claimant assert, which
698
+ * (a) could not represent two legitimately-pending engine resumptions — a
699
+ * running activation X delivering a resume to Z while Y's resumption was
700
+ * still pending crashed on the assert — and (b) made every driver on every
701
+ * store yield while ANY store held a claim, so an idle store's
702
+ * `driveStoreAsync` died at the 10,000-hop assert (~311ms) while another
703
+ * store merely dwelt on a slow host import. The assert's invariant was
704
+ * tier-3 attribution unambiguity, which no longer exists (see
705
+ * `resolveAmbient`), so it is gone with the slot; the entries and their
706
+ * release edges are otherwise unchanged, per entry.
707
+ *
708
+ * Cross-store de-serialization is safe by disjointness: an activation
709
+ * belongs to exactly one store. Same-store it is strictly more conservative
710
+ * than the old slot — the gate keeps refusing until EVERY pending entry has
711
+ * died, rather than crashing on the second.
712
+ *
713
+ * Release edges, per entry: the activation PARKS again
714
+ * (`blockCurrentActivation` -> `consumePendingIfRunning`), it FINISHES (its
715
+ * `awaitValue` promise settles -> `noteAwaiting` -> `releasePendingOf`), or
716
+ * the driver drops its own speculative entry (`removePendingResumption`).
717
+ */
718
+ pendingResumptions = new Set();
719
+ /**
720
+ * Record that a suspension of this store has been settled and its
721
+ * activation has not run yet. Idempotent; a null/undefined activation is
722
+ * "no entry" (the instantiation-time shape that has no thread at all).
723
+ *
724
+ * No one-claimant assert: two entries are legitimate (see
725
+ * `pendingResumptions`). Two SuspensionPoints of ONE task cannot be pending
726
+ * simultaneously — a task's single activation suspends at one point at a
727
+ * time — so collapsing entries by identity loses nothing.
728
+ */
729
+ addPendingResumption(t) {
730
+ if (t === null || t === undefined)
731
+ return;
732
+ if (AMBIENT_TRACE)
733
+ traceAmbient("pending+", t);
734
+ this.pendingResumptions.add(t);
735
+ }
736
+ /** Is some settled-but-not-yet-run activation of this store pending? */
737
+ hasPendingResumptions() {
738
+ return this.pendingResumptions.size > 0;
739
+ }
740
+ /** Drop exactly `t` (the driver's own speculative entry). */
741
+ removePendingResumption(t) {
742
+ if (AMBIENT_TRACE)
743
+ traceAmbient("pending-", t);
744
+ this.pendingResumptions.delete(t);
745
+ }
746
+ /**
747
+ * Drop the pending entry iff its activation is demonstrably RUNNING — i.e.
748
+ * the entry names the same thread the ACTIVATION AMBIENT names for the code
749
+ * calling us. An entry exists to cover the window between settling a
750
+ * suspension and the resumed activation running; once that activation's own
751
+ * code is on the stack the window is closed, and holding the entry would
752
+ * gate the store on an activation that has already had its turn — while a
753
+ * running activation's built-in settles ANOTHER activation's suspension
754
+ * (`subtask.cancel` delivering a cancellation to a parked callee,
755
+ * cancellable.wast) that other entry must legitimately stay.
756
+ *
757
+ * The comparison is against `activationOf()` — the wasm-ENTRY brackets,
758
+ * deliberately not the full `threadStack` (see `entryStack`: routing it
759
+ * through the full stack moved 64 conformance commands).
760
+ */
761
+ consumePendingIfRunning() {
762
+ const a = activationOf();
763
+ if (a !== null && a !== undefined)
764
+ this.pendingResumptions.delete(a);
765
+ }
766
+ /**
767
+ * Drop the pending entry naming `t` — the settle-side half: an entry taken
768
+ * when `t`'s suspension was settled dies when `t`'s activation finishes (its
769
+ * `awaitValue` promise settles; `noteAwaiting` calls this from the eager
770
+ * settle continuation) or parks again (`blockCurrentActivation` consumes via
771
+ * `consumePendingIfRunning`).
772
+ *
773
+ * The `task.implicitThread` indirection covers entries taken against a
774
+ * task's implicit thread. `t` FINISHING also ends its activation ambient,
775
+ * so both are dropped here.
776
+ */
777
+ // deno-lint-ignore no-explicit-any
778
+ releasePendingOf(t) {
779
+ releaseActivationAmbient(t);
780
+ this.pendingResumptions.delete(t);
781
+ const implicit = t?.task
782
+ ?.implicitThread;
783
+ if (implicit !== undefined && implicit !== null) {
784
+ this.pendingResumptions.delete(implicit);
785
+ }
786
+ }
651
787
  startWaiting(t) {
652
788
  assert_(!this.waiting.includes(t), "thread already in the waiting list");
653
789
  this.waiting.push(t);
@@ -693,17 +829,17 @@ export class Store {
693
829
  * another activation's suspension — `subtask.cancel` delivering a
694
830
  * cancellation): the claim taken at settle time must survive until the
695
831
  * resumed activation parks again or finishes, and "finished" is exactly
696
- * this continuation firing. See `releaseClaimOf`.
832
+ * this continuation firing. See `releasePendingOf`.
697
833
  */
698
834
  // deno-lint-ignore no-explicit-any
699
835
  noteAwaiting(t, promise) {
700
836
  this.awaiting.add(t);
701
837
  promise.then((value) => {
702
838
  this.settled.push({ t, value, failure: undefined });
703
- releaseClaimOf(t);
839
+ this.releasePendingOf(t);
704
840
  }, (e) => {
705
841
  this.settled.push({ t, value: undefined, failure: { error: e } });
706
- releaseClaimOf(t);
842
+ this.releasePendingOf(t);
707
843
  });
708
844
  }
709
845
  /**
@@ -863,14 +999,17 @@ export class Store {
863
999
  //
864
1000
  // Settling a suspension hands control to wasm in a *microtask*, not
865
1001
  // synchronously — so `tick` returns with the resumed activation not yet
866
- // run and its ambient claim still outstanding. Resolving a second one
867
- // before that happens would overwrite the claim, and the first
868
- // activation's built-ins would then attribute themselves to the wrong
869
- // task (observed as `exit-sync-call` popping another task's bracket).
870
- // Refusing to make progress while a claim is live forces the caller to
871
- // yield to the microtask queue first, which is exactly what `driveAsync`
872
- // does.
873
- if (resumingThread !== null)
1002
+ // run and its pending entry still outstanding. Resolving a second one
1003
+ // before that happens would let the first activation's built-ins
1004
+ // attribute themselves to the wrong task (observed as `exit-sync-call`
1005
+ // popping another task's bracket). Refusing to make progress while an
1006
+ // entry is pending forces the caller to yield to the microtask queue
1007
+ // first, which is exactly what `driveAsync` does.
1008
+ //
1009
+ // THIS STORE's entries only (issue #210): activations never cross stores,
1010
+ // so another store's pending resumption says nothing about what this one
1011
+ // may schedule.
1012
+ if (this.pendingResumptions.size > 0)
874
1013
  return false;
875
1014
  // Same discipline, other edge: a settled-but-unserviced activation tail
876
1015
  // (see `settled`) is mid-"atomic resume" from the reference's point of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polyengine/runtime",
3
- "version": "0.3.1-pre.gf4b740b",
3
+ "version": "0.3.1",
4
4
  "description": "A WebAssembly Component Model host for JavaScript engines: plan executor, canonical ABI, 0.3 task scheduler, JSPI bridge, and embedder API.",
5
5
  "homepage": "https://github.com/polymorph-components/polyengine#readme",
6
6
  "repository": {
@@ -14,10 +14,10 @@ export declare const NUM_CONTEXT_SLOTS = 2;
14
14
  * in slot 0, which is why this intrinsic is the entry blocker for async
15
15
  * guests.
16
16
  */
17
- export declare function canonContextGet(i: number): number;
17
+ export declare function canonContextGet(i: number, inst?: unknown): number;
18
18
  export declare function ctxThreadId(t: unknown): string;
19
19
  /** definitions.py `canon_context_set` (line 2358). */
20
- export declare function canonContextSet(i: number, v: number): void;
20
+ export declare function canonContextSet(i: number, v: number, inst?: unknown): void;
21
21
  /**
22
22
  * Materialize one `unsafe-intrinsic` CoreDef as a core function.
23
23
  *
@@ -25,4 +25,14 @@ export declare function canonContextSet(i: number, v: number): void;
25
25
  * unimplementable symbol fails instantiation rather than the first call
26
26
  * (contracts/plan-format.md "Executor obligations").
27
27
  */
28
- export declare function createUnsafeIntrinsic(symbol: string): CoreFn;
28
+ export declare function createUnsafeIntrinsic(symbol: string,
29
+ /**
30
+ * The component instance whose core module declares this import — the
31
+ * instance whose frame is, by construction, the one executing when it is
32
+ * called. `undefined`/`null` (a FACT adapter module, which the plan records
33
+ * with `instance: null`) falls back to the unscoped ambient. See
34
+ * `currentThreadForInstance` (task/scheduler.ts) for why this discriminator
35
+ * is what makes a JSPI continuation chunk's `context.set` land in its own
36
+ * thread's slots.
37
+ */
38
+ inst?: unknown): CoreFn;
@@ -129,7 +129,7 @@ export declare function withActivation<T>(t: any, fn: () => T): T;
129
129
  * direct evidence that `t` is running RIGHT NOW (its Suspending import just
130
130
  * returned into its wasm). The previous early-return kept stale order: a
131
131
  * nested callee's claim whose release edge is a promise reaction
132
- * (`Store.noteAwaiting` -> `releaseClaimOf`) outlives the callee by a
132
+ * (`Store.noteAwaiting` -> `Store.releasePendingOf`) outlives the callee by a
133
133
  * microtask, and an outer activation's continuation chunk that resumed in
134
134
  * that window re-claimed itself as a NOOP — leaving the finished callee on
135
135
  * top, so every ambient read in the rest of the chunk (the next hop's
@@ -154,51 +154,67 @@ export declare function dbgId(t: unknown): string;
154
154
  * second edge for claims taken against a task's implicit thread.
155
155
  */
156
156
  export declare function releaseActivationAmbient(t: any): void;
157
- /** Claim the ambient for `t` across an engine-driven resumption. */
158
- export declare function setResumingThread(t: any): void;
159
- /** Is a settled-but-not-yet-run activation holding the ambient? */
160
- export declare function hasResumingThread(): boolean;
161
- /** Release the claim; called once we are back in our own continuation. */
162
- export declare function clearResumingThread(): void;
163
- /**
164
- * Release the driver's claim iff its activation is demonstrably RUNNING —
165
- * i.e. the claim names the same thread the ACTIVATION AMBIENT names for the
166
- * code calling us. The claim exists to cover the window between settling a
167
- * suspension and the resumed activation running; once that activation's own
168
- * code is on the stack the window is closed, and holding the claim would
169
- * falsely trip the one-claimant assert when the running activation's built-in
170
- * settles ANOTHER activation's suspension — `subtask.cancel` delivering a
171
- * cancellation to a parked callee (cancellable.wast) is exactly that shape.
172
- * When the two disagree (or no ambient is present) the claim stays, and the
173
- * assert keeps guarding the genuine two-unrun-claimants bug it was built for.
174
- *
175
- * The comparison used to be against the async-context store; it is now
176
- * against `activationOf()`, which is the same statement made explicitly.
177
- */
178
- export declare function consumeClaimIfRunning(): void;
179
- /**
180
- * Release the claim iff it names `t` — the settle-side half of the claim
181
- * discipline: a claim taken when `t`'s suspension was settled dies when `t`'s
182
- * activation finishes (its `awaitValue` promise settles; `Store.noteAwaiting`
183
- * calls this from the eager settle continuation) or parks again
184
- * (`blockCurrentActivation` consumes via `consumeClaimIfRunning`).
185
- *
186
- * `t` FINISHING also ends its activation ambient, so both are dropped here.
187
- */
188
- export declare function releaseClaimOf(t: any): void;
189
157
  /** Diagnostic (#24 probe): the full ambient state, for tracing. */
190
158
  export declare function ambientDebug(): {
191
159
  stack: unknown[];
192
160
  claims: unknown[];
193
- resuming: unknown;
194
161
  };
195
- /** Diagnostic: module-scope state that must NOT survive a completed call. */
162
+ /**
163
+ * Diagnostic: module-scope AMBIENT state that must NOT survive a completed
164
+ * call. The scheduling gate is no longer module-scope — a store's
165
+ * `pendingResumptions` set is the per-Store analogue and is checked there.
166
+ */
196
167
  export declare function ambientResidue(): {
197
168
  stack: number;
198
169
  claim: boolean;
199
170
  };
200
171
  export declare function currentThread<T = CurrentThreadLike>(): T;
201
172
  export declare function maybeCurrentThread(): CurrentThreadLike | undefined;
173
+ /**
174
+ * THE ambient, NARROWED BY THE INSTANCE WHOSE CORE FRAME IS EXECUTING.
175
+ *
176
+ * For a built-in whose declaration names a component instance, "who is
177
+ * running" is not an open question about the whole store: the call arrived
178
+ * from a core frame OF THAT INSTANCE, so the running activation is one of
179
+ * that instance's. This narrows `resolveAmbient` accordingly — same tiers,
180
+ * same order, candidates filtered — and falls back to the unscoped answer
181
+ * when the instance has no candidate at all (the instantiation-time shape,
182
+ * and any built-in reached before its instance has a task).
183
+ *
184
+ * WHY IT IS NEEDED (polyengine#24's residue; polyvisor#49 trap 1,
185
+ * `runtime/tests/context_attribution_test.ts`). A JSPI continuation chunk —
186
+ * the tail of a suspended activation, e.g. wit-bindgen's callback epilogue
187
+ * restoring its task pointer with `context.set` (rt/async_support.rs:592) —
188
+ * runs with an EMPTY `threadStack` and, unlike a hop, has no re-anchoring
189
+ * edge of its own. Tier 2 then answers the newest claim, which is whichever
190
+ * SIBLING activation suspended most recently. The attribution sentinels
191
+ * (jspi/bridge.ts) plant that claim one microtask ahead of the chunk, which
192
+ * is exact when the engine queues the resumption while the settle reaction
193
+ * returns (measured so in Deno's V8) — and NOT exact in Chromium, where a
194
+ * wider gap lets a sibling's sentinel land in between. Measured there 3/3:
195
+ * one task's epilogue wrote its state pointer into another task's slots, and
196
+ * the starved task's next callback entry hit `assert!(!state.is_null())`
197
+ * (async_support.rs:578) -> unreachable.
198
+ *
199
+ * Ordering discipline cannot fix that class — engine chunk boundaries are not
200
+ * observable, so every microtask-ordering scheme is a hope. Instance identity
201
+ * is not a hope: it is static (the declaration), and it is decisive because
202
+ * ONE INSTANCE CAN ONLY HAVE ONE ACTIVATION MID-FRAME AT A TIME — a callback
203
+ * invocation holds `inst.exclusiveThread` for its whole extent, suspensions
204
+ * included (definitions.py line 2187 / `runCallbackLoop`), and a sync or
205
+ * stackful-async lift holds the entry gate. Two activations that can race for
206
+ * an unbracketed read are therefore necessarily of different instances, which
207
+ * is exactly what this discriminates.
208
+ *
209
+ * SPEC BASIS. `canon_context_get`/`canon_context_set` (definitions.py 2348 /
210
+ * 2358) read `current_thread().storage`, and in the reference a built-in is
211
+ * only ever reached from inside the activation that called it — the identity
212
+ * is exact by construction, never inferred. This runtime has to reconstruct
213
+ * it; narrowing the reconstruction to the declaring instance moves it TOWARD
214
+ * the reference (it can only ever remove candidates the reference would never
215
+ * have named), never away.
216
+ */
217
+ export declare function currentThreadForInstance<T = CurrentThreadLike>(inst: unknown): T;
202
218
  /** definitions.py `current_task()` (line 309). */
203
219
  export declare function currentTask(): any;
204
220
  /**
@@ -241,6 +257,84 @@ export declare class Store {
241
257
  * is driving the store — which is the call the guest is blocked in.
242
258
  */
243
259
  hostFailure: unknown;
260
+ /**
261
+ * Resumed-but-not-yet-run activations of THIS store — the driver's
262
+ * scheduling gate, not an ambient.
263
+ *
264
+ * Keeping this distinct from `activationClaims` matters. This set answers
265
+ * "may I schedule something else right now?" (`Store.tick` and both driving
266
+ * loops refuse while it is non-empty, which is what forces a microtask yield
267
+ * so the resumed activation actually runs). `activationClaims` answers
268
+ * "whose code is this?". Conflating them — driving off the ambient queue —
269
+ * wedges the loops, because an activation that merely hopped legitimately
270
+ * holds an ambient while the scheduler is free to proceed.
271
+ *
272
+ * PER-STORE and MULTI-ENTRY since 2026-08-22 (issues #158 mechanism B,
273
+ * #210). It was one module-global slot with a one-claimant assert, which
274
+ * (a) could not represent two legitimately-pending engine resumptions — a
275
+ * running activation X delivering a resume to Z while Y's resumption was
276
+ * still pending crashed on the assert — and (b) made every driver on every
277
+ * store yield while ANY store held a claim, so an idle store's
278
+ * `driveStoreAsync` died at the 10,000-hop assert (~311ms) while another
279
+ * store merely dwelt on a slow host import. The assert's invariant was
280
+ * tier-3 attribution unambiguity, which no longer exists (see
281
+ * `resolveAmbient`), so it is gone with the slot; the entries and their
282
+ * release edges are otherwise unchanged, per entry.
283
+ *
284
+ * Cross-store de-serialization is safe by disjointness: an activation
285
+ * belongs to exactly one store. Same-store it is strictly more conservative
286
+ * than the old slot — the gate keeps refusing until EVERY pending entry has
287
+ * died, rather than crashing on the second.
288
+ *
289
+ * Release edges, per entry: the activation PARKS again
290
+ * (`blockCurrentActivation` -> `consumePendingIfRunning`), it FINISHES (its
291
+ * `awaitValue` promise settles -> `noteAwaiting` -> `releasePendingOf`), or
292
+ * the driver drops its own speculative entry (`removePendingResumption`).
293
+ */
294
+ readonly pendingResumptions: Set<unknown>;
295
+ /**
296
+ * Record that a suspension of this store has been settled and its
297
+ * activation has not run yet. Idempotent; a null/undefined activation is
298
+ * "no entry" (the instantiation-time shape that has no thread at all).
299
+ *
300
+ * No one-claimant assert: two entries are legitimate (see
301
+ * `pendingResumptions`). Two SuspensionPoints of ONE task cannot be pending
302
+ * simultaneously — a task's single activation suspends at one point at a
303
+ * time — so collapsing entries by identity loses nothing.
304
+ */
305
+ addPendingResumption(t: unknown): void;
306
+ /** Is some settled-but-not-yet-run activation of this store pending? */
307
+ hasPendingResumptions(): boolean;
308
+ /** Drop exactly `t` (the driver's own speculative entry). */
309
+ removePendingResumption(t: unknown): void;
310
+ /**
311
+ * Drop the pending entry iff its activation is demonstrably RUNNING — i.e.
312
+ * the entry names the same thread the ACTIVATION AMBIENT names for the code
313
+ * calling us. An entry exists to cover the window between settling a
314
+ * suspension and the resumed activation running; once that activation's own
315
+ * code is on the stack the window is closed, and holding the entry would
316
+ * gate the store on an activation that has already had its turn — while a
317
+ * running activation's built-in settles ANOTHER activation's suspension
318
+ * (`subtask.cancel` delivering a cancellation to a parked callee,
319
+ * cancellable.wast) that other entry must legitimately stay.
320
+ *
321
+ * The comparison is against `activationOf()` — the wasm-ENTRY brackets,
322
+ * deliberately not the full `threadStack` (see `entryStack`: routing it
323
+ * through the full stack moved 64 conformance commands).
324
+ */
325
+ consumePendingIfRunning(): void;
326
+ /**
327
+ * Drop the pending entry naming `t` — the settle-side half: an entry taken
328
+ * when `t`'s suspension was settled dies when `t`'s activation finishes (its
329
+ * `awaitValue` promise settles; `noteAwaiting` calls this from the eager
330
+ * settle continuation) or parks again (`blockCurrentActivation` consumes via
331
+ * `consumePendingIfRunning`).
332
+ *
333
+ * The `task.implicitThread` indirection covers entries taken against a
334
+ * task's implicit thread. `t` FINISHING also ends its activation ambient,
335
+ * so both are dropped here.
336
+ */
337
+ releasePendingOf(t: any): void;
244
338
  startWaiting(t: SchedulableThread): void;
245
339
  stopWaiting(t: SchedulableThread): void;
246
340
  /** Ready waiting threads, in wait order (the FIFO of the default policy). */
@@ -282,7 +376,7 @@ export declare class Store {
282
376
  * another activation's suspension — `subtask.cancel` delivering a
283
377
  * cancellation): the claim taken at settle time must survive until the
284
378
  * resumed activation parks again or finishes, and "finished" is exactly
285
- * this continuation firing. See `releaseClaimOf`.
379
+ * this continuation firing. See `releasePendingOf`.
286
380
  */
287
381
  noteAwaiting(t: any, promise: Promise<unknown>): void;
288
382
  /**