@polyengine/runtime 0.3.0 → 0.3.1-pre.g4fe5f4b

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.
@@ -29,7 +29,7 @@ export const COPY_URL = import.meta.url;
29
29
  * @internal — copy-identity constant for the A9 multi-copy diagnostics; not
30
30
  * host-facing.
31
31
  */
32
- export const RUNTIME_VERSION = "0.3.0";
32
+ export const RUNTIME_VERSION = "0.3.1";
33
33
  /**
34
34
  * Compose a cross-copy diagnostic: what was foreign, which copy is speaking,
35
35
  * the census of every copy in the graph, and the by-value remediation.
@@ -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
@@ -53,7 +53,7 @@ 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
59
  export function canonContextSet(i, v) {
@@ -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
  *
@@ -473,9 +473,20 @@ export class SuspensionPoint {
473
473
  // like the value path. Missing it here is what `trap-if-done.wast:448`
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
+ //
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();
476
487
  if (maybeCurrentThread() === undefined)
477
488
  claimActivationAmbient(this.owner);
478
- setResumingThread(this.task?.implicitThread ?? null);
489
+ this.#store.addPendingResumption(this.task?.implicitThread ?? null);
479
490
  this.#fail(e);
480
491
  return;
481
492
  }
@@ -483,25 +494,25 @@ export class SuspensionPoint {
483
494
  // settling the import's Promise hands control to wasm, which will call
484
495
  // built-ins with an empty bracket stack. The claim names `owner` — the
485
496
  // activation captured when this point was minted — not a guess derived
486
- // now; see `owner` and `setResumingThread`.
497
+ // now; see `owner` and `Store.pendingResumptions`.
487
498
  //
488
- // If the DRIVER's slot is live for the activation currently executing (it
489
- // is the code that called us — a running guest's `subtask.cancel`
490
- // delivering a cancellation settles the callee's suspension from inside
491
- // its own frame), that claim has served its purpose; consume it rather
492
- // than false-positive the one-claimant assert.
493
- 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();
494
505
  // The activation-ambient claim (site (i) in scheduler.ts). Taken only when
495
506
  // NOBODY is running right now: if a guest activation is executing, `owner`
496
507
  // does not run until that activation yields, and pushing onto a
497
508
  // LAST-IN-FIRST-OUT stack now would make `owner` the ambient for the
498
- // caller's remaining frame. In that shape `owner` is picked up either by
499
- // its own first `Suspending` call (site (ii)) or, before that, by the
500
- // driver's `resumingThread` slot at the bottom tier exactly as it always
501
- // 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.
502
513
  if (maybeCurrentThread() === undefined)
503
514
  claimActivationAmbient(this.owner);
504
- setResumingThread(this.task?.implicitThread ?? null);
515
+ this.#store.addPendingResumption(this.task?.implicitThread ?? null);
505
516
  this.#settle(value);
506
517
  }
507
518
  /** Abandon this suspension without resuming the guest (teardown paths). */
@@ -568,11 +579,11 @@ export function blockCurrentActivation(input) {
568
579
  // and strands the point with no owner (measured: `cancellable.wast:322`
569
580
  // then reported `pending-capability: instantiation-time task context`).
570
581
  const owner = maybeCurrentThread() ?? input.task?.implicitThread ?? null;
571
- // The activation is parking: if it still carried the resumed-ambient claim
572
- // from the settle that resumed it, that claim's window closes here (the
573
- // 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
574
585
  // `Store.noteAwaiting`'s settle continuation).
575
- consumeClaimIfRunning();
586
+ input.store.consumePendingIfRunning();
576
587
  releaseActivationAmbient(owner);
577
588
  const point = new SuspensionPoint(input.store, input.task, input.readyFunc, input.cancellable, input.produce, owner, input.onSettled);
578
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) {
@@ -648,6 +611,109 @@ export class Store {
648
611
  * is driving the store — which is the call the guest is blocked in.
649
612
  */
650
613
  hostFailure = undefined;
614
+ /**
615
+ * Resumed-but-not-yet-run activations of THIS store — the driver's
616
+ * scheduling gate, not an ambient.
617
+ *
618
+ * Keeping this distinct from `activationClaims` matters. This set answers
619
+ * "may I schedule something else right now?" (`Store.tick` and both driving
620
+ * loops refuse while it is non-empty, which is what forces a microtask yield
621
+ * so the resumed activation actually runs). `activationClaims` answers
622
+ * "whose code is this?". Conflating them — driving off the ambient queue —
623
+ * wedges the loops, because an activation that merely hopped legitimately
624
+ * holds an ambient while the scheduler is free to proceed.
625
+ *
626
+ * PER-STORE and MULTI-ENTRY since 2026-08-22 (issues #158 mechanism B,
627
+ * #210). It was one module-global slot with a one-claimant assert, which
628
+ * (a) could not represent two legitimately-pending engine resumptions — a
629
+ * running activation X delivering a resume to Z while Y's resumption was
630
+ * still pending crashed on the assert — and (b) made every driver on every
631
+ * store yield while ANY store held a claim, so an idle store's
632
+ * `driveStoreAsync` died at the 10,000-hop assert (~311ms) while another
633
+ * store merely dwelt on a slow host import. The assert's invariant was
634
+ * tier-3 attribution unambiguity, which no longer exists (see
635
+ * `resolveAmbient`), so it is gone with the slot; the entries and their
636
+ * release edges are otherwise unchanged, per entry.
637
+ *
638
+ * Cross-store de-serialization is safe by disjointness: an activation
639
+ * belongs to exactly one store. Same-store it is strictly more conservative
640
+ * than the old slot — the gate keeps refusing until EVERY pending entry has
641
+ * died, rather than crashing on the second.
642
+ *
643
+ * Release edges, per entry: the activation PARKS again
644
+ * (`blockCurrentActivation` -> `consumePendingIfRunning`), it FINISHES (its
645
+ * `awaitValue` promise settles -> `noteAwaiting` -> `releasePendingOf`), or
646
+ * the driver drops its own speculative entry (`removePendingResumption`).
647
+ */
648
+ pendingResumptions = new Set();
649
+ /**
650
+ * Record that a suspension of this store has been settled and its
651
+ * activation has not run yet. Idempotent; a null/undefined activation is
652
+ * "no entry" (the instantiation-time shape that has no thread at all).
653
+ *
654
+ * No one-claimant assert: two entries are legitimate (see
655
+ * `pendingResumptions`). Two SuspensionPoints of ONE task cannot be pending
656
+ * simultaneously — a task's single activation suspends at one point at a
657
+ * time — so collapsing entries by identity loses nothing.
658
+ */
659
+ addPendingResumption(t) {
660
+ if (t === null || t === undefined)
661
+ return;
662
+ if (AMBIENT_TRACE)
663
+ traceAmbient("pending+", t);
664
+ this.pendingResumptions.add(t);
665
+ }
666
+ /** Is some settled-but-not-yet-run activation of this store pending? */
667
+ hasPendingResumptions() {
668
+ return this.pendingResumptions.size > 0;
669
+ }
670
+ /** Drop exactly `t` (the driver's own speculative entry). */
671
+ removePendingResumption(t) {
672
+ if (AMBIENT_TRACE)
673
+ traceAmbient("pending-", t);
674
+ this.pendingResumptions.delete(t);
675
+ }
676
+ /**
677
+ * Drop the pending entry iff its activation is demonstrably RUNNING — i.e.
678
+ * the entry names the same thread the ACTIVATION AMBIENT names for the code
679
+ * calling us. An entry exists to cover the window between settling a
680
+ * suspension and the resumed activation running; once that activation's own
681
+ * code is on the stack the window is closed, and holding the entry would
682
+ * gate the store on an activation that has already had its turn — while a
683
+ * running activation's built-in settles ANOTHER activation's suspension
684
+ * (`subtask.cancel` delivering a cancellation to a parked callee,
685
+ * cancellable.wast) that other entry must legitimately stay.
686
+ *
687
+ * The comparison is against `activationOf()` — the wasm-ENTRY brackets,
688
+ * deliberately not the full `threadStack` (see `entryStack`: routing it
689
+ * through the full stack moved 64 conformance commands).
690
+ */
691
+ consumePendingIfRunning() {
692
+ const a = activationOf();
693
+ if (a !== null && a !== undefined)
694
+ this.pendingResumptions.delete(a);
695
+ }
696
+ /**
697
+ * Drop the pending entry naming `t` — the settle-side half: an entry taken
698
+ * when `t`'s suspension was settled dies when `t`'s activation finishes (its
699
+ * `awaitValue` promise settles; `noteAwaiting` calls this from the eager
700
+ * settle continuation) or parks again (`blockCurrentActivation` consumes via
701
+ * `consumePendingIfRunning`).
702
+ *
703
+ * The `task.implicitThread` indirection covers entries taken against a
704
+ * task's implicit thread. `t` FINISHING also ends its activation ambient,
705
+ * so both are dropped here.
706
+ */
707
+ // deno-lint-ignore no-explicit-any
708
+ releasePendingOf(t) {
709
+ releaseActivationAmbient(t);
710
+ this.pendingResumptions.delete(t);
711
+ const implicit = t?.task
712
+ ?.implicitThread;
713
+ if (implicit !== undefined && implicit !== null) {
714
+ this.pendingResumptions.delete(implicit);
715
+ }
716
+ }
651
717
  startWaiting(t) {
652
718
  assert_(!this.waiting.includes(t), "thread already in the waiting list");
653
719
  this.waiting.push(t);
@@ -693,17 +759,17 @@ export class Store {
693
759
  * another activation's suspension — `subtask.cancel` delivering a
694
760
  * cancellation): the claim taken at settle time must survive until the
695
761
  * resumed activation parks again or finishes, and "finished" is exactly
696
- * this continuation firing. See `releaseClaimOf`.
762
+ * this continuation firing. See `releasePendingOf`.
697
763
  */
698
764
  // deno-lint-ignore no-explicit-any
699
765
  noteAwaiting(t, promise) {
700
766
  this.awaiting.add(t);
701
767
  promise.then((value) => {
702
768
  this.settled.push({ t, value, failure: undefined });
703
- releaseClaimOf(t);
769
+ this.releasePendingOf(t);
704
770
  }, (e) => {
705
771
  this.settled.push({ t, value: undefined, failure: { error: e } });
706
- releaseClaimOf(t);
772
+ this.releasePendingOf(t);
707
773
  });
708
774
  }
709
775
  /**
@@ -863,14 +929,17 @@ export class Store {
863
929
  //
864
930
  // Settling a suspension hands control to wasm in a *microtask*, not
865
931
  // 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)
932
+ // run and its pending entry still outstanding. Resolving a second one
933
+ // before that happens would let the first activation's built-ins
934
+ // attribute themselves to the wrong task (observed as `exit-sync-call`
935
+ // popping another task's bracket). Refusing to make progress while an
936
+ // entry is pending forces the caller to yield to the microtask queue
937
+ // first, which is exactly what `driveAsync` does.
938
+ //
939
+ // THIS STORE's entries only (issue #210): activations never cross stores,
940
+ // so another store's pending resumption says nothing about what this one
941
+ // may schedule.
942
+ if (this.pendingResumptions.size > 0)
874
943
  return false;
875
944
  // Same discipline, other edge: a settled-but-unserviced activation tail
876
945
  // (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.0",
3
+ "version": "0.3.1-pre.g4fe5f4b",
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": {
@@ -55,7 +55,7 @@
55
55
  "access": "public"
56
56
  },
57
57
  "dependencies": {
58
- "@polyengine/protocol": "0.3.0"
58
+ "@polyengine/protocol": "^0.1.0"
59
59
  },
60
60
  "_generatedBy": "dnt@0.43.2"
61
61
  }
@@ -17,7 +17,7 @@ export declare const COPY_URL: string;
17
17
  * @internal — copy-identity constant for the A9 multi-copy diagnostics; not
18
18
  * host-facing.
19
19
  */
20
- export declare const RUNTIME_VERSION = "0.3.0";
20
+ export declare const RUNTIME_VERSION = "0.3.1";
21
21
  /**
22
22
  * Compose a cross-copy diagnostic: what was foreign, which copy is speaking,
23
23
  * the census of every copy in the graph, and the by-value remediation.
@@ -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,45 +154,16 @@ 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;
@@ -241,6 +212,84 @@ export declare class Store {
241
212
  * is driving the store — which is the call the guest is blocked in.
242
213
  */
243
214
  hostFailure: unknown;
215
+ /**
216
+ * Resumed-but-not-yet-run activations of THIS store — the driver's
217
+ * scheduling gate, not an ambient.
218
+ *
219
+ * Keeping this distinct from `activationClaims` matters. This set answers
220
+ * "may I schedule something else right now?" (`Store.tick` and both driving
221
+ * loops refuse while it is non-empty, which is what forces a microtask yield
222
+ * so the resumed activation actually runs). `activationClaims` answers
223
+ * "whose code is this?". Conflating them — driving off the ambient queue —
224
+ * wedges the loops, because an activation that merely hopped legitimately
225
+ * holds an ambient while the scheduler is free to proceed.
226
+ *
227
+ * PER-STORE and MULTI-ENTRY since 2026-08-22 (issues #158 mechanism B,
228
+ * #210). It was one module-global slot with a one-claimant assert, which
229
+ * (a) could not represent two legitimately-pending engine resumptions — a
230
+ * running activation X delivering a resume to Z while Y's resumption was
231
+ * still pending crashed on the assert — and (b) made every driver on every
232
+ * store yield while ANY store held a claim, so an idle store's
233
+ * `driveStoreAsync` died at the 10,000-hop assert (~311ms) while another
234
+ * store merely dwelt on a slow host import. The assert's invariant was
235
+ * tier-3 attribution unambiguity, which no longer exists (see
236
+ * `resolveAmbient`), so it is gone with the slot; the entries and their
237
+ * release edges are otherwise unchanged, per entry.
238
+ *
239
+ * Cross-store de-serialization is safe by disjointness: an activation
240
+ * belongs to exactly one store. Same-store it is strictly more conservative
241
+ * than the old slot — the gate keeps refusing until EVERY pending entry has
242
+ * died, rather than crashing on the second.
243
+ *
244
+ * Release edges, per entry: the activation PARKS again
245
+ * (`blockCurrentActivation` -> `consumePendingIfRunning`), it FINISHES (its
246
+ * `awaitValue` promise settles -> `noteAwaiting` -> `releasePendingOf`), or
247
+ * the driver drops its own speculative entry (`removePendingResumption`).
248
+ */
249
+ readonly pendingResumptions: Set<unknown>;
250
+ /**
251
+ * Record that a suspension of this store has been settled and its
252
+ * activation has not run yet. Idempotent; a null/undefined activation is
253
+ * "no entry" (the instantiation-time shape that has no thread at all).
254
+ *
255
+ * No one-claimant assert: two entries are legitimate (see
256
+ * `pendingResumptions`). Two SuspensionPoints of ONE task cannot be pending
257
+ * simultaneously — a task's single activation suspends at one point at a
258
+ * time — so collapsing entries by identity loses nothing.
259
+ */
260
+ addPendingResumption(t: unknown): void;
261
+ /** Is some settled-but-not-yet-run activation of this store pending? */
262
+ hasPendingResumptions(): boolean;
263
+ /** Drop exactly `t` (the driver's own speculative entry). */
264
+ removePendingResumption(t: unknown): void;
265
+ /**
266
+ * Drop the pending entry iff its activation is demonstrably RUNNING — i.e.
267
+ * the entry names the same thread the ACTIVATION AMBIENT names for the code
268
+ * calling us. An entry exists to cover the window between settling a
269
+ * suspension and the resumed activation running; once that activation's own
270
+ * code is on the stack the window is closed, and holding the entry would
271
+ * gate the store on an activation that has already had its turn — while a
272
+ * running activation's built-in settles ANOTHER activation's suspension
273
+ * (`subtask.cancel` delivering a cancellation to a parked callee,
274
+ * cancellable.wast) that other entry must legitimately stay.
275
+ *
276
+ * The comparison is against `activationOf()` — the wasm-ENTRY brackets,
277
+ * deliberately not the full `threadStack` (see `entryStack`: routing it
278
+ * through the full stack moved 64 conformance commands).
279
+ */
280
+ consumePendingIfRunning(): void;
281
+ /**
282
+ * Drop the pending entry naming `t` — the settle-side half: an entry taken
283
+ * when `t`'s suspension was settled dies when `t`'s activation finishes (its
284
+ * `awaitValue` promise settles; `noteAwaiting` calls this from the eager
285
+ * settle continuation) or parks again (`blockCurrentActivation` consumes via
286
+ * `consumePendingIfRunning`).
287
+ *
288
+ * The `task.implicitThread` indirection covers entries taken against a
289
+ * task's implicit thread. `t` FINISHING also ends its activation ambient,
290
+ * so both are dropped here.
291
+ */
292
+ releasePendingOf(t: any): void;
244
293
  startWaiting(t: SchedulableThread): void;
245
294
  stopWaiting(t: SchedulableThread): void;
246
295
  /** Ready waiting threads, in wait order (the FIFO of the default policy). */
@@ -282,7 +331,7 @@ export declare class Store {
282
331
  * another activation's suspension — `subtask.cancel` delivering a
283
332
  * cancellation): the claim taken at settle time must survive until the
284
333
  * resumed activation parks again or finishes, and "finished" is exactly
285
- * this continuation firing. See `releaseClaimOf`.
334
+ * this continuation firing. See `releasePendingOf`.
286
335
  */
287
336
  noteAwaiting(t: any, promise: Promise<unknown>): void;
288
337
  /**