@polyengine/runtime 0.5.0 → 0.6.0

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.
Files changed (82) hide show
  1. package/esm/cabi/async_values.js +6 -5
  2. package/esm/cabi/bulk_lists.js +0 -5
  3. package/esm/cabi/context.js +13 -3
  4. package/esm/cabi/flatten.js +41 -9
  5. package/esm/cabi/handles.js +57 -54
  6. package/esm/cabi/layout.js +113 -52
  7. package/esm/cabi/load.js +31 -23
  8. package/esm/cabi/store.js +33 -26
  9. package/esm/cabi/trap.js +2 -2
  10. package/esm/cabi/types.js +138 -25
  11. package/esm/cabi/values.js +25 -7
  12. package/esm/cache/core.js +2 -11
  13. package/esm/digest/digest.js +10 -8
  14. package/esm/digest/mod.js +1 -1
  15. package/esm/digest/verify.js +6 -86
  16. package/esm/embedder/casing.js +24 -9
  17. package/esm/embedder/copy.js +6 -6
  18. package/esm/embedder/errors.js +2 -2
  19. package/esm/embedder/imports.js +3 -3
  20. package/esm/embedder/instantiate.js +170 -41
  21. package/esm/embedder/mod.js +9 -8
  22. package/esm/embedder/resources.js +39 -16
  23. package/esm/embedder/streams.js +36 -37
  24. package/esm/embedder/sync.js +242 -0
  25. package/esm/embedder/values.js +84 -22
  26. package/esm/embedder/version.js +9 -9
  27. package/esm/exec/boundary.js +318 -165
  28. package/esm/exec/executor.js +51 -24
  29. package/esm/exec/host_streams.js +31 -31
  30. package/esm/intrinsics/async_builtins.js +19 -8
  31. package/esm/intrinsics/context.js +1 -1
  32. package/esm/intrinsics/errors.js +9 -9
  33. package/esm/intrinsics/fact_calls.js +37 -49
  34. package/esm/intrinsics/mod.js +54 -117
  35. package/esm/intrinsics/stream_builtins.js +2 -2
  36. package/esm/intrinsics/transcode.js +1 -1
  37. package/esm/jspi/bridge.js +4 -3
  38. package/esm/jspi/suspending.js +10 -5
  39. package/esm/plan/loader.js +5 -5
  40. package/esm/shim/translator.js +2 -2
  41. package/esm/task/mod.js +45 -182
  42. package/esm/task/scheduler.js +154 -185
  43. package/esm/task/streams.js +39 -54
  44. package/esm/task/subtask.js +2 -2
  45. package/esm/task/thread.js +20 -41
  46. package/esm/task/waitable.js +0 -1
  47. package/package.json +2 -2
  48. package/types/cabi/async_values.d.ts +3 -2
  49. package/types/cabi/bulk_lists.d.ts +0 -2
  50. package/types/cabi/context.d.ts +15 -5
  51. package/types/cabi/flatten.d.ts +2 -2
  52. package/types/cabi/handles.d.ts +15 -26
  53. package/types/cabi/layout.d.ts +22 -1
  54. package/types/cabi/load.d.ts +10 -2
  55. package/types/cabi/store.d.ts +4 -2
  56. package/types/cabi/types.d.ts +22 -3
  57. package/types/digest/mod.d.ts +1 -1
  58. package/types/digest/verify.d.ts +3 -19
  59. package/types/embedder/casing.d.ts +9 -1
  60. package/types/embedder/copy.d.ts +4 -4
  61. package/types/embedder/instantiate.d.ts +4 -4
  62. package/types/embedder/mod.d.ts +3 -2
  63. package/types/embedder/resources.d.ts +20 -7
  64. package/types/embedder/streams.d.ts +5 -6
  65. package/types/embedder/sync.d.ts +81 -0
  66. package/types/embedder/values.d.ts +2 -2
  67. package/types/exec/boundary.d.ts +66 -42
  68. package/types/exec/executor.d.ts +3 -2
  69. package/types/exec/host_streams.d.ts +8 -8
  70. package/types/intrinsics/errors.d.ts +3 -3
  71. package/types/intrinsics/mod.d.ts +1 -1
  72. package/types/intrinsics/stream_builtins.d.ts +2 -2
  73. package/types/jspi/bridge.d.ts +6 -5
  74. package/types/jspi/suspending.d.ts +1 -1
  75. package/types/plan/format.d.ts +11 -10
  76. package/types/plan/loader.d.ts +2 -2
  77. package/types/shim/translator.d.ts +2 -2
  78. package/types/task/mod.d.ts +26 -97
  79. package/types/task/scheduler.d.ts +81 -62
  80. package/types/task/streams.d.ts +23 -38
  81. package/types/task/subtask.d.ts +2 -2
  82. package/types/task/waitable.d.ts +0 -1
@@ -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, EventCode, withActivation, hasRealHostCall, dispatchableTail, NeedsJspi, needsJspi, packSubtaskResult, PendingCapability, notifyInstancePoisoned, realHostCalls, storeQuiescent, Subtask, WaitableSet, SubtaskState, Task, Thread, withPoisonCause, } from "../task/mod.js";
7
+ import { driveSyncLift, EventCode, withActivation, hasRealHostCall, NeedsJspi, needsJspi, packSubtaskResult, PendingCapability, notifyInstancePoisoned, realHostCalls, storeQuiescent, Subtask, SyncEntryBusy, WaitableSet, SubtaskState, Task, Thread, entryRefusal, } 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";
@@ -31,7 +31,7 @@ export function newStats() {
31
31
  * Structurally compatible with cabi's `MemInst` (same public surface).
32
32
  */
33
33
  export class LiveMemory {
34
- addrType = "i32"; // memory64 components: out of M0 scope
34
+ addrType = "i32"; // memory64 components: not yet implemented
35
35
  #provider;
36
36
  #label;
37
37
  #buffer = null;
@@ -240,7 +240,7 @@ function isPromiseLike(v) {
240
240
  typeof v.then === "function");
241
241
  }
242
242
  // ---------------------------------------------------------------------------
243
- // Handshake probe (M2 phase 3l)
243
+ // Handshake probe
244
244
  // ---------------------------------------------------------------------------
245
245
  //
246
246
  // Env-gated tracing of the drive loops. This exists because site 1 is the
@@ -366,7 +366,7 @@ function tagAwait(t) {
366
366
  * repeat. Reimplementing it there diverged: that copy only drained
367
367
  * `store.awaiting` and never awaited `pendingHostCalls`, so a guest parked on
368
368
  * a Promise-returning host import was never resumed and the host's read of
369
- * the stream it was feeding hung (C0 finding R-1).
369
+ * the stream it was feeding hung (host-pump starvation of `pendingHostCalls`).
370
370
  *
371
371
  * Callers that must not hit the deadlock traps below (the host pump: an
372
372
  * embedder that never does its half is documented to hang, not trap) can
@@ -385,7 +385,9 @@ export async function driveStoreAsync(store, done, what) {
385
385
  * have always produced concurrent loops, and the host-stream pump's stand-down
386
386
  * below is cooperative, so a *bounded overlap window* remains by construction
387
387
  * (an export call can start while the pump is parked mid-`await`; the pump
388
- * only notices at its next `done()` evaluation). The invariant is:
388
+ * notices at its next `done()` evaluation, which the driver-arrival one-shot
389
+ * below now makes prompt — before issue #239 it was "whenever the host happens
390
+ * to answer", i.e. not bounded at all). The invariant is:
389
391
  *
390
392
  * **no activation is resumed twice for one settlement, and no activation is
391
393
  * resumed with a value from a settlement it has already consumed.**
@@ -455,6 +457,51 @@ export function whenStoreDriverIdle(store) {
455
457
  return w.p;
456
458
  }
457
459
  // ---------------------------------------------------------------------------
460
+ // Driver arrival: closing the overlap window (issue #239)
461
+ // ---------------------------------------------------------------------------
462
+ //
463
+ // The stand-down above ("the pumps are *fallback* drivers") is evaluated only
464
+ // at a driver's next `done()`, so the doc's "bounded overlap window" is really
465
+ // bounded by whatever the incumbent driver is parked on — and its longest park
466
+ // is `Promise.race([...parked tags, ...pendingHostCalls])`, i.e. HOST-CONTROLLED
467
+ // time. That is a stall in its own right, and it is fatal in combination with
468
+ // the SPECULATIVE resume entry the race holds: `Store.pendingResumptions` is a
469
+ // store-wide scheduling gate, so a second driver on the same store spins at
470
+ // `driveAsync`'s top and dies at the 10,000-hop internal-bug assert in ~311ms
471
+ // (issue #239 — the same-store half of the cross-store stall #210 fixed; see
472
+ // `tests/cross_store_driver_test.ts`, whose header describes this gate being
473
+ // "held for the entire duration of a guest's wait on a slow host import").
474
+ //
475
+ // So drivers announce themselves: every `driveAsync` that finds itself the
476
+ // second (or later) loop on a store fires this one-shot, which every driver
477
+ // races alongside its parked tags. The incumbent wakes within a microtask,
478
+ // drops the speculative entry on its way out of the race, and re-evaluates
479
+ // `done()` — which is exactly the stand-down the pumps were always supposed to
480
+ // perform, now prompt instead of "whenever the host happens to answer".
481
+ const driverArrivals = new WeakMap();
482
+ /** A one-shot that resolves (to `null`, the race's "nothing settled" value)
483
+ * when another driver starts on `store`. */
484
+ function armDriverArrival(store) {
485
+ let n = driverArrivals.get(store);
486
+ if (n === undefined) {
487
+ let r;
488
+ const p = new Promise((res) => (r = () => res(null)));
489
+ n = { p, r };
490
+ driverArrivals.set(store, n);
491
+ }
492
+ return n.p;
493
+ }
494
+ function fireDriverArrival(store) {
495
+ const n = driverArrivals.get(store);
496
+ if (n === undefined)
497
+ return;
498
+ // Deleted before resolving so the next `armDriverArrival` mints a fresh,
499
+ // unresolved one-shot: a driver that wakes on this and re-parks must not
500
+ // pick the settled promise back up and spin.
501
+ driverArrivals.delete(store);
502
+ n.r();
503
+ }
504
+ // ---------------------------------------------------------------------------
458
505
  // The settlement pump: liveness between export calls
459
506
  // ---------------------------------------------------------------------------
460
507
  //
@@ -599,7 +646,14 @@ async function settlementPumpLoop(store) {
599
646
  }
600
647
  }
601
648
  async function driveAsync(store, done, what) {
602
- driverDepth.set(store, storeDriverDepth(store) + 1);
649
+ const depth = storeDriverDepth(store) + 1;
650
+ driverDepth.set(store, depth);
651
+ // An incumbent driver may be parked in the awaiting-race holding the
652
+ // speculative resume entry — a store-wide gate this loop would otherwise
653
+ // spin on until the 10,000-hop assert (issue #239). Announce ourselves so it
654
+ // stands down within a microtask.
655
+ if (depth > 1)
656
+ fireDriverArrival(store);
603
657
  try {
604
658
  let claimHops = 0;
605
659
  for (;;) {
@@ -626,11 +680,12 @@ async function driveAsync(store, done, what) {
626
680
  // and clearing it before that activation runs re-opens the
627
681
  // mis-attribution window the entry exists to close.
628
682
  //
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
- // ~311msmerely 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.
683
+ // PER-STORE (issue #210): read only THIS store's entries. Activations
684
+ // never cross stores, so another store's pending resumption is none of
685
+ // this loop's business and a gate shared across stores would spin an
686
+ // idle store's driver here, to its death at the hop bound below in
687
+ // ~311ms, merely because ANOTHER store's guest was dwelling on a slow
688
+ // host import.
634
689
  if (store.hasPendingResumptions()) {
635
690
  traceDrive("driveAsync", store, done, "yield-pending");
636
691
  // Bounded: a pending entry that never dies is an internal bug (every
@@ -688,7 +743,7 @@ async function driveAsync(store, done, what) {
688
743
  // therefore stops the scheduler while waiting for something that needs the
689
744
  // scheduler: a pure-microtask stall with no trap and no rejection.
690
745
  // Observed on `async/async-calls-sync.wast` the moment site 1 became the
691
- // first lit suspension site (M2 phase 3l): turn N serviced a promise that
746
+ // first lit suspension site: turn N serviced a promise that
692
747
  // never settled while three other parked threads and three ready-able
693
748
  // suspension points went unexamined.
694
749
  //
@@ -751,12 +806,12 @@ async function driveAsync(store, done, what) {
751
806
  // set changing: the same activation resumes off an engine
752
807
  // continuation chunk during the probe's macrotask turn (jspi
753
808
  // pin (j) — a sync-completing Suspending import still defers its
754
- // continuation), runs, and re-parks through the A1 arm, which
809
+ // continuation), runs, and re-parks through the suspending mark arm, which
755
810
  // registers a fresh `pendingHostCalls` entry. The activation
756
811
  // promise never settled and `awaiting` membership is unchanged,
757
812
  // but the park is externally wakeable now — the verdict's own
758
813
  // precondition (`pendingHostCalls.size === 0`) no longer holds.
759
- // Observed on wasi-shims' A5 poll (sync fast path): probe sampled
814
+ // Observed on wasi-shims' stream/future round-trip poll (sync fast path): probe sampled
760
815
  // hostCalls=0 between a settled park and the next one, then
761
816
  // trapped a live workload with hostCalls=1. Re-check ⇒ re-probe.
762
817
  // Likewise a SERVICEABLE settled entry (issue #156): dispatching
@@ -801,7 +856,7 @@ async function driveAsync(store, done, what) {
801
856
  // snapshot's `parked[0]` is then `undefined` — the exact check-then-act
802
857
  // shape that made the host pump's copy of this loop throw
803
858
  // `TypeError: ... (reading 'awaiting')` into `store.hostFailure`, where
804
- // it poisoned a later unrelated call (C0 finding R-2). Nothing to
859
+ // it poisoned a later unrelated call via check-then-act on `store.hostFailure`. Nothing to
805
860
  // service ⇒ go back to the top and re-evaluate `done`.
806
861
  // Same re-check for the settled queue, and for the same reason: the
807
862
  // probe's macrotask turn can land a fresh, SERVICEABLE activation tail
@@ -820,7 +875,7 @@ async function driveAsync(store, done, what) {
820
875
  // never be held hostage by it. The claimed thread's promise may only be
821
876
  // settleable by further scheduler progress (a promising-wrapped nested
822
877
  // activation whose own suspension points this loop must still resume);
823
- // blocking on it alone is the pure-microtask stall of M2 phase 3l.
878
+ // blocking on it alone is the pure-microtask stall described above.
824
879
  // Same exclusion as the probe (issue #156): a thread whose tail is
825
880
  // already queued in `store.settled` must not be raced — its tag is
826
881
  // settled, so it re-wins instantly and livelocks the event loop,
@@ -828,12 +883,17 @@ async function driveAsync(store, done, what) {
828
883
  const queued = new Set(store.settled.map((s) => s.t));
829
884
  const parked = [...store.awaiting].filter((t) => !queued.has(t));
830
885
  if (parked.length === 0) {
831
- // Every awaiting thread's settle is deferred on a non-enterable
832
- // instance. The way out is the lock holder finishing, and the only
833
- // await-spanning host-entry lock is the async-dtor bracket, which
834
- // registers in `pendingHostCalls` — so park on those.
886
+ // UNREACHABLE BY CONSTRUCTION. `parked` is `store.awaiting` minus
887
+ // the threads whose tails are already queued in `store.settled`, and
888
+ // we only get here with `awaiting` non-empty and
889
+ // `hasServiceableSettled()` false which means the settled queue is
890
+ // EMPTY, so nothing was excluded. Retained as a wedge detector, not
891
+ // as expected behavior.
835
892
  if (store.pendingHostCalls.size > 0) {
836
- await Promise.race([...store.pendingHostCalls]).catch(() => { });
893
+ await Promise.race([
894
+ ...store.pendingHostCalls,
895
+ armDriverArrival(store),
896
+ ]).catch(() => { });
837
897
  continue;
838
898
  }
839
899
  // Per the issue #156 analysis this is unreachable (a spanning lock
@@ -858,18 +918,55 @@ async function driveAsync(store, done, what) {
858
918
  // `SuspensionPoint.resume`) is what carries it, and dropping an entry
859
919
  // that names a thread already gone from the set is a no-op.
860
920
  //
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);
921
+ // ONLY ITS OWN ENTRY (issue #158): the `finally` must drop the entry
922
+ // THIS loop added and nothing else. A guest-synchronous delivery during
923
+ // the await takes a fresh entry of its own, and clearing that one here
924
+ // would re-open early the very window it exists to close which is why
925
+ // the gate is a set of entries rather than a single slot.
926
+ //
927
+ // SOLE DRIVER ONLY, AND ONLY UNTIL ONE ARRIVES (issue #239). The entry
928
+ // is a claim over a window this loop cannot bound: the race settles when
929
+ // the HOST answers, which may be never. As a store-wide scheduling gate
930
+ // (`Store.tick` refuses; every driver yields at its top) that is a wedge
931
+ // the moment a second driver exists — it spins at the top of its own
932
+ // loop and dies at the 10,000-hop assert in ~311ms, an internal-bug
933
+ // detector firing on a perfectly ordinary suspended guest. Two concurrent
934
+ // export calls with one slow suspending import were enough; the reported
935
+ // shape was a detached guest task cancelling an in-flight import, which
936
+ // parks mid-frame with no export call outstanding and leaves the
937
+ // settlement pump holding this entry.
938
+ //
939
+ // What the entry protects — "the engine may run `chosen`'s wasm during
940
+ // this await" — it protects by refusing OTHER `Store.tick` callers, and
941
+ // this loop is not one of them while it awaits. The tick callers that
942
+ // can reach a store mid-race are another `driveAsync` loop and
943
+ // `HostActivity.pump`'s synchronous drain (exec/host_streams.ts) — the
944
+ // latter is not gated by driver depth, so scoping the entry to "sole
945
+ // driver" does hand it a window an unscoped entry would close at
946
+ // depth >= 2.
947
+ // What holds regardless is the invariant the `driverDepth` note names:
948
+ // a genuine resumption is preceded by `SuspensionPoint.resume`'s OWN
949
+ // entry (jspi/bridge.ts, minted before the settle), and every
950
+ // resumption site here re-checks membership and promise identity
951
+ // synchronously — mechanisms (a) and (b), which is where that note
952
+ // already puts the weight.
953
+ const sole = storeDriverDepth(store) === 1;
954
+ if (sole)
955
+ store.addPendingResumption(chosen);
867
956
  let winner;
868
957
  try {
869
- winner = await Promise.race([chosenTag, ...others]);
958
+ // `armDriverArrival` rides the race for every driver, not just the one
959
+ // holding the entry: waking on a new arrival is also how a fallback
960
+ // pump reaches its next `done()` — i.e. its stand-down — promptly.
961
+ winner = await Promise.race([
962
+ chosenTag,
963
+ ...others,
964
+ armDriverArrival(store),
965
+ ]);
870
966
  }
871
967
  finally {
872
- store.removePendingResumption(chosen);
968
+ if (sole)
969
+ store.removePendingResumption(chosen);
873
970
  }
874
971
  // Resume whichever thread actually settled -- not necessarily the one we
875
972
  // claimed. Resuming only the claimed thread would spin: its promise may
@@ -884,13 +981,7 @@ async function driveAsync(store, done, what) {
884
981
  // true again but the tag's value belongs to a settlement this thread
885
982
  // has already consumed. Compare promise identity too.
886
983
  if (winner !== null && store.awaiting.has(winner.t) &&
887
- winner.t.awaiting === winner.p &&
888
- // Dispatch guard, the same predicate `Store.serviceSettled` uses
889
- // (issue #156): never resume into an instance that is not
890
- // host-enterable. The entry is (also) queued in `store.settled` by
891
- // `noteAwaiting`'s continuation, and `serviceSettled` owns it once
892
- // the lock releases.
893
- dispatchableTail(winner.t)) {
984
+ winner.t.awaiting === winner.p) {
894
985
  winner.t.resumeWith(winner.value, winner.failure);
895
986
  }
896
987
  continue;
@@ -906,7 +997,18 @@ async function driveAsync(store, done, what) {
906
997
  // not ours — this is genuine, unavoidable nondeterminism at the boundary
907
998
  // (the reference has the same freedom in `Store.tick`). Everything
908
999
  // *inside* the component stays deterministic per scheduler.ts.
909
- await Promise.race([...store.pendingHostCalls]).catch(() => { });
1000
+ //
1001
+ // The driver-arrival one-shot rides here too. This is the routine park of
1002
+ // a quiet guest with a real host call outstanding — no speculative entry
1003
+ // is held, so there is no wedge to break, but a fallback pump parked here
1004
+ // would otherwise not reach its `done()` (i.e. its stand-down) until the
1005
+ // HOST answered, leaving two loops interleaving `serviceSettled`/`tick`
1006
+ // for that whole window. That interleaving is what the `driverDepth` note
1007
+ // above calls out as bad for throughput and blame.
1008
+ await Promise.race([
1009
+ ...store.pendingHostCalls,
1010
+ armDriverArrival(store),
1011
+ ]).catch(() => { });
910
1012
  }
911
1013
  }
912
1014
  finally {
@@ -945,26 +1047,35 @@ function takeHostFailure(store) {
945
1047
  * needs genuine wasm-frame suspension: `needsJspi`, at the precise point.
946
1048
  */
947
1049
  /**
948
- * The plain-entered variant of a `[constructor]` export in jspi mode,
949
- * attached to the promising-wrapped lifted function under this symbol.
1050
+ * The plain-entered variant of a **sync-typed** lifted export in jspi mode,
1051
+ * attached to the promising-wrapped lifted function under this symbol
1052
+ * (contracts/embedder-api.md §"Functions and async", §"Functions and async").
1053
+ *
1054
+ * In jspi mode every promising-wrapped entry returns a Promise even when the
1055
+ * activation completes without suspending (jspi pin (e)). Some host contexts
1056
+ * cannot use a Promise no matter how promptly it resolves, so each sync-typed
1057
+ * export carries a second lifted function whose ENTRY is plain (unwrapped):
1058
+ * a guest activation that completes synchronously — the overwhelmingly common
1059
+ * case for sync-typed WIT — delivers its results synchronously through it.
950
1060
  *
951
- * A WIT constructor is surfaced as a JS class constructor
952
- * (contracts/embedder-api.md §"Resources"), and a JS constructor cannot
953
- * await — but in jspi mode every promising-wrapped entry returns a Promise
954
- * even when the activation completes without suspending (jspi pin (e)). So
955
- * constructor exports carry a second lifted function whose ENTRY is plain
956
- * (unwrapped): a constructor that completes synchronously — the
957
- * overwhelmingly common case; WIT constructors are always sync-typed —
958
- * returns its rep synchronously through it.
1061
+ * Two consumers, one mechanism:
959
1062
  *
960
- * The cost is confined to genuinely-suspending constructors, which no JS
961
- * host can surface as `new` anyway: a blocking built-in reached through the
962
- * plain entry signals `NeedsJspi` (a capability error, instance left
963
- * enterable), and a Suspending-wrapped host import reached from the
964
- * unwrapped frame fails as a trap. Both name the constructor rather than
965
- * silently deadlocking.
1063
+ * * **resource constructors** a WIT constructor is surfaced as a JS class
1064
+ * constructor (§"Resources") and a JS constructor cannot await, so the
1065
+ * embedder layer reads this symbol unconditionally for `[constructor]`
1066
+ * exports;
1067
+ * * **the embedder `sync()` adapter** the explicit per-use
1068
+ * synchronous view of any sync-typed export.
1069
+ *
1070
+ * The cost is confined to genuinely-suspending activations: a blocking
1071
+ * built-in reached through the plain entry signals `NeedsJspi` (a capability
1072
+ * error, instance left enterable), and a `Suspending`-wrapped host import
1073
+ * reached from the unwrapped frame fails as a trap. Both name the export
1074
+ * rather than silently deadlocking. A call made while the instance has
1075
+ * hop-parked activations refuses with `SyncEntryBusy` before entering
1076
+ * (`refuseOnEntryHops` below).
966
1077
  */
967
- export const CONSTRUCTOR_SYNC_ENTRY = Symbol("polyengine.constructorSyncEntry");
1078
+ export const SYNC_ENTRY = Symbol("polyengine.syncEntry");
968
1079
  export function createLiftedFunction(input) {
969
1080
  const { name, ft, opts, core, stats, trapState, syncCallStack, allInstances, } = input;
970
1081
  const inst = opts.instance;
@@ -1002,19 +1113,16 @@ export function createLiftedFunction(input) {
1002
1113
  trapState.pending = undefined;
1003
1114
  // Depth of the sync-call scope stack on entry; see the `finally` below.
1004
1115
  const syncCallDepth = syncCallStack?.length ?? 0;
1005
- // Reference `Store.lift` (line 578): the host is the caller, so the
1006
- // entering set is the callee's `self_and_ancestors()`.
1007
- // On refusal, distinguish the corpse from the crowd: a poisoned
1008
- // instance's refusal names the original trap (polyengine#145 ask 1).
1009
- if (!inst.mayEnterFrom(null)) {
1010
- trap(withPoisonCause(inst, `cannot enter component instance ${inst.index} (reentrance forbidden)`));
1116
+ // Reference `Store.lift` (@ 2f13265) runs `canon_lift` with NO gate
1117
+ // (CM#705), so host entry into a live instance is valid. What this adds
1118
+ // is polyengine's per-instance
1119
+ // poisoning divergence a poisoned instance is a corpse, and its refusal
1120
+ // names the original trap (polyengine#145 ask 1).
1121
+ {
1122
+ const refusal = entryRefusal(inst, null, `cannot enter component instance ${inst.index}`);
1123
+ if (refusal !== null)
1124
+ trap(refusal);
1011
1125
  }
1012
- // The set this entry locked (definitions.py `ComponentInstance.enter_from`
1013
- // iterates `entering_set`). Remembered so a trap can leave exactly these
1014
- // locked and no others.
1015
- const enteredSet = inst.enteringSet(null);
1016
- inst.enterFrom(null);
1017
- let entered = true;
1018
1126
  let completed = false;
1019
1127
  let resolved = null;
1020
1128
  let resolvedSeen = false;
@@ -1080,44 +1188,30 @@ export function createLiftedFunction(input) {
1080
1188
  // definition; assert that resting state rather than leaving the
1081
1189
  // component bricked.
1082
1190
  //
1083
- // The *entered* instances are excluded: they are poisoned by this trap
1084
- // (see `poison` below) and must stay exactly as the trap left them.
1085
- // Restoring their `may_leave` would be tidying the state of an instance
1086
- // that is no longer allowed to run at all.
1191
+ // The ENTERED instance is excluded: it is poisoned by this trap (see
1192
+ // `poison` below) and must stay exactly as the trap left it. Restoring
1193
+ // its `may_leave` would be tidying the state of an instance that is no
1194
+ // longer allowed to run at all.
1087
1195
  for (const i of allInstances?.() ?? []) {
1088
- if (!enteredSet.has(i)) {
1196
+ if (i !== inst) {
1089
1197
  i.mayLeave = true;
1090
1198
  }
1091
1199
  }
1092
1200
  };
1093
- const leave = () => {
1094
- if (!entered)
1095
- return;
1096
- entered = false;
1097
- inst.leaveTo(null);
1098
- };
1099
1201
  /**
1100
- * A trap escaped the task: **do not** release the reentrance lock.
1101
- *
1102
- * definitions.py `Store.lift` (line 578) is
1103
- *
1104
- * ```python
1105
- * trap_if(not inst.may_enter_from(caller))
1106
- * inst.enter_from(caller)
1107
- * on_cancel = canon_lift(...) # <-- a Trap propagates out of here
1108
- * inst.leave_to(caller) # <-- and so this never runs
1109
- * ```
1202
+ * A trap escaped the task: mark the instance poisoned.
1110
1203
  *
1111
- * so a trapping task leaves every instance it entered with
1112
- * `may_enter == False` permanently. That is the Component Model's
1113
- * "poisoning": a component that trapped is not in a known state, so it may
1114
- * never be entered again, and the next call reports `cannot enter
1115
- * component instance`. `test/async/builtin-trap-poisons-instance.wast`
1116
- * asserts exactly this, twice.
1204
+ * polyengine's NAMED DIVERGENCE. definitions.py has no notion of a
1205
+ * post-trap instance at all a Trap is the end of the world — and
1206
+ * wasmtime's answer is to poison the whole store. This runtime keeps the
1207
+ * component graph alive and buries only the instance that trapped: it is
1208
+ * not in a known state, so it may never be entered again, and the next
1209
+ * call reports `cannot enter component instance` with the recorded cause
1210
+ * appended (polyengine#145 ask 1).
1211
+ * `test/async/builtin-trap-poisons-instance.wast` asserts exactly this,
1212
+ * twice; the marker (`notifyInstancePoisoned`) is the whole mechanism.
1117
1213
  *
1118
- * Only the entered set is affected; sibling instances stay usable, which
1119
- * is why the lock is released per-instance rather than by poisoning a
1120
- * whole store the way wasmtime does.
1214
+ * Only `inst` is affected; sibling instances stay usable.
1121
1215
  *
1122
1216
  * Poisoned instances can never rendezvous again, so their handle tables'
1123
1217
  * live stream/future ends are retired here (#66): parked host operations
@@ -1125,21 +1219,10 @@ export function createLiftedFunction(input) {
1125
1219
  * lets the embedder layer reject them loudly.
1126
1220
  */
1127
1221
  const poison = (e) => {
1128
- entered = false; // consumed: the lock is now permanent
1129
- // ...for the leaf. The synthetic per-instantiation root (plan v3
1130
- // amendment 4) is in `enteredSet` too, and leaving IT locked would
1131
- // poison every instance of the component — exactly the store-wide
1132
- // behaviour the paragraph above says this runtime deliberately does not
1133
- // have. Released; see `releaseSyntheticRootOnPoison` in task/mod.ts.
1134
- inst.releaseSyntheticRootOnPoison();
1135
1222
  // Through the seam (not retireInstanceAsyncEnds directly) so the
1136
1223
  // poison marker is recorded too — `Thread.resumeWith` retires this
1137
1224
  // instance's late settles against it instead of assert-cascading.
1138
- for (const i of enteredSet) {
1139
- if (i.isSyntheticRoot)
1140
- continue;
1141
- notifyInstancePoisoned(i, e);
1142
- }
1225
+ notifyInstancePoisoned(inst, e);
1143
1226
  };
1144
1227
  /**
1145
1228
  * Is `e` a *capability* signal rather than a genuine trap?
@@ -1148,9 +1231,9 @@ export function createLiftedFunction(input) {
1148
1231
  * not "the component faulted". Poisoning on them is wrong on the
1149
1232
  * reference's own terms: the operation they stand in for — a synchronous
1150
1233
  * stream copy, `waitable-set.wait`, a blocking cross-component call —
1151
- * *blocks and then completes* in definitions.py. `Store.lift` reaches
1152
- * `leave_to` in every one of those executions, so the instance stays
1153
- * enterable. Poisoning would attribute a permanent fault to a component
1234
+ * *blocks and then completes* in definitions.py. Every one of those
1235
+ * executions returns normally there, so the instance stays healthy.
1236
+ * Poisoning would attribute a permanent fault to a component
1154
1237
  * that, on a complete runtime, is perfectly healthy — and it cascades:
1155
1238
  * one unsupported operation made every later call on that instance report
1156
1239
  * `cannot enter component instance`, which is neither our real behaviour
@@ -1158,8 +1241,6 @@ export function createLiftedFunction(input) {
1158
1241
  *
1159
1242
  * What unwinding must still do on this path, and what it must not:
1160
1243
  *
1161
- * - MUST release the reentrance lock (`leave`) — the call is over and no
1162
- * activation of this instance survives it.
1163
1244
  * - MUST unwind the FACT sync-call scopes and restore `may_leave`
1164
1245
  * (`unwind`), for exactly the reasons it does after a trap: a bail-out
1165
1246
  * mid-adapter skips `exit-sync-call` and the `may_leave` restore, and
@@ -1203,17 +1284,10 @@ export function createLiftedFunction(input) {
1203
1284
  }
1204
1285
  catch (e) {
1205
1286
  unwind();
1206
- if (isCapabilitySignal(e))
1207
- leave();
1208
- else
1287
+ if (!isCapabilitySignal(e))
1209
1288
  poison(e);
1210
1289
  throw e;
1211
1290
  }
1212
- // The reentrance gate is released here, before the store is pumped:
1213
- // `Store.tick` re-enters each waiting thread's instance itself
1214
- // (`enter_from(None)` / `leave_to(None)`), exactly as in the reference,
1215
- // where `lift_and_run` ticks after `store.invoke` has returned.
1216
- leave();
1217
1291
  let pending;
1218
1292
  try {
1219
1293
  // Completion is "the task resolved AND its threads have drained", not
@@ -1229,7 +1303,7 @@ export function createLiftedFunction(input) {
1229
1303
  // abandoned mid-loop — leaking the exclusive thread and its table slot.
1230
1304
  // The lifted call is over when the task has resolved AND this task's
1231
1305
  // activation is no longer mid-wasm-call. Those are two different events
1232
- // and both matter (M2 phase 3e):
1306
+ // and both matter:
1233
1307
  //
1234
1308
  // * "task resolved" alone abandons a still-running activation. Under
1235
1309
  // JSPI the guest calls `task.return` while suspended, so returning
@@ -1268,6 +1342,14 @@ export function createLiftedFunction(input) {
1268
1342
  });
1269
1343
  };
1270
1344
  return (...hostArgs) => {
1345
+ // sync() arm 2: the synchronous variant refuses rather than deferring, and
1346
+ // does so FIRST — before the arity check's sibling logic reaches
1347
+ // `invokeNow` — because the refusal must be pre-enter to stay
1348
+ // non-poisoning. See `refuseOnEntryHops` above for why the mode-keyed
1349
+ // gate below cannot cover this variant.
1350
+ if (input.refuseOnEntryHops && entryHopThreads(store, inst).length > 0) {
1351
+ throw new SyncEntryBusy(name);
1352
+ }
1271
1353
  if (hostArgs.length !== ft.params.length) {
1272
1354
  throw new TypeError(`${name}: expected ${ft.params.length} argument(s), got ${hostArgs.length}`);
1273
1355
  }
@@ -1275,11 +1357,10 @@ export function createLiftedFunction(input) {
1275
1357
  //
1276
1358
  // A promising-wrapped entry settles a microtask AFTER the guest's core
1277
1359
  // call returns, even when nothing suspended (jspi pin (j)) — so there
1278
- // is a hop between core return and the host-side result LIFT, and the
1279
- // reentrance bracket has already been released by then (`leave()` runs
1280
- // when the first segment parks). In the reference no such window
1281
- // exists: `canon_lift` for sync options runs core + lift atomically
1282
- // inside one entered bracket. Admitting another host call into the
1360
+ // is a hop between core return and the host-side result LIFT, with
1361
+ // nothing holding the instance against another host entry. In the
1362
+ // reference no such window exists: `canon_lift` for sync options runs
1363
+ // core + lift atomically. Admitting another host call into the
1283
1364
  // window lets a full guest turn mutate the memory the pending lift
1284
1365
  // will read — observed as `Trap: list too long` lifting the wosh
1285
1366
  // engine's `tick` (`list<list<u8>>`) after a concurrent `feed-keys`
@@ -1381,27 +1462,13 @@ function dtorOptions(instance) {
1381
1462
  * callee = inst.store.lift(dtor, ft, opts, rt.impl)
1382
1463
  * ```
1383
1464
  *
1384
- * Before #160 the host-initiated path (embedder `drop()`, the GC backstop,
1385
- * `dropOwn`) hand-rolled the bracket in cabi/handles.ts `callDtorGated`: a
1386
- * bare call to the dtor with `enterFrom(null)` HELD across the returned
1387
- * promise. Three defects followed from having no Task/Thread behind the
1388
- * activation:
1389
- *
1390
- * - **#160 itself**: the held bracket left the impl instance non-enterable,
1391
- * so `Store.tick`'s enterability filter (#155) could never resume a
1392
- * suspension point belonging to the dtor's own activation. The completion
1393
- * promise sat in `pendingHostCalls` looking like external work, and every
1394
- * driver parked on it forever.
1395
- * - it was the runtime's only `enterFrom(null)` bracket spanning an await —
1396
- * the macro-scale reachability window of the #156 class, through which a
1397
- * sibling instance looked non-enterable from the synthetic root.
1398
- * - built-ins reached inside the dtor had no ambient task (`currentTask()`
1399
- * → `PendingCapability`, or a foreign-task misattribution, the #24 class).
1400
- *
1401
- * Under the lift harness all three go away structurally: the activation has a
1402
- * real `Task` + implicit `Thread`, the entry bracket is released when the
1403
- * first segment parks (`leave()` before `drive`), and settled tails flow
1404
- * through `serviceSettled` like any other lifted sync call.
1465
+ * The host-initiated paths (embedder `drop()`, the GC backstop, `dropOwn`)
1466
+ * route through this harness rather than calling the dtor bare, because the
1467
+ * activation then has a real `Task` + implicit `Thread`: built-ins reached
1468
+ * inside the dtor are well-attributed (a bare call leaves `currentTask()`
1469
+ * with no ambient task — `PendingCapability`, or a foreign-task
1470
+ * misattribution, the #24 class), and settled tails flow through
1471
+ * `serviceSettled` like any other lifted sync call.
1405
1472
  *
1406
1473
  * The returned function takes the rep and returns either `undefined` (the
1407
1474
  * activation completed synchronously — the overwhelmingly common case) or a
@@ -1487,7 +1554,7 @@ export function hostDtorCall(rt, rep) {
1487
1554
  * This is the whole of the jspi entry seam. In **plain** mode the entry is not
1488
1555
  * `promising`-wrapped, `callCore` returns core values, and this returns them
1489
1556
  * without yielding — no await, no Promise allocation, the identical
1490
- * synchronous path M1 shipped. In **jspi** mode the entry *is* wrapped, so the
1557
+ * synchronous path plain mode always used. In **jspi** mode the entry *is* wrapped, so the
1491
1558
  * call returns a Promise (jspi pin (e)) and we park the thread on it via the
1492
1559
  * `awaitValue` block request; the driving loop resumes us with the values, or
1493
1560
  * throws the rejection in (a post-resume trap).
@@ -1626,7 +1693,7 @@ function* liftBody(input) {
1626
1693
  * (`thread.wait_until(subtask.resolved)`, line 2286), so: `needsJspi`.
1627
1694
  */
1628
1695
  export function createLoweredImport(input) {
1629
- const { name, ft, opts, hostFn, stats, mode, suspendable } = input;
1696
+ const { name, ft, opts, hostFn, stats, mode, suspendable, deferCancel, abortable, } = input;
1630
1697
  const inst = opts.instance;
1631
1698
  const store = inst.store;
1632
1699
  const computed = flattenFunctype(cabiOptions(opts), ft, "lower");
@@ -1677,22 +1744,42 @@ export function createLoweredImport(input) {
1677
1744
  // definitions.py assigns the callee's `OnCancel` here:
1678
1745
  // `subtask.on_cancel = callee(on_start, on_resolve, caller = ...)`
1679
1746
  //
1680
- // A host import is a plain JS function and offers no cancellation
1681
- // channel there is nothing to forward a request to. The faithful model
1682
- // is therefore a handler that *accepts and ignores* the request, which is
1683
- // exactly what the reference permits: `canon_subtask_cancel` (line 2469)
1684
- // calls `on_cancel` and then re-checks `subtask.resolved()`; a callee that
1685
- // declines to cancel promptly leaves the subtask unresolved, and the async
1686
- // form returns BLOCKED while the sync form waits. The subtask still
1687
- // resolves normally when the promise settles cancellation is a request,
1688
- // not a guarantee.
1747
+ // The `OnCancel` is the CALLEE's to supply: `Store.invoke` takes it back
1748
+ // from the callee it invoked (`on_cancel = f(on_start, on_resolve, caller
1749
+ // = None)`, definitions.py line 572), i.e. the reference expects the
1750
+ // embedding to hand back the cancellation behaviour of whatever it is
1751
+ // hosting. A wasmtime host gets a real one for free — dropping a Rust
1752
+ // future IS cancellation. A JS Promise has no such channel, so polyengine
1753
+ // answers on the host's behalf; §"Functions and async" makes the DEFAULT answer the
1754
+ // reference's prompt-cancel host (`on_cancel = () => on_resolve(None)`),
1755
+ // installed by the async arm below.
1756
+ //
1757
+ // The no-op assigned HERE is only the placeholder for paths where
1758
+ // `subtask.cancel` is unreachable, so no answer can ever be demanded of
1759
+ // it: an eagerly-resolving callee never mints a subtask handle (the
1760
+ // fast-path return below is a bare state), and a sync-typed import's suspending mark
1761
+ // park never mints one either. It is also the FINAL handler for a
1762
+ // `deferCancel()`-branded import — accept and ignore, the pre-cancellation discard
1763
+ // behaviour, now per-declaration.
1689
1764
  //
1690
1765
  // Leaving `on_cancel` null instead made a *legal* `subtask.cancel` crash
1691
1766
  // with an internal AssertionError, which is neither reference behaviour
1692
1767
  // nor a sanctioned incompleteness signal.
1693
1768
  subtask.onCancel = () => { };
1769
+ // abortable() (contracts/embedder-api.md §"Functions and async"): a marked import
1770
+ // is handed a fresh `AbortSignal` after its WIT-declared parameters. The
1771
+ // mark controls the SIGNATURE UNCONDITIONALLY — a marked function receives
1772
+ // a signal on every call, including the paths where it can never fire
1773
+ // (sync-typed, eager resolve, `deferCancel`) — so the host's arity is a
1774
+ // property of its declaration, not of how a particular call happened to
1775
+ // go. `new AbortController()` is evaluated only for marked imports, which
1776
+ // keeps bare engine shells with no `AbortController` off this path for the
1777
+ // whole unmarked corpus.
1778
+ const controller = abortable ? new AbortController() : null;
1694
1779
  const args = onStart();
1695
- const raw = hostFn(...args);
1780
+ const raw = controller === null
1781
+ ? hostFn(...args)
1782
+ : hostFn(...args, controller.signal);
1696
1783
  const toResults = (v) => ft.results.length === 0 ? [] : [v];
1697
1784
  if (isPromiseLike(raw)) {
1698
1785
  if (!opts.async) {
@@ -1703,8 +1790,9 @@ export function createLoweredImport(input) {
1703
1790
  // Suspending wrap is applied per-declaration (`importValue`), so an
1704
1791
  // unmarked import physically cannot suspend, whatever the mode.
1705
1792
  //
1706
- // A capability signal is expressly NON-poisoning (amendment 2, #91
1707
- // scope clarification): the caller keeps running, so the borrows
1793
+ // A capability signal is expressly NON-poisoning (the
1794
+ // trap-unwind/lender-release obligation, contracts/intrinsics.md §A):
1795
+ // the caller keeps running, so the borrows
1708
1796
  // `onStart` lifted into this subtask must be discharged here or
1709
1797
  // its lenders stay elevated forever and later `resource.drop`s
1710
1798
  // trap "handle still lent out" on a healthy instance (found
@@ -1720,7 +1808,7 @@ export function createLoweredImport(input) {
1720
1808
  `only park the frame when declared with suspending() ` +
1721
1809
  `(contracts/embedder-api.md §"Functions and async")`);
1722
1810
  }
1723
- // The park (A1): the reference's plain, NON-cancellable wait — a
1811
+ // The park: the reference's plain, NON-cancellable wait — a
1724
1812
  // cancel request against the caller stays pending-cancel and is
1725
1813
  // delivered at its next cancellable wait, exactly as for any other
1726
1814
  // mid-frame block. The instance-entry gate stays HELD across the park
@@ -1764,8 +1852,8 @@ export function createLoweredImport(input) {
1764
1852
  // * produce SUCCESS -> `onResolve` + `deliverResolve` release the
1765
1853
  // lenders; the `onSettled` backstop below observes
1766
1854
  // `resolveDelivered()` and is a no-op.
1767
- // * produce THROW -> exempt-by-poisoning under amendment 2
1768
- // (contracts/intrinsics.md v0.2 §2: release is owed only on exits
1855
+ // * produce THROW -> exempt under the trap-unwind/lender-release
1856
+ // obligation (contracts/intrinsics.md §A: release is owed only on exits
1769
1857
  // that do NOT poison the caller). Every rejection that reaches
1770
1858
  // this park is a poisoning trap in the CALLER's own frame:
1771
1859
  // branded `ComponentException`s on fallible imports were already resolved
@@ -1817,6 +1905,16 @@ export function createLoweredImport(input) {
1817
1905
  }
1818
1906
  const promise = Promise.resolve(raw).then((v) => {
1819
1907
  store.pendingHostCalls.delete(promise);
1908
+ // cancellation discard: the subtask may already be resolved when the host promise
1909
+ // settles — the discard `onCancel` below resolved it
1910
+ // CANCELLED_BEFORE_RETURNED (the only pre-settle resolver on this
1911
+ // arm). The value has no addressee, and `onResolve` would run
1912
+ // straight into its `state === STARTED` assert ("on_resolve on a
1913
+ // subtask that never started") and park that AssertionError on
1914
+ // `store.hostFailure`, poisoning whatever unrelated embedder call
1915
+ // came next.
1916
+ if (subtask.resolved())
1917
+ return;
1820
1918
  try {
1821
1919
  onResolve(toResults(v));
1822
1920
  }
@@ -1825,9 +1923,64 @@ export function createLoweredImport(input) {
1825
1923
  }
1826
1924
  }, (e) => {
1827
1925
  store.pendingHostCalls.delete(promise);
1926
+ // Same guard, different reason: a rejection of a RENOUNCED call is
1927
+ // not a host failure. The guest cancelled and was told so; surfacing
1928
+ // the rejection would fail an unrelated later call with the error of
1929
+ // an operation nobody is waiting for.
1930
+ if (subtask.resolved())
1931
+ return;
1828
1932
  store.hostFailure = e;
1829
1933
  });
1830
1934
  store.pendingHostCalls.add(promise);
1935
+ if (!deferCancel) {
1936
+ // cancellation discard DISCARD (contracts/embedder-api.md §"Functions and async";
1937
+ // polyengine#241) — the reference's prompt-cancel host,
1938
+ // `on_cancel = () => on_resolve(None)` (definitions.py canon_lower's
1939
+ // null branch, line ~2267).
1940
+ //
1941
+ // This runs synchronously inside `canon_subtask_cancel`, which already
1942
+ // set `cancellationRequested` before calling us (the assert in
1943
+ // `onResolve`'s null branch relies on that ordering). `onResolve(null)`
1944
+ // arms the SUBTASK event — a delivery-time thunk — and resolves
1945
+ // CANCELLED_BEFORE_RETURNED, so the built-in's `finish()` tail consumes
1946
+ // the event, `deliverResolve` releases the lenders (the #106 class,
1947
+ // discharged exactly as a RETURNED delivery would), and BOTH cancel
1948
+ // forms return the state without blocking. The null path lowers
1949
+ // nothing, so there is no realloc re-entry from inside a built-in.
1950
+ //
1951
+ // The renounced call can no longer wake the guest, so it must stop
1952
+ // counting as externally-wakeable for the driver's deadlock probe:
1953
+ // deregister it NOW. (The settle continuation above also deletes;
1954
+ // `Set.delete` is idempotent.)
1955
+ subtask.onCancel = () => {
1956
+ store.pendingHostCalls.delete(promise);
1957
+ onResolve(null);
1958
+ if (controller !== null) {
1959
+ // abortable(): tell the host its result was discarded, so it can stop the
1960
+ // underlying operation — clear a timer, abort a fetch, close a
1961
+ // dial. Reachable only from this arm by construction: a
1962
+ // `deferCancel()` import never discards, so its signal never
1963
+ // fires.
1964
+ //
1965
+ // Deferred one microtask. This closure runs SYNCHRONOUSLY inside
1966
+ // `canon_subtask_cancel`, i.e. inside a live guest activation, and
1967
+ // host abort listeners must not execute there — that is the
1968
+ // issue-#24 attribution class, plus arbitrary re-entrancy into a
1969
+ // guest mid-built-in. `Promise.resolve().then`, not
1970
+ // `queueMicrotask`: the latter does not exist in bare engine
1971
+ // shells (see jspi/bridge.ts's SENTINEL_TICK note).
1972
+ //
1973
+ // The resulting order is: the guest observes
1974
+ // CANCELLED_BEFORE_RETURNED first, the host observes the abort a
1975
+ // tick later. Any settlement the abort provokes (typically an
1976
+ // `AbortError` rejection) arrives at the settle continuation above
1977
+ // with the subtask already resolved, so it lands on the cancellation discard
1978
+ // resolved-subtask guards and is discarded like any other late
1979
+ // settlement — never a `store.hostFailure`.
1980
+ Promise.resolve().then(() => controller.abort());
1981
+ }
1982
+ };
1983
+ }
1831
1984
  }
1832
1985
  else {
1833
1986
  onResolve(toResults(raw));