@polyengine/runtime 0.5.1 → 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 (81) 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 +132 -37
  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 +123 -161
  28. package/esm/exec/executor.js +37 -25
  29. package/esm/exec/host_streams.js +31 -31
  30. package/esm/intrinsics/async_builtins.js +15 -7
  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 +5 -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 +55 -44
  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/plan/format.d.ts +11 -10
  75. package/types/plan/loader.d.ts +2 -2
  76. package/types/shim/translator.d.ts +2 -2
  77. package/types/task/mod.d.ts +26 -97
  78. package/types/task/scheduler.d.ts +81 -62
  79. package/types/task/streams.d.ts +23 -38
  80. package/types/task/subtask.d.ts +2 -2
  81. 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
@@ -680,11 +680,12 @@ async function driveAsync(store, done, what) {
680
680
  // and clearing it before that activation runs re-opens the
681
681
  // mis-attribution window the entry exists to close.
682
682
  //
683
- // PER-STORE (issue #210): this gate used to read a module-global slot, so
684
- // an idle store's driver spun here and died at the hop bound below in
685
- // ~311msmerely because ANOTHER store's guest was dwelling on a slow
686
- // host import. Activations never cross stores; another store's pending
687
- // 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.
688
689
  if (store.hasPendingResumptions()) {
689
690
  traceDrive("driveAsync", store, done, "yield-pending");
690
691
  // Bounded: a pending entry that never dies is an internal bug (every
@@ -742,7 +743,7 @@ async function driveAsync(store, done, what) {
742
743
  // therefore stops the scheduler while waiting for something that needs the
743
744
  // scheduler: a pure-microtask stall with no trap and no rejection.
744
745
  // Observed on `async/async-calls-sync.wast` the moment site 1 became the
745
- // first lit suspension site (M2 phase 3l): turn N serviced a promise that
746
+ // first lit suspension site: turn N serviced a promise that
746
747
  // never settled while three other parked threads and three ready-able
747
748
  // suspension points went unexamined.
748
749
  //
@@ -805,12 +806,12 @@ async function driveAsync(store, done, what) {
805
806
  // set changing: the same activation resumes off an engine
806
807
  // continuation chunk during the probe's macrotask turn (jspi
807
808
  // pin (j) — a sync-completing Suspending import still defers its
808
- // continuation), runs, and re-parks through the A1 arm, which
809
+ // continuation), runs, and re-parks through the suspending mark arm, which
809
810
  // registers a fresh `pendingHostCalls` entry. The activation
810
811
  // promise never settled and `awaiting` membership is unchanged,
811
812
  // but the park is externally wakeable now — the verdict's own
812
813
  // precondition (`pendingHostCalls.size === 0`) no longer holds.
813
- // 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
814
815
  // hostCalls=0 between a settled park and the next one, then
815
816
  // trapped a live workload with hostCalls=1. Re-check ⇒ re-probe.
816
817
  // Likewise a SERVICEABLE settled entry (issue #156): dispatching
@@ -855,7 +856,7 @@ async function driveAsync(store, done, what) {
855
856
  // snapshot's `parked[0]` is then `undefined` — the exact check-then-act
856
857
  // shape that made the host pump's copy of this loop throw
857
858
  // `TypeError: ... (reading 'awaiting')` into `store.hostFailure`, where
858
- // 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
859
860
  // service ⇒ go back to the top and re-evaluate `done`.
860
861
  // Same re-check for the settled queue, and for the same reason: the
861
862
  // probe's macrotask turn can land a fresh, SERVICEABLE activation tail
@@ -874,7 +875,7 @@ async function driveAsync(store, done, what) {
874
875
  // never be held hostage by it. The claimed thread's promise may only be
875
876
  // settleable by further scheduler progress (a promising-wrapped nested
876
877
  // activation whose own suspension points this loop must still resume);
877
- // 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.
878
879
  // Same exclusion as the probe (issue #156): a thread whose tail is
879
880
  // already queued in `store.settled` must not be raced — its tag is
880
881
  // settled, so it re-wins instantly and livelocks the event loop,
@@ -882,12 +883,12 @@ async function driveAsync(store, done, what) {
882
883
  const queued = new Set(store.settled.map((s) => s.t));
883
884
  const parked = [...store.awaiting].filter((t) => !queued.has(t));
884
885
  if (parked.length === 0) {
885
- // Every awaiting thread's settle is deferred on a non-enterable
886
- // instance. The way out is the lock holder finishing, and the only
887
- // await-spanning host-entry lock is the async-dtor bracket, which
888
- // registers in `pendingHostCalls` — so park on those, plus the
889
- // driver-arrival one-shot: every park in this loop races it, so the
890
- // stand-down below is prompt wherever we happen to be waiting.
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.
891
892
  if (store.pendingHostCalls.size > 0) {
892
893
  await Promise.race([
893
894
  ...store.pendingHostCalls,
@@ -917,11 +918,11 @@ async function driveAsync(store, done, what) {
917
918
  // `SuspensionPoint.resume`) is what carries it, and dropping an entry
918
919
  // that names a thread already gone from the set is a no-op.
919
920
  //
920
- // ONLY ITS OWN ENTRY (issue #158): the `finally` used to blanket-clear
921
- // the single global slot, so a guest-synchronous delivery during the
922
- // await — which takes a fresh entry of its own had that entry
923
- // clobbered early, re-opening the window it exists to close. With a set
924
- // we can name exactly what we added.
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.
925
926
  //
926
927
  // SOLE DRIVER ONLY, AND ONLY UNTIL ONE ARRIVES (issue #239). The entry
927
928
  // is a claim over a window this loop cannot bound: the race settles when
@@ -941,13 +942,14 @@ async function driveAsync(store, done, what) {
941
942
  // can reach a store mid-race are another `driveAsync` loop and
942
943
  // `HostActivity.pump`'s synchronous drain (exec/host_streams.ts) — the
943
944
  // latter is not gated by driver depth, so scoping the entry to "sole
944
- // driver" does hand it a window the entry used to close at depth >= 2.
945
+ // driver" does hand it a window an unscoped entry would close at
946
+ // depth >= 2.
945
947
  // What holds regardless is the invariant the `driverDepth` note names:
946
948
  // a genuine resumption is preceded by `SuspensionPoint.resume`'s OWN
947
949
  // entry (jspi/bridge.ts, minted before the settle), and every
948
- // resumption site here re-checks membership, promise identity and
949
- // `dispatchableTail` synchronously — mechanisms (a) and (b), which is
950
- // where that note already puts the weight.
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.
951
953
  const sole = storeDriverDepth(store) === 1;
952
954
  if (sole)
953
955
  store.addPendingResumption(chosen);
@@ -979,13 +981,7 @@ async function driveAsync(store, done, what) {
979
981
  // true again but the tag's value belongs to a settlement this thread
980
982
  // has already consumed. Compare promise identity too.
981
983
  if (winner !== null && store.awaiting.has(winner.t) &&
982
- winner.t.awaiting === winner.p &&
983
- // Dispatch guard, the same predicate `Store.serviceSettled` uses
984
- // (issue #156): never resume into an instance that is not
985
- // host-enterable. The entry is (also) queued in `store.settled` by
986
- // `noteAwaiting`'s continuation, and `serviceSettled` owns it once
987
- // the lock releases.
988
- dispatchableTail(winner.t)) {
984
+ winner.t.awaiting === winner.p) {
989
985
  winner.t.resumeWith(winner.value, winner.failure);
990
986
  }
991
987
  continue;
@@ -1051,26 +1047,35 @@ function takeHostFailure(store) {
1051
1047
  * needs genuine wasm-frame suspension: `needsJspi`, at the precise point.
1052
1048
  */
1053
1049
  /**
1054
- * The plain-entered variant of a `[constructor]` export in jspi mode,
1055
- * 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").
1056
1053
  *
1057
- * A WIT constructor is surfaced as a JS class constructor
1058
- * (contracts/embedder-api.md §"Resources"), and a JS constructor cannot
1059
- * await but in jspi mode every promising-wrapped entry returns a Promise
1060
- * even when the activation completes without suspending (jspi pin (e)). So
1061
- * constructor exports carry a second lifted function whose ENTRY is plain
1062
- * (unwrapped): a constructor that completes synchronously the
1063
- * overwhelmingly common case; WIT constructors are always sync-typed —
1064
- * returns its rep synchronously through it.
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.
1065
1060
  *
1066
- * The cost is confined to genuinely-suspending constructors, which no JS
1067
- * host can surface as `new` anyway: a blocking built-in reached through the
1068
- * plain entry signals `NeedsJspi` (a capability error, instance left
1069
- * enterable), and a Suspending-wrapped host import reached from the
1070
- * unwrapped frame fails as a trap. Both name the constructor rather than
1071
- * silently deadlocking.
1061
+ * Two consumers, one mechanism:
1062
+ *
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).
1072
1077
  */
1073
- export const CONSTRUCTOR_SYNC_ENTRY = Symbol("polyengine.constructorSyncEntry");
1078
+ export const SYNC_ENTRY = Symbol("polyengine.syncEntry");
1074
1079
  export function createLiftedFunction(input) {
1075
1080
  const { name, ft, opts, core, stats, trapState, syncCallStack, allInstances, } = input;
1076
1081
  const inst = opts.instance;
@@ -1108,19 +1113,16 @@ export function createLiftedFunction(input) {
1108
1113
  trapState.pending = undefined;
1109
1114
  // Depth of the sync-call scope stack on entry; see the `finally` below.
1110
1115
  const syncCallDepth = syncCallStack?.length ?? 0;
1111
- // Reference `Store.lift` (line 578): the host is the caller, so the
1112
- // entering set is the callee's `self_and_ancestors()`.
1113
- // On refusal, distinguish the corpse from the crowd: a poisoned
1114
- // instance's refusal names the original trap (polyengine#145 ask 1).
1115
- if (!inst.mayEnterFrom(null)) {
1116
- 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);
1117
1125
  }
1118
- // The set this entry locked (definitions.py `ComponentInstance.enter_from`
1119
- // iterates `entering_set`). Remembered so a trap can leave exactly these
1120
- // locked and no others.
1121
- const enteredSet = inst.enteringSet(null);
1122
- inst.enterFrom(null);
1123
- let entered = true;
1124
1126
  let completed = false;
1125
1127
  let resolved = null;
1126
1128
  let resolvedSeen = false;
@@ -1186,44 +1188,30 @@ export function createLiftedFunction(input) {
1186
1188
  // definition; assert that resting state rather than leaving the
1187
1189
  // component bricked.
1188
1190
  //
1189
- // The *entered* instances are excluded: they are poisoned by this trap
1190
- // (see `poison` below) and must stay exactly as the trap left them.
1191
- // Restoring their `may_leave` would be tidying the state of an instance
1192
- // 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.
1193
1195
  for (const i of allInstances?.() ?? []) {
1194
- if (!enteredSet.has(i)) {
1196
+ if (i !== inst) {
1195
1197
  i.mayLeave = true;
1196
1198
  }
1197
1199
  }
1198
1200
  };
1199
- const leave = () => {
1200
- if (!entered)
1201
- return;
1202
- entered = false;
1203
- inst.leaveTo(null);
1204
- };
1205
1201
  /**
1206
- * A trap escaped the task: **do not** release the reentrance lock.
1207
- *
1208
- * definitions.py `Store.lift` (line 578) is
1209
- *
1210
- * ```python
1211
- * trap_if(not inst.may_enter_from(caller))
1212
- * inst.enter_from(caller)
1213
- * on_cancel = canon_lift(...) # <-- a Trap propagates out of here
1214
- * inst.leave_to(caller) # <-- and so this never runs
1215
- * ```
1202
+ * A trap escaped the task: mark the instance poisoned.
1216
1203
  *
1217
- * so a trapping task leaves every instance it entered with
1218
- * `may_enter == False` permanently. That is the Component Model's
1219
- * "poisoning": a component that trapped is not in a known state, so it may
1220
- * never be entered again, and the next call reports `cannot enter
1221
- * component instance`. `test/async/builtin-trap-poisons-instance.wast`
1222
- * 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.
1223
1213
  *
1224
- * Only the entered set is affected; sibling instances stay usable, which
1225
- * is why the lock is released per-instance rather than by poisoning a
1226
- * whole store the way wasmtime does.
1214
+ * Only `inst` is affected; sibling instances stay usable.
1227
1215
  *
1228
1216
  * Poisoned instances can never rendezvous again, so their handle tables'
1229
1217
  * live stream/future ends are retired here (#66): parked host operations
@@ -1231,21 +1219,10 @@ export function createLiftedFunction(input) {
1231
1219
  * lets the embedder layer reject them loudly.
1232
1220
  */
1233
1221
  const poison = (e) => {
1234
- entered = false; // consumed: the lock is now permanent
1235
- // ...for the leaf. The synthetic per-instantiation root (plan v3
1236
- // amendment 4) is in `enteredSet` too, and leaving IT locked would
1237
- // poison every instance of the component — exactly the store-wide
1238
- // behaviour the paragraph above says this runtime deliberately does not
1239
- // have. Released; see `releaseSyntheticRootOnPoison` in task/mod.ts.
1240
- inst.releaseSyntheticRootOnPoison();
1241
1222
  // Through the seam (not retireInstanceAsyncEnds directly) so the
1242
1223
  // poison marker is recorded too — `Thread.resumeWith` retires this
1243
1224
  // instance's late settles against it instead of assert-cascading.
1244
- for (const i of enteredSet) {
1245
- if (i.isSyntheticRoot)
1246
- continue;
1247
- notifyInstancePoisoned(i, e);
1248
- }
1225
+ notifyInstancePoisoned(inst, e);
1249
1226
  };
1250
1227
  /**
1251
1228
  * Is `e` a *capability* signal rather than a genuine trap?
@@ -1254,9 +1231,9 @@ export function createLiftedFunction(input) {
1254
1231
  * not "the component faulted". Poisoning on them is wrong on the
1255
1232
  * reference's own terms: the operation they stand in for — a synchronous
1256
1233
  * stream copy, `waitable-set.wait`, a blocking cross-component call —
1257
- * *blocks and then completes* in definitions.py. `Store.lift` reaches
1258
- * `leave_to` in every one of those executions, so the instance stays
1259
- * 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
1260
1237
  * that, on a complete runtime, is perfectly healthy — and it cascades:
1261
1238
  * one unsupported operation made every later call on that instance report
1262
1239
  * `cannot enter component instance`, which is neither our real behaviour
@@ -1264,8 +1241,6 @@ export function createLiftedFunction(input) {
1264
1241
  *
1265
1242
  * What unwinding must still do on this path, and what it must not:
1266
1243
  *
1267
- * - MUST release the reentrance lock (`leave`) — the call is over and no
1268
- * activation of this instance survives it.
1269
1244
  * - MUST unwind the FACT sync-call scopes and restore `may_leave`
1270
1245
  * (`unwind`), for exactly the reasons it does after a trap: a bail-out
1271
1246
  * mid-adapter skips `exit-sync-call` and the `may_leave` restore, and
@@ -1309,17 +1284,10 @@ export function createLiftedFunction(input) {
1309
1284
  }
1310
1285
  catch (e) {
1311
1286
  unwind();
1312
- if (isCapabilitySignal(e))
1313
- leave();
1314
- else
1287
+ if (!isCapabilitySignal(e))
1315
1288
  poison(e);
1316
1289
  throw e;
1317
1290
  }
1318
- // The reentrance gate is released here, before the store is pumped:
1319
- // `Store.tick` re-enters each waiting thread's instance itself
1320
- // (`enter_from(None)` / `leave_to(None)`), exactly as in the reference,
1321
- // where `lift_and_run` ticks after `store.invoke` has returned.
1322
- leave();
1323
1291
  let pending;
1324
1292
  try {
1325
1293
  // Completion is "the task resolved AND its threads have drained", not
@@ -1335,7 +1303,7 @@ export function createLiftedFunction(input) {
1335
1303
  // abandoned mid-loop — leaking the exclusive thread and its table slot.
1336
1304
  // The lifted call is over when the task has resolved AND this task's
1337
1305
  // activation is no longer mid-wasm-call. Those are two different events
1338
- // and both matter (M2 phase 3e):
1306
+ // and both matter:
1339
1307
  //
1340
1308
  // * "task resolved" alone abandons a still-running activation. Under
1341
1309
  // JSPI the guest calls `task.return` while suspended, so returning
@@ -1374,6 +1342,14 @@ export function createLiftedFunction(input) {
1374
1342
  });
1375
1343
  };
1376
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
+ }
1377
1353
  if (hostArgs.length !== ft.params.length) {
1378
1354
  throw new TypeError(`${name}: expected ${ft.params.length} argument(s), got ${hostArgs.length}`);
1379
1355
  }
@@ -1381,11 +1357,10 @@ export function createLiftedFunction(input) {
1381
1357
  //
1382
1358
  // A promising-wrapped entry settles a microtask AFTER the guest's core
1383
1359
  // call returns, even when nothing suspended (jspi pin (j)) — so there
1384
- // is a hop between core return and the host-side result LIFT, and the
1385
- // reentrance bracket has already been released by then (`leave()` runs
1386
- // when the first segment parks). In the reference no such window
1387
- // exists: `canon_lift` for sync options runs core + lift atomically
1388
- // 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
1389
1364
  // window lets a full guest turn mutate the memory the pending lift
1390
1365
  // will read — observed as `Trap: list too long` lifting the wosh
1391
1366
  // engine's `tick` (`list<list<u8>>`) after a concurrent `feed-keys`
@@ -1487,27 +1462,13 @@ function dtorOptions(instance) {
1487
1462
  * callee = inst.store.lift(dtor, ft, opts, rt.impl)
1488
1463
  * ```
1489
1464
  *
1490
- * Before #160 the host-initiated path (embedder `drop()`, the GC backstop,
1491
- * `dropOwn`) hand-rolled the bracket in cabi/handles.ts `callDtorGated`: a
1492
- * bare call to the dtor with `enterFrom(null)` HELD across the returned
1493
- * promise. Three defects followed from having no Task/Thread behind the
1494
- * activation:
1495
- *
1496
- * - **#160 itself**: the held bracket left the impl instance non-enterable,
1497
- * so `Store.tick`'s enterability filter (#155) could never resume a
1498
- * suspension point belonging to the dtor's own activation. The completion
1499
- * promise sat in `pendingHostCalls` looking like external work, and every
1500
- * driver parked on it forever.
1501
- * - it was the runtime's only `enterFrom(null)` bracket spanning an await —
1502
- * the macro-scale reachability window of the #156 class, through which a
1503
- * sibling instance looked non-enterable from the synthetic root.
1504
- * - built-ins reached inside the dtor had no ambient task (`currentTask()`
1505
- * → `PendingCapability`, or a foreign-task misattribution, the #24 class).
1506
- *
1507
- * Under the lift harness all three go away structurally: the activation has a
1508
- * real `Task` + implicit `Thread`, the entry bracket is released when the
1509
- * first segment parks (`leave()` before `drive`), and settled tails flow
1510
- * 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.
1511
1472
  *
1512
1473
  * The returned function takes the rep and returns either `undefined` (the
1513
1474
  * activation completed synchronously — the overwhelmingly common case) or a
@@ -1593,7 +1554,7 @@ export function hostDtorCall(rt, rep) {
1593
1554
  * This is the whole of the jspi entry seam. In **plain** mode the entry is not
1594
1555
  * `promising`-wrapped, `callCore` returns core values, and this returns them
1595
1556
  * without yielding — no await, no Promise allocation, the identical
1596
- * 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
1597
1558
  * call returns a Promise (jspi pin (e)) and we park the thread on it via the
1598
1559
  * `awaitValue` block request; the driving loop resumes us with the values, or
1599
1560
  * throws the rejection in (a post-resume trap).
@@ -1789,23 +1750,23 @@ export function createLoweredImport(input) {
1789
1750
  // embedding to hand back the cancellation behaviour of whatever it is
1790
1751
  // hosting. A wasmtime host gets a real one for free — dropping a Rust
1791
1752
  // future IS cancellation. A JS Promise has no such channel, so polyengine
1792
- // answers on the host's behalf; amendment A23 makes the DEFAULT answer the
1753
+ // answers on the host's behalf; §"Functions and async" makes the DEFAULT answer the
1793
1754
  // reference's prompt-cancel host (`on_cancel = () => on_resolve(None)`),
1794
1755
  // installed by the async arm below.
1795
1756
  //
1796
1757
  // The no-op assigned HERE is only the placeholder for paths where
1797
1758
  // `subtask.cancel` is unreachable, so no answer can ever be demanded of
1798
1759
  // it: an eagerly-resolving callee never mints a subtask handle (the
1799
- // fast-path return below is a bare state), and a sync-typed import's A1
1760
+ // fast-path return below is a bare state), and a sync-typed import's suspending mark
1800
1761
  // park never mints one either. It is also the FINAL handler for a
1801
- // `deferCancel()`-branded import — accept and ignore, the pre-A23
1762
+ // `deferCancel()`-branded import — accept and ignore, the pre-cancellation discard
1802
1763
  // behaviour, now per-declaration.
1803
1764
  //
1804
1765
  // Leaving `on_cancel` null instead made a *legal* `subtask.cancel` crash
1805
1766
  // with an internal AssertionError, which is neither reference behaviour
1806
1767
  // nor a sanctioned incompleteness signal.
1807
1768
  subtask.onCancel = () => { };
1808
- // A24 (contracts/embedder-api.md §"Functions and async"): a marked import
1769
+ // abortable() (contracts/embedder-api.md §"Functions and async"): a marked import
1809
1770
  // is handed a fresh `AbortSignal` after its WIT-declared parameters. The
1810
1771
  // mark controls the SIGNATURE UNCONDITIONALLY — a marked function receives
1811
1772
  // a signal on every call, including the paths where it can never fire
@@ -1829,8 +1790,9 @@ export function createLoweredImport(input) {
1829
1790
  // Suspending wrap is applied per-declaration (`importValue`), so an
1830
1791
  // unmarked import physically cannot suspend, whatever the mode.
1831
1792
  //
1832
- // A capability signal is expressly NON-poisoning (amendment 2, #91
1833
- // 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
1834
1796
  // `onStart` lifted into this subtask must be discharged here or
1835
1797
  // its lenders stay elevated forever and later `resource.drop`s
1836
1798
  // trap "handle still lent out" on a healthy instance (found
@@ -1846,7 +1808,7 @@ export function createLoweredImport(input) {
1846
1808
  `only park the frame when declared with suspending() ` +
1847
1809
  `(contracts/embedder-api.md §"Functions and async")`);
1848
1810
  }
1849
- // The park (A1): the reference's plain, NON-cancellable wait — a
1811
+ // The park: the reference's plain, NON-cancellable wait — a
1850
1812
  // cancel request against the caller stays pending-cancel and is
1851
1813
  // delivered at its next cancellable wait, exactly as for any other
1852
1814
  // mid-frame block. The instance-entry gate stays HELD across the park
@@ -1890,8 +1852,8 @@ export function createLoweredImport(input) {
1890
1852
  // * produce SUCCESS -> `onResolve` + `deliverResolve` release the
1891
1853
  // lenders; the `onSettled` backstop below observes
1892
1854
  // `resolveDelivered()` and is a no-op.
1893
- // * produce THROW -> exempt-by-poisoning under amendment 2
1894
- // (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
1895
1857
  // that do NOT poison the caller). Every rejection that reaches
1896
1858
  // this park is a poisoning trap in the CALLER's own frame:
1897
1859
  // branded `ComponentException`s on fallible imports were already resolved
@@ -1943,7 +1905,7 @@ export function createLoweredImport(input) {
1943
1905
  }
1944
1906
  const promise = Promise.resolve(raw).then((v) => {
1945
1907
  store.pendingHostCalls.delete(promise);
1946
- // A23: the subtask may already be resolved when the host promise
1908
+ // cancellation discard: the subtask may already be resolved when the host promise
1947
1909
  // settles — the discard `onCancel` below resolved it
1948
1910
  // CANCELLED_BEFORE_RETURNED (the only pre-settle resolver on this
1949
1911
  // arm). The value has no addressee, and `onResolve` would run
@@ -1971,7 +1933,7 @@ export function createLoweredImport(input) {
1971
1933
  });
1972
1934
  store.pendingHostCalls.add(promise);
1973
1935
  if (!deferCancel) {
1974
- // A23 DISCARD (contracts/embedder-api.md §"Functions and async";
1936
+ // cancellation discard DISCARD (contracts/embedder-api.md §"Functions and async";
1975
1937
  // polyengine#241) — the reference's prompt-cancel host,
1976
1938
  // `on_cancel = () => on_resolve(None)` (definitions.py canon_lower's
1977
1939
  // null branch, line ~2267).
@@ -1994,7 +1956,7 @@ export function createLoweredImport(input) {
1994
1956
  store.pendingHostCalls.delete(promise);
1995
1957
  onResolve(null);
1996
1958
  if (controller !== null) {
1997
- // A24: tell the host its result was discarded, so it can stop the
1959
+ // abortable(): tell the host its result was discarded, so it can stop the
1998
1960
  // underlying operation — clear a timer, abort a fetch, close a
1999
1961
  // dial. Reachable only from this arm by construction: a
2000
1962
  // `deferCancel()` import never discards, so its signal never
@@ -2012,7 +1974,7 @@ export function createLoweredImport(input) {
2012
1974
  // CANCELLED_BEFORE_RETURNED first, the host observes the abort a
2013
1975
  // tick later. Any settlement the abort provokes (typically an
2014
1976
  // `AbortError` rejection) arrives at the settle continuation above
2015
- // with the subtask already resolved, so it lands on the A23
1977
+ // with the subtask already resolved, so it lands on the cancellation discard
2016
1978
  // resolved-subtask guards and is discarded like any other late
2017
1979
  // settlement — never a `store.hostFailure`.
2018
1980
  Promise.resolve().then(() => controller.abort());