@polyengine/runtime 0.1.0-pre.g633468a

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 (126) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +23 -0
  3. package/esm/cabi/async_values.js +162 -0
  4. package/esm/cabi/bulk_lists.js +198 -0
  5. package/esm/cabi/context.js +42 -0
  6. package/esm/cabi/flatten.js +145 -0
  7. package/esm/cabi/float.js +67 -0
  8. package/esm/cabi/handles.js +253 -0
  9. package/esm/cabi/layout.js +149 -0
  10. package/esm/cabi/lift.js +196 -0
  11. package/esm/cabi/load.js +146 -0
  12. package/esm/cabi/lower.js +141 -0
  13. package/esm/cabi/memory.js +182 -0
  14. package/esm/cabi/mod.js +22 -0
  15. package/esm/cabi/store.js +186 -0
  16. package/esm/cabi/strings.js +336 -0
  17. package/esm/cabi/trap.js +38 -0
  18. package/esm/cabi/types.js +264 -0
  19. package/esm/cabi/values.js +64 -0
  20. package/esm/cache/core.js +156 -0
  21. package/esm/cache/dir.js +170 -0
  22. package/esm/cache/mod.js +4 -0
  23. package/esm/cache/web.js +136 -0
  24. package/esm/digest/digest.js +332 -0
  25. package/esm/digest/mod.js +3 -0
  26. package/esm/digest/verify.js +129 -0
  27. package/esm/embedder/casing.js +56 -0
  28. package/esm/embedder/copy.js +42 -0
  29. package/esm/embedder/errors.js +26 -0
  30. package/esm/embedder/imports.js +63 -0
  31. package/esm/embedder/instantiate.js +978 -0
  32. package/esm/embedder/mod.js +40 -0
  33. package/esm/embedder/resources.js +406 -0
  34. package/esm/embedder/streams.js +770 -0
  35. package/esm/embedder/values.js +450 -0
  36. package/esm/embedder/version.js +273 -0
  37. package/esm/exec/boundary.js +1902 -0
  38. package/esm/exec/executor.js +1004 -0
  39. package/esm/exec/host_streams.js +818 -0
  40. package/esm/exec/mod.js +4 -0
  41. package/esm/intrinsics/async_builtins.js +510 -0
  42. package/esm/intrinsics/context.js +90 -0
  43. package/esm/intrinsics/errors.js +13 -0
  44. package/esm/intrinsics/fact_calls.js +865 -0
  45. package/esm/intrinsics/mod.js +564 -0
  46. package/esm/intrinsics/stream_builtins.js +578 -0
  47. package/esm/intrinsics/transcode.js +443 -0
  48. package/esm/jspi/bridge.js +579 -0
  49. package/esm/jspi/mechanics.js +89 -0
  50. package/esm/jspi/mod.js +5 -0
  51. package/esm/jspi/suspending.js +15 -0
  52. package/esm/jspi/types.js +29 -0
  53. package/esm/package.json +3 -0
  54. package/esm/plan/format.js +5 -0
  55. package/esm/plan/loader.js +657 -0
  56. package/esm/plan/mod.js +3 -0
  57. package/esm/shim/mod.js +2 -0
  58. package/esm/shim/translator.js +94 -0
  59. package/esm/task/mod.js +483 -0
  60. package/esm/task/scheduler.js +1028 -0
  61. package/esm/task/streams.js +786 -0
  62. package/esm/task/subtask.js +135 -0
  63. package/esm/task/thread.js +255 -0
  64. package/esm/task/waitable.js +144 -0
  65. package/package.json +91 -0
  66. package/types/cabi/async_values.d.ts +35 -0
  67. package/types/cabi/bulk_lists.d.ts +18 -0
  68. package/types/cabi/context.d.ts +59 -0
  69. package/types/cabi/flatten.d.ts +14 -0
  70. package/types/cabi/float.d.ts +14 -0
  71. package/types/cabi/handles.d.ts +70 -0
  72. package/types/cabi/layout.d.ts +13 -0
  73. package/types/cabi/lift.d.ts +25 -0
  74. package/types/cabi/load.d.ts +14 -0
  75. package/types/cabi/lower.d.ts +10 -0
  76. package/types/cabi/memory.d.ts +58 -0
  77. package/types/cabi/mod.d.ts +15 -0
  78. package/types/cabi/store.d.ts +12 -0
  79. package/types/cabi/strings.d.ts +23 -0
  80. package/types/cabi/trap.d.ts +11 -0
  81. package/types/cabi/types.d.ts +206 -0
  82. package/types/cabi/values.d.ts +5 -0
  83. package/types/cache/core.d.ts +97 -0
  84. package/types/cache/dir.d.ts +6 -0
  85. package/types/cache/mod.d.ts +3 -0
  86. package/types/cache/web.d.ts +10 -0
  87. package/types/digest/digest.d.ts +17 -0
  88. package/types/digest/mod.d.ts +2 -0
  89. package/types/digest/verify.d.ts +48 -0
  90. package/types/embedder/casing.d.ts +40 -0
  91. package/types/embedder/copy.d.ts +24 -0
  92. package/types/embedder/errors.d.ts +11 -0
  93. package/types/embedder/imports.d.ts +47 -0
  94. package/types/embedder/instantiate.d.ts +88 -0
  95. package/types/embedder/mod.d.ts +11 -0
  96. package/types/embedder/resources.d.ts +158 -0
  97. package/types/embedder/streams.d.ts +202 -0
  98. package/types/embedder/values.d.ts +70 -0
  99. package/types/embedder/version.d.ts +85 -0
  100. package/types/exec/boundary.d.ts +360 -0
  101. package/types/exec/executor.d.ts +125 -0
  102. package/types/exec/host_streams.d.ts +165 -0
  103. package/types/exec/mod.d.ts +3 -0
  104. package/types/intrinsics/async_builtins.d.ts +69 -0
  105. package/types/intrinsics/context.d.ts +28 -0
  106. package/types/intrinsics/errors.d.ts +5 -0
  107. package/types/intrinsics/fact_calls.d.ts +120 -0
  108. package/types/intrinsics/mod.d.ts +187 -0
  109. package/types/intrinsics/stream_builtins.d.ts +113 -0
  110. package/types/intrinsics/transcode.d.ts +21 -0
  111. package/types/jspi/bridge.d.ts +227 -0
  112. package/types/jspi/mechanics.d.ts +50 -0
  113. package/types/jspi/mod.d.ts +3 -0
  114. package/types/jspi/suspending.d.ts +1 -0
  115. package/types/jspi/types.d.ts +26 -0
  116. package/types/plan/format.d.ts +369 -0
  117. package/types/plan/loader.d.ts +113 -0
  118. package/types/plan/mod.d.ts +2 -0
  119. package/types/shim/mod.d.ts +1 -0
  120. package/types/shim/translator.d.ts +55 -0
  121. package/types/task/mod.d.ts +257 -0
  122. package/types/task/scheduler.d.ts +421 -0
  123. package/types/task/streams.d.ts +370 -0
  124. package/types/task/subtask.d.ts +96 -0
  125. package/types/task/thread.d.ts +73 -0
  126. package/types/task/waitable.d.ts +67 -0
@@ -0,0 +1,1028 @@
1
+ // The 0.3 task scheduler (docs/architecture.md §6) — the `Store` of definitions.py plus
2
+ // the current-thread context that every canonical built-in reads.
3
+ //
4
+ // ===========================================================================
5
+ // SCHEDULING POLICY (orchestrator decision, docs/architecture.md §6)
6
+ // ===========================================================================
7
+ //
8
+ // definitions.py makes two explicitly nondeterministic choices:
9
+ //
10
+ // * `Store.tick` (line 597): `random.choice(list(candidates))` over ready
11
+ // threads;
12
+ // * `WaitableSet.get_pending_event` (line 821): `random.shuffle(self.elems)`
13
+ // before picking a waitable with a pending event;
14
+ // * `Thread.wait_until` (line 396): `if ready_func() and not
15
+ // DETERMINISTIC_PROFILE and random.randint(0,1): return` — an optional
16
+ // "don't block even though you could" fast path.
17
+ //
18
+ // All three are *allowed* nondeterminism, not required: any single consistent
19
+ // choice is a conforming schedule. This scheduler therefore runs a
20
+ // **deterministic FIFO ready queue** by default — candidates are resumed in
21
+ // the order they became ready, waitable sets deliver events in join order,
22
+ // and `wait_until` always blocks (the reference's `DETERMINISTIC_PROFILE`
23
+ // branch). Reproducible schedules are worth a great deal when debugging a
24
+ // concurrency bug, and FIFO is also the fairest of the cheap policies.
25
+ //
26
+ // Setting `POLYENGINE_SCHED_SEED=<integer>` switches to a **seeded shuffle**: the same
27
+ // choice points become pseudo-random but reproducible from the seed, which is
28
+ // how we explore the schedule space that the FIFO default deliberately pins.
29
+ // A test that passes under FIFO but fails under some seed has found a real
30
+ // order-dependence — in our runtime or in the guest. The seed is read once at
31
+ // module load; `schedulerSeedForTesting` exists so tests can drive both modes
32
+ // without a subprocess.
33
+ //
34
+ // ===========================================================================
35
+ // THREADS WITHOUT STACK SWITCHING
36
+ // ===========================================================================
37
+ //
38
+ // definitions.py implements `Thread` on real OS threads with lock handoff
39
+ // (`cont_new`/`resume`/`block`, lines 270-305) purely to get one-shot
40
+ // continuations. We get the same structure from **JS generators**: a thread
41
+ // body is a generator function that `yield`s a block request and is resumed
42
+ // by `next(cancelled)`. That is a faithful model precisely because the
43
+ // stackless (callback-ABI) path never blocks *inside* a wasm frame — every
44
+ // wasm call returns a callback code before the host decides to wait. Blocking
45
+ // inside a wasm frame (stackful async lifts; a sync lower on an unresolved
46
+ // subtask) genuinely requires JSPI and is M2 phase 3; those sites fail loudly
47
+ // rather than pretending (see `needsJspi`).
48
+ import { assert_, trapIf } from "../cabi/trap.js";
49
+ /** definitions.py `Cancelled` (line 248). */
50
+ export const CANCELLED_FALSE = false;
51
+ export const CANCELLED_TRUE = true;
52
+ /**
53
+ * Failure raised where the reference genuinely needs to suspend a wasm frame.
54
+ *
55
+ * This is deliberately *not* a `Trap`: the component is not at fault and the
56
+ * program is not ill-formed — our runtime is incomplete. Reporting it as a
57
+ * trap would let a conformance run score a missing capability as a correct
58
+ * rejection, which is the exact failure mode contracts/plan-format.md's
59
+ * error-phase split exists to prevent.
60
+ */
61
+ export class NeedsJspi extends Error {
62
+ constructor(what) {
63
+ super(`needs JSPI (M2 phase 3): ${what}`);
64
+ this.name = "NeedsJspi";
65
+ }
66
+ }
67
+ export function needsJspi(what) {
68
+ throw new NeedsJspi(what);
69
+ }
70
+ /**
71
+ * Failure raised where a capability scheduled for a later M2 phase is
72
+ * required. Same rationale as `NeedsJspi`: never a `Trap`.
73
+ */
74
+ export class PendingCapability extends Error {
75
+ constructor(what) {
76
+ super(`pending-capability: ${what}`);
77
+ this.name = "PendingCapability";
78
+ }
79
+ }
80
+ /**
81
+ * Hook invoked when a trap breaks an instance's enter/leave bracket in
82
+ * `Store.tick` (instance poisoning — see the comment at the call site).
83
+ * task/streams.ts registers the stream/future-end retirement walk here
84
+ * (#66). An injection seam rather than an import: streams.ts (via
85
+ * waitable.ts) already imports this module, and a scheduler → streams import
86
+ * would make `CopyEnd extends Waitable` evaluation-order-sensitive.
87
+ */
88
+ let onInstancePoisoned = null;
89
+ /** @internal — see `onInstancePoisoned`; registered once by task/streams.ts. */
90
+ export function setOnInstancePoisoned(f) {
91
+ onInstancePoisoned = f;
92
+ }
93
+ /**
94
+ * @internal — invoke the poisoning hook. For the bracket-break sites that
95
+ * live outside this module (`Thread.resumeWith`, exec/boundary.ts `poison`):
96
+ * one seam, all sites.
97
+ */
98
+ export function notifyInstancePoisoned(inst, cause) {
99
+ // First cause wins: a poisoned instance can collect follow-on failures
100
+ // (late settles retired against it, repeated bracket breaks), and the
101
+ // original trap is the one worth reporting on later entry refusals
102
+ // (polyengine#145 ask 1).
103
+ if (!poisonedInstances.has(inst))
104
+ poisonedInstances.set(inst, cause);
105
+ onInstancePoisoned?.(inst, cause);
106
+ }
107
+ /** Poisoned instances → poisoning cause, for late-settle retirement
108
+ * (`Thread.resumeWith`) and entry-refusal diagnostics (`withPoisonCause`,
109
+ * polyengine#145). A WeakMap mirror of streams.ts's `retiredInstances`, kept
110
+ * here because thread.ts cannot import streams.ts (the same
111
+ * evaluation-order constraint that made `setOnInstancePoisoned` an
112
+ * injection seam). */
113
+ const poisonedInstances = new WeakMap();
114
+ export function isInstancePoisoned(inst) {
115
+ return poisonedInstances.has(inst);
116
+ }
117
+ /**
118
+ * May a settled activation tail for parked thread `t` be DISPATCHED now
119
+ * (issue #156)? True iff its instance is host-enterable — `Thread.resumeWith`
120
+ * brackets the resumption with `enterFrom(null)` — or POISONED, in which case
121
+ * `resumeWith`'s early return retires it and deferring would leak forever.
122
+ *
123
+ * CONTRACT: a parked entry without a reachable `task.inst` (the partial
124
+ * thread doubles the host-pump tests park in `Store.awaiting`) holds no
125
+ * reentrance state, so there is nothing to defer on: dispatchable.
126
+ */
127
+ // deno-lint-ignore no-explicit-any
128
+ export function dispatchableTail(t) {
129
+ const inst = t?.task?.inst;
130
+ if (inst === undefined || inst === null)
131
+ return true;
132
+ return isInstancePoisoned(inst) || inst.mayEnterFrom(null);
133
+ }
134
+ /**
135
+ * The recorded cause of an instance's poisoning: the original trap that
136
+ * broke the enter/leave bracket (polyengine#145). `undefined` when the instance
137
+ * is not poisoned — and, degenerately, when the poisoning cause itself was
138
+ * a thrown `undefined`; use `isInstancePoisoned` for the predicate.
139
+ */
140
+ export function instancePoisonCause(inst) {
141
+ return poisonedInstances.get(inst);
142
+ }
143
+ /**
144
+ * Append the recorded poison cause to an entry-refusal trap message
145
+ * (polyengine#145 ask 1). "cannot enter component instance" covers two states
146
+ * that send an embedder down entirely different debugging paths — a
147
+ * transient reentrance overlap (retry later, look for caller-side call
148
+ * overlap) and a permanently poisoned instance (the corpse of an earlier
149
+ * trap, which this suffix names). Only the poisoned case gets the suffix:
150
+ * the transient message stays byte-identical, and the suffix is
151
+ * conformance-safe because the official suite matches trap messages by
152
+ * substring (harness/src/runner.ts).
153
+ */
154
+ export function withPoisonCause(inst, base) {
155
+ if (!poisonedInstances.has(inst))
156
+ return base;
157
+ const cause = describeCause(poisonedInstances.get(inst));
158
+ return `${base} — instance poisoned by: ${cause}`;
159
+ }
160
+ function describeCause(cause) {
161
+ try {
162
+ // String(err) renders "Name: message" — for a `Trap`, exactly the
163
+ // original trap line the embedder needs to see.
164
+ return String(cause);
165
+ }
166
+ catch {
167
+ return "(unprintable poison cause)";
168
+ }
169
+ }
170
+ // ---------------------------------------------------------------------------
171
+ // Deterministic choice
172
+ // ---------------------------------------------------------------------------
173
+ function readSeed() {
174
+ let raw;
175
+ try {
176
+ raw = Deno.env.get("POLYENGINE_SCHED_SEED");
177
+ }
178
+ catch {
179
+ // No env permission: FIFO. Never fail to *run* because we could not read
180
+ // a debugging knob.
181
+ return null;
182
+ }
183
+ if (raw === undefined || raw === "")
184
+ return null;
185
+ const n = Number(raw);
186
+ if (!Number.isFinite(n))
187
+ return null;
188
+ return Math.trunc(n) >>> 0;
189
+ }
190
+ let seed = readSeed();
191
+ let rngState = 0;
192
+ /** Test hook: switch policy at runtime. `null` restores FIFO. */
193
+ export function schedulerSeedForTesting(value) {
194
+ seed = value === null ? null : value >>> 0;
195
+ rngState = seed ?? 0;
196
+ }
197
+ export function schedulerPolicy() {
198
+ return seed === null ? "fifo" : "seeded-shuffle";
199
+ }
200
+ /** xorshift32 — small, deterministic, and adequate for schedule exploration. */
201
+ function nextRandom() {
202
+ let x = rngState || 0x9e3779b9;
203
+ x ^= x << 13;
204
+ x >>>= 0;
205
+ x ^= x >>> 17;
206
+ x ^= x << 5;
207
+ x >>>= 0;
208
+ rngState = x;
209
+ return x;
210
+ }
211
+ /**
212
+ * Pick one candidate. FIFO (index 0 — candidates are supplied in
213
+ * ready-order) unless a seed is configured, in which case a seeded uniform
214
+ * choice, mirroring the reference's `random.choice`.
215
+ */
216
+ export function chooseCandidate(candidates) {
217
+ assert_(candidates.length > 0, "chooseCandidate on an empty candidate set");
218
+ if (seed === null)
219
+ return candidates[0];
220
+ return candidates[nextRandom() % candidates.length];
221
+ }
222
+ // ---------------------------------------------------------------------------
223
+ // Current-thread context (definitions.py `current_thread`, line 306)
224
+ // ---------------------------------------------------------------------------
225
+ /**
226
+ * The reference keeps the running thread in a thread-local
227
+ * (`thread_local_handler`). A JS generator has no such ambient slot, so the
228
+ * scheduler maintains an explicit stack: `resume()` pushes, and every
229
+ * canonical built-in reads the top. It is a stack rather than a single slot
230
+ * because a *host* import called from a guest can lift into another component
231
+ * instance, nesting one activation inside another exactly as the reference's
232
+ * recursive `store.lift` does.
233
+ */
234
+ // deno-lint-ignore no-explicit-any
235
+ const threadStack = [];
236
+ export function pushCurrentThread(t) {
237
+ threadStack.push(t);
238
+ }
239
+ export function popCurrentThread(t) {
240
+ const top = threadStack.pop();
241
+ assert_(top === t, "current-thread stack imbalance");
242
+ }
243
+ /**
244
+ * Run `fn` with `t` as the ambient, for `fn`'s SYNCHRONOUS extent.
245
+ *
246
+ * This is the wasm-entry bracket (`awaitCore`). It is the same `threadStack`
247
+ * the scheduler's own `resume()` bracket uses, deliberately: a wasm entry made
248
+ * from *inside* an engine-driven resumption (a FACT callee reached from a
249
+ * resumed activation — fact_calls.ts) has an empty scheduler bracket, and the
250
+ * entry itself is then the most specific statement of who is running.
251
+ */
252
+ // deno-lint-ignore no-explicit-any
253
+ export function withActivation(t, fn) {
254
+ threadStack.push(t);
255
+ entryStack.push(t);
256
+ try {
257
+ return fn();
258
+ }
259
+ finally {
260
+ entryStack.pop();
261
+ const top = threadStack.pop();
262
+ assert_(top === t, "withActivation: current-thread stack imbalance");
263
+ }
264
+ }
265
+ /**
266
+ * The WASM-ENTRY brackets alone — a subset of `threadStack`.
267
+ *
268
+ * Kept separately because it is the exact analogue of what the retired
269
+ * async-context store held: the store was written by `withActivation` and by
270
+ * nothing else, so a built-in reached under a scheduler `resume()` bracket
271
+ * that had not (yet) entered wasm saw NO store, even though `threadStack`
272
+ * named a thread. `consumeClaimIfRunning` — the driver-gate
273
+ * release whose scheduling effects the corpus pins precisely — asked exactly
274
+ * that question, so it must keep asking exactly that question — measured:
275
+ * routing it through the full `threadStack` instead moved 64 conformance
276
+ * commands. Ambient *resolution* is a different question and uses the full
277
+ * `threadStack`.
278
+ */
279
+ // deno-lint-ignore no-explicit-any
280
+ const entryStack = [];
281
+ /**
282
+ * "Whose wasm frame are we lexically inside, or running on behalf of?" — the
283
+ * async-context store's replacement, used only by `consumeClaimIfRunning`.
284
+ */
285
+ // deno-lint-ignore no-explicit-any
286
+ function activationOf() {
287
+ return entryStack[entryStack.length - 1] ??
288
+ activationClaims[activationClaims.length - 1] ?? undefined;
289
+ }
290
+ // ---------------------------------------------------------------------------
291
+ // Engine-driven resumptions: the explicit activation-ambient stack
292
+ // ---------------------------------------------------------------------------
293
+ /**
294
+ * ACTIVATIONS THE ENGINE IS RUNNING OUTSIDE OUR FRAMES — innermost last.
295
+ *
296
+ * ===========================================================================
297
+ * WHAT REPLACED THE ASYNC-CONTEXT STORE, AND WHY IT NEEDS NO ENGINE MAGIC
298
+ * ===========================================================================
299
+ *
300
+ * A wasm activation under JSPI does not stay inside our JS frames. Two
301
+ * distinct mechanics take it outside them, and BOTH are ours to observe:
302
+ *
303
+ * (i) A GENUINE SUSPENSION. A `Suspending`-wrapped built-in returned a
304
+ * Promise; the engine parks the activation and resumes it in a
305
+ * microtask of its own when that Promise settles. There is exactly one
306
+ * source of such a Promise in this runtime — `blockCurrentActivation`
307
+ * mints it, `SuspensionPoint.resume`/`.abandon` settle it — so the
308
+ * moment of resumption is ours, including for a **background
309
+ * activation** whose lifted call already returned (that resumption
310
+ * still runs through `SuspensionPoint.resume`, from `Store.tick`).
311
+ *
312
+ * (ii) THE MICROTASK HOP ON EVERY `Suspending` CALL — jspi pin (j),
313
+ * `tests/jspi/fastpath_hop_test.ts`. Even when the built-in produced
314
+ * its value synchronously and nothing suspended, the guest's frame
315
+ * resumes through a microtask, i.e. AFTER our `withActivation` bracket
316
+ * (and `callCore`, and the whole driving frame) has unwound. This one
317
+ * is easy to overlook because nothing looks asynchronous at the call
318
+ * site; it is nonetheless the dominant case, and the one that produced
319
+ * `exit-sync-call with an empty sync-call stack` when it was missed
320
+ * (trap-if-done.wast:448, big-interleaving-test.wast).
321
+ *
322
+ * Both are claimed explicitly — (i) in `SuspensionPoint.resume`, (ii) in the
323
+ * wrapper `suspendingImport` puts around every blocking-capable trampoline —
324
+ * naming the activation captured from the ambient while its bracket was still
325
+ * live. That is exactly the value the async-context store used to
326
+ * reproduce: the store was set by `withActivation` around the wasm entry, and
327
+ * the engine restored it because it had captured the context when it
328
+ * registered the continuation. We now record the same activation ourselves,
329
+ * at the same instant, by construction — no Node `async_hooks` builtin, no
330
+ * `AsyncContext` proposal, nothing beyond Promises (docs/architecture.md §4.3; M3A-1).
331
+ *
332
+ * NOTE ON ORDINARY `await`s. Nothing here needs a context to survive a plain
333
+ * `await` any more, and nothing ever did on its own merits: the driving loops
334
+ * (`drive`/`driveAsync` in exec/boundary.ts, the host-stream pump) run outside
335
+ * every activation and read no ambient. What they do is *resume* threads, and
336
+ * every resumption re-establishes the ambient explicitly — a scheduler-driven
337
+ * one through `Thread.#resumeInternal`'s `pushCurrentThread` bracket, an
338
+ * engine-driven one through this queue.
339
+ *
340
+ * LIFO, TOP-IS-CURRENT — and that direction is load-bearing, not incidental.
341
+ * Activations NEST: an outer activation's built-in can synchronously enter an
342
+ * inner activation's wasm (`async-start-call` running its callee through
343
+ * `awaitCore`), and the inner one is the one executing. Reading the OLDEST
344
+ * claim instead of the newest was measured at 45 conformance failures.
345
+ *
346
+ * The opposite shape — A settles B's suspension so B runs AFTER A — is
347
+ * deliberately NOT represented here: `SuspensionPoint.resume` pushes only when
348
+ * 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.
351
+ *
352
+ * An activation leaves this stack when it parks again
353
+ * (`blockCurrentActivation`) or finishes (its `awaitValue` promise settles —
354
+ * `Store.noteAwaiting`).
355
+ */
356
+ // deno-lint-ignore no-explicit-any
357
+ const activationClaims = [];
358
+ /**
359
+ * Record that the engine will run `t`'s wasm outside our frames.
360
+ *
361
+ * Idempotent in MEMBERSHIP but not in POSITION: re-claiming MOVES an
362
+ * existing claim to the top. The stack's contract is "top = the innermost
363
+ * activation the engine is running outside our frames", and a re-claim is
364
+ * direct evidence that `t` is running RIGHT NOW (its Suspending import just
365
+ * returned into its wasm). The previous early-return kept stale order: a
366
+ * nested callee's claim whose release edge is a promise reaction
367
+ * (`Store.noteAwaiting` -> `releaseClaimOf`) outlives the callee by a
368
+ * microtask, and an outer activation's continuation chunk that resumed in
369
+ * that window re-claimed itself as a NOOP — leaving the finished callee on
370
+ * top, so every ambient read in the rest of the chunk (the next hop's
371
+ * `owner` capture, and any unsafe intrinsic like `context.set`, which has
372
+ * no hop to re-anchor on) answered the wrong thread. Found as issue #24:
373
+ * wit-bindgen's callback epilogue restored its task pointer into another
374
+ * thread's context slots, and the next disciplined callback invocation
375
+ * panicked on a null slot (async_support.rs:578).
376
+ *
377
+ * A null/undefined activation is "no claim" — the instantiation-time shape
378
+ * that has no thread at all.
379
+ */
380
+ // deno-lint-ignore no-explicit-any
381
+ export function claimActivationAmbient(t) {
382
+ if (t === null || t === undefined)
383
+ return;
384
+ if (AMBIENT_TRACE)
385
+ traceAmbient("claim", t);
386
+ const i = activationClaims.indexOf(t);
387
+ if (i === activationClaims.length - 1 && i !== -1)
388
+ return; // already top
389
+ if (i !== -1)
390
+ activationClaims.splice(i, 1);
391
+ activationClaims.push(t);
392
+ }
393
+ // #24 probe.
394
+ // deno-lint-ignore no-explicit-any
395
+ function traceAmbient(what, t) {
396
+ // Lazy import avoidance: reuse context.ts's ids via a local map.
397
+ 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")}`);
399
+ }
400
+ const dbgIds = new WeakMap();
401
+ let nextDbgId = 1;
402
+ export function dbgId(t) {
403
+ if (t === null || t === undefined || typeof t !== "object")
404
+ return String(t);
405
+ let id = dbgIds.get(t);
406
+ if (id === undefined) {
407
+ id = nextDbgId++;
408
+ dbgIds.set(t, id);
409
+ }
410
+ return `T${id}`;
411
+ }
412
+ /**
413
+ * Drop `t`'s activation-ambient claim, if it holds one.
414
+ *
415
+ * The two closing edges: the activation PARKS on a fresh suspension
416
+ * (`blockCurrentActivation`), or it FINISHES — its `awaitValue` promise
417
+ * settles, normally or by rejection, and `Store.noteAwaiting`'s eager settle
418
+ * continuation calls this. The `task.implicitThread` indirection covers the
419
+ * second edge for claims taken against a task's implicit thread.
420
+ */
421
+ // deno-lint-ignore no-explicit-any
422
+ export function releaseActivationAmbient(t) {
423
+ if (t === null || t === undefined)
424
+ return;
425
+ if (AMBIENT_TRACE)
426
+ traceAmbient("release", t);
427
+ let i = activationClaims.indexOf(t);
428
+ if (i === -1) {
429
+ const implicit = t?.task
430
+ ?.implicitThread;
431
+ if (implicit === undefined || implicit === null)
432
+ return;
433
+ i = activationClaims.indexOf(implicit);
434
+ if (i === -1)
435
+ return;
436
+ }
437
+ activationClaims.splice(i, 1);
438
+ }
439
+ // ---------------------------------------------------------------------------
440
+ // The driver's resume claim (a SEPARATE concern from the ambient above)
441
+ // ---------------------------------------------------------------------------
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
+ }
512
+ const AMBIENT_TRACE = (() => {
513
+ try {
514
+ return Deno.env.get("CE_AMBIENT_TRACE") === "1";
515
+ }
516
+ catch {
517
+ return false;
518
+ }
519
+ })();
520
+ /** Diagnostic (#24 probe): the full ambient state, for tracing. */
521
+ export function ambientDebug() {
522
+ return {
523
+ stack: [...threadStack],
524
+ claims: [...activationClaims],
525
+ resuming: resumingThread,
526
+ };
527
+ }
528
+ /** Diagnostic: module-scope state that must NOT survive a completed call. */
529
+ export function ambientResidue() {
530
+ return {
531
+ stack: threadStack.length,
532
+ claim: resumingThread !== null || activationClaims.length > 0,
533
+ };
534
+ }
535
+ /**
536
+ * THE ambient precedence, in one place. Every reader goes through this.
537
+ *
538
+ * 1. `threadStack` -- a synchronous bracket we pushed ourselves: either
539
+ * `Thread.#resumeInternal`'s `resume()` bracket, `withActivation`'s
540
+ * wasm-entry bracket, or `suspendingImport`'s built-in-call bracket.
541
+ * Most specific: we are literally inside that activation's execution.
542
+ * 2. the TOP of `activationClaims` -- the innermost activation the engine
543
+ * is running outside our frames (a `Suspending` hop or a resumption).
544
+ * LIFO, because activations nest: an outer activation's built-in can
545
+ * 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.
549
+ *
550
+ * Tier 2 replaced an async-context store (M3A-1). The store held
551
+ * precisely "the innermost wasm activation currently executing, across the
552
+ * engine's hops and resumptions", because it was written by `withActivation`
553
+ * around the wasm entry and the engine restored it on every continuation it
554
+ * had captured inside that extent. Tiers 1+2 now state that directly. The
555
+ * equivalence is not asserted from the armchair: it was established
556
+ * differentially, by running the whole conformance corpus with both the store
557
+ * and this queue live and comparing them at every read (zero disagreements
558
+ * over 1395 commands), and the corpus pins the result.
559
+ *
560
+ * Having TWO readers with different orders is not a hypothetical hazard: for
561
+ * two rounds `currentThread` used store-first while `maybeCurrentThread` still
562
+ * used slot-first, and since the FACT bracket sites read the latter, the
563
+ * bracket was attributed to the driver's claim instead of its own activation
564
+ * (`exit-sync-call with an empty sync-call stack`). Fixing the precedence in
565
+ * one reader measured as "no change" because the failing sites used the other.
566
+ * Do not add a third reader; extend this one. (`activationOf` above is not a
567
+ * second reader -- it answers a different question, "whose wasm frame are we
568
+ * running on behalf of", and is used only by `consumeClaimIfRunning`.)
569
+ */
570
+ function resolveAmbient() {
571
+ return threadStack[threadStack.length - 1] ??
572
+ activationClaims[activationClaims.length - 1] ?? resumingThread ??
573
+ undefined;
574
+ }
575
+ export function currentThread() {
576
+ if (AMBIENT_TRACE && threadStack.length === 0) {
577
+ console.error(`[ambient] bracket empty; claims=${activationClaims.length} ` +
578
+ `head=${activationClaims[0]?.constructor?.name ?? "none"} ` +
579
+ `resuming=${resumingThread?.constructor?.name ?? "none"}`);
580
+ }
581
+ const t = resolveAmbient();
582
+ if (t === undefined) {
583
+ // Reaching this is not an internal invariant violation, so it must not be
584
+ // an `AssertionError`: it is a *known incompleteness*. wasmtime lets a
585
+ // core module's start function call canonical built-ins during
586
+ // instantiation, before any task exists, and definitions.py has no model
587
+ // for that — `current_thread()` (line 306) simply presumes a running
588
+ // task, because in the reference a built-in is only ever reached from
589
+ // inside one.
590
+ //
591
+ // Instance-scoped built-ins already avoid this by taking their instance
592
+ // from the trampoline declaration (see intrinsics/async_builtins.ts). What
593
+ // lands here is a *task*-scoped built-in (task.return, task.cancel,
594
+ // thread.yield, subtask.*) called at instantiation time, which needs the
595
+ // instantiation-time task context the spec implies but does not spell out.
596
+ // Exercised by test/async/dont-block-start.wast:3.
597
+ throw new PendingCapability("instantiation-time task context — a task-scoped canonical built-in " +
598
+ "ran outside any task (a core start function calling task.return / " +
599
+ "task.cancel / thread.yield / subtask.*; see " +
600
+ "test/async/dont-block-start.wast)");
601
+ }
602
+ return t;
603
+ }
604
+ export function maybeCurrentThread() {
605
+ return resolveAmbient();
606
+ }
607
+ /** definitions.py `current_task()` (line 309). */
608
+ // deno-lint-ignore no-explicit-any
609
+ export function currentTask() {
610
+ return currentThread().task;
611
+ }
612
+ /**
613
+ * The running task, or `null` outside any task — e.g. a core module's start
614
+ * function during instantiation, which the reference has no model for.
615
+ */
616
+ // deno-lint-ignore no-explicit-any
617
+ export function maybeCurrentTask() {
618
+ return maybeCurrentThread()?.task ?? null;
619
+ }
620
+ /** definitions.py `current_instance()` (line 312). */
621
+ // deno-lint-ignore no-explicit-any
622
+ export function currentInstance() {
623
+ return currentTask().inst;
624
+ }
625
+ /**
626
+ * The embedder-visible scheduler state (definitions.py `Store`). One per
627
+ * instantiated component in this runtime — the reference shares one `Store`
628
+ * across component instances of a linked graph, and so do we: `Executor`
629
+ * creates a single `Store` and hands it to every `ComponentInstanceState`.
630
+ *
631
+ * `waiting` is kept as an **array, in insertion order**, which is what makes
632
+ * the default policy FIFO: `readyCandidates()` preserves the order in which
633
+ * threads started waiting.
634
+ */
635
+ export class Store {
636
+ waiting = [];
637
+ /**
638
+ * Host-import promises this store is waiting on. Non-empty means progress
639
+ * is possible but only after a microtask turn — see `drive` in
640
+ * exec/boundary.ts. (definitions.py has no analogue: its host functions run
641
+ * on real threads.)
642
+ */
643
+ pendingHostCalls = new Set();
644
+ /**
645
+ * An exception raised by a host import's promise (a rejection, or a trap
646
+ * thrown while lowering its results). It cannot propagate out of the
647
+ * microtask that produced it, so it is parked here and rethrown by whoever
648
+ * is driving the store — which is the call the guest is blocked in.
649
+ */
650
+ hostFailure = undefined;
651
+ startWaiting(t) {
652
+ assert_(!this.waiting.includes(t), "thread already in the waiting list");
653
+ this.waiting.push(t);
654
+ }
655
+ stopWaiting(t) {
656
+ const i = this.waiting.indexOf(t);
657
+ assert_(i !== -1, "thread not in the waiting list");
658
+ this.waiting.splice(i, 1);
659
+ }
660
+ /** Ready waiting threads, in wait order (the FIFO of the default policy). */
661
+ readyCandidates() {
662
+ return this.waiting.filter((t) => t.ready());
663
+ }
664
+ /**
665
+ * Threads parked on a Promise (the jspi `awaitValue` seam). They are not in
666
+ * `waiting` — nothing the scheduler can do makes them ready — so the driving
667
+ * loop tracks them separately and resumes them when their promise settles.
668
+ */
669
+ // deno-lint-ignore no-explicit-any
670
+ awaiting = new Set();
671
+ /**
672
+ * Settled-but-unserviced activation tails, in settle order.
673
+ *
674
+ * A settled `awaitValue` is the rest of an activation that already finished
675
+ * its wasm: result shaping, the callback loop, `exit_implicit_thread` (and
676
+ * with it the exclusive-thread release). The reference runs all of that
677
+ * atomically inside `Thread.resume`; under jspi it lands a few engine
678
+ * microtasks after the observable effects of the activation (`task.return`
679
+ * flips `resolved` DURING the wasm, the settle only afterwards — jspi
680
+ * pin (j)). Any scheduling decision taken in that window sees phantom
681
+ * state — a finished callee still "holding" its exclusive slot made
682
+ * cancellable.wast report STARTING for an entry the reference admits. So
683
+ * settlement is recorded EAGERLY (at park time, below), `tick` refuses to
684
+ * run anything while a tail is unserviced, and the driving loop services
685
+ * this queue first.
686
+ */
687
+ settled = [];
688
+ /**
689
+ * Park `t` on `promise` (jspi `awaitValue`), with EAGER settle tracking.
690
+ *
691
+ * The `.then` here is also what closes the claim discipline for
692
+ * resumptions the driver did not settle itself (a guest built-in resolving
693
+ * another activation's suspension — `subtask.cancel` delivering a
694
+ * cancellation): the claim taken at settle time must survive until the
695
+ * resumed activation parks again or finishes, and "finished" is exactly
696
+ * this continuation firing. See `releaseClaimOf`.
697
+ */
698
+ // deno-lint-ignore no-explicit-any
699
+ noteAwaiting(t, promise) {
700
+ this.awaiting.add(t);
701
+ promise.then((value) => {
702
+ this.settled.push({ t, value, failure: undefined });
703
+ releaseClaimOf(t);
704
+ }, (e) => {
705
+ this.settled.push({ t, value: undefined, failure: { error: e } });
706
+ releaseClaimOf(t);
707
+ });
708
+ }
709
+ /**
710
+ * Service settled activation tails. Returns whether anything ran. EVERY
711
+ * driving loop must call this before (and interleaved with) `tick` — the
712
+ * queue gates `tick`, so a driver that never services it wedges the store
713
+ * (observed: host-stream pumping between export calls). A `resumeWith` may
714
+ * throw (trap unwinding); callers propagate or park it exactly as they do
715
+ * for `tick`.
716
+ *
717
+ * A tail whose instance is NOT host-enterable is DEFERRED IN PLACE — left
718
+ * in the queue, skipped here — until the lock releases (issue #156).
719
+ * `resumeWith` brackets the resumption with `enterFrom(null)`, and under
720
+ * the shared synthetic per-instantiation root a host entry into ANY
721
+ * instance of the graph locks the root, so while one instance is entered a
722
+ * sibling's tail cannot be dispatched: dispatching it tripped
723
+ * `resumeWith`'s enterability assert (which, mutating before asserting,
724
+ * also stranded the thread and lost the settle).
725
+ *
726
+ * Deferral is safe because `!inst.mayEnterFrom(null)` is EXACTLY `tick`'s
727
+ * candidate-filter predicate on the same instance: while a tail of `inst`
728
+ * is deferred, `tick` cannot resume any thread of `inst` either, so the
729
+ * phantom-state gate the queue exists to enforce is preserved per-instance
730
+ * by construction.
731
+ *
732
+ * The ordering discipline is therefore per-instance settle order. Cross-
733
+ * instance order relaxes only when enterability defers a tail, which is
734
+ * conforming schedule nondeterminism: in definitions.py the tail runs
735
+ * atomically inside the entered bracket, so a host entry admitted during a
736
+ * park necessarily orders before the parked activation's tail there.
737
+ *
738
+ * A POISONED instance's tail is still dispatched: `resumeWith`'s poison
739
+ * early-return retires it, and deferring it would leak forever — a
740
+ * poisoned leaf keeps its lock permanently.
741
+ */
742
+ serviceSettled() {
743
+ let did = false;
744
+ // Rescan from the head after every dispatch: a dispatched tail runs guest
745
+ // code synchronously, which can change lock/poison state and can re-enter
746
+ // `serviceSettled` (mutating the queue under us).
747
+ scan: for (;;) {
748
+ for (let i = 0; i < this.settled.length; i++) {
749
+ const s = this.settled[i];
750
+ // Stale: the thread was resumed elsewhere (driveAsync's race-winner
751
+ // path). Drop it regardless of enterability; it is not progress.
752
+ if (!this.awaiting.has(s.t)) {
753
+ this.settled.splice(i, 1);
754
+ continue scan;
755
+ }
756
+ if (!dispatchableTail(s.t))
757
+ continue;
758
+ this.settled.splice(i, 1);
759
+ s.t.resumeWith(s.value, s.failure);
760
+ did = true;
761
+ continue scan;
762
+ }
763
+ // A full scan found nothing stale and nothing serviceable.
764
+ return did;
765
+ }
766
+ }
767
+ /**
768
+ * "Would a `serviceSettled` call make progress right now?" — i.e. some
769
+ * entry is stale (would be removed) or serviceable (would be dispatched).
770
+ * A queue holding ONLY deferred tails (issue #156) answers false: `tick`
771
+ * must not be gated by them, and the driving loops must not spin on them.
772
+ */
773
+ hasServiceableSettled() {
774
+ for (const s of this.settled) {
775
+ if (!this.awaiting.has(s.t))
776
+ return true;
777
+ if (dispatchableTail(s.t))
778
+ return true;
779
+ }
780
+ return false;
781
+ }
782
+ /**
783
+ * "Does component instance `inst` still have runnable work?" — the
784
+ * drain-to-quiescence predicate behind the **deferred entry decision**
785
+ * (issue #43).
786
+ *
787
+ * wasmtime decides an async-lowered call's initial status only after the
788
+ * executor has drained the work queued ahead of it: a queued
789
+ * `GuestCall(StartImplicit)` is popped, and if `is_ready` is false
790
+ * (`do_not_enter || backpressure`) the caller is told STARTING
791
+ * (concurrent.rs :1497-1522, :3040-3160). That formulation is FIFO-order
792
+ * dependent; polyengine uses the order-robust restatement (issue #43): *the
793
+ * call reports STARTING only if the callee is still unstarted after the
794
+ * instance's runnable work has been exhausted* — drain to quiescence, not
795
+ * pop-one. That is what keeps `sync-streams.wast` green under
796
+ * `POLYENGINE_SCHED_SEED` shuffles, which wasmtime's own rule would not be.
797
+ * Adjudicated 2026-08-10 (issue #43): entry-status timing is NOT
798
+ * normative — this predicate implements a scheduler *policy*, picked so
799
+ * the suite's schedule-overfitted STARTED assertion holds under any
800
+ * seed; the hold-rule gate itself is the spec semantics.
801
+ *
802
+ * "Runnable work of `inst`" is, exhaustively:
803
+ *
804
+ * (a) a settled-but-unserviced activation tail (`settled`) — bookkeeping
805
+ * the reference runs atomically inside `Thread.resume`, so the
806
+ * instance is mid-step, not quiescent;
807
+ * (b) a waiting entry (thread or `SuspensionPoint`) of `inst` that is
808
+ * `ready()` — the scheduler will resume it on the next tick. A gate
809
+ * holder parked mid-frame on an un-rendezvous'd operation is NOT
810
+ * ready and therefore contributes nothing: that is the "holder
811
+ * cannot be drained" case, whose answer is STARTING;
812
+ * (c) a thread of `inst` in `awaiting` whose promise is not a scheduler
813
+ * park — i.e. genuinely in flight across an engine microtask hop.
814
+ * A JSPI-parked activation appears in `awaiting` *and* owns a
815
+ * `SuspensionPoint` in `waiting` (`SuspensionPoint.owner`), and is
816
+ * accounted for by (b) instead; counting it here would make the
817
+ * instance permanently non-quiescent.
818
+ *
819
+ * `excludeTask` is the CALLER's task, and is excluded everywhere: the
820
+ * caller cannot be drained — it is the activation asking the question.
821
+ * This is what makes the "only obstacle is the current running activation"
822
+ * shape (a nested lower from inside the gate holder's own invocation)
823
+ * answer STARTING immediately, with no park at all.
824
+ */
825
+ hasRunnableWork(inst, excludeTask) {
826
+ // deno-lint-ignore no-explicit-any
827
+ const instOf = (x) => x?.task?.inst;
828
+ // deno-lint-ignore no-explicit-any
829
+ const mine = (x) => instOf(x) === inst && x?.task !== excludeTask;
830
+ for (const s of this.settled) {
831
+ if (mine(s.t))
832
+ return true;
833
+ }
834
+ for (const w of this.waiting) {
835
+ if (mine(w) && w.ready())
836
+ return true;
837
+ }
838
+ if (this.awaiting.size === 0)
839
+ return false;
840
+ const parked = new Set();
841
+ for (const w of this.waiting) {
842
+ // deno-lint-ignore no-explicit-any
843
+ const owner = w.owner;
844
+ if (owner !== undefined && owner !== null)
845
+ parked.add(owner);
846
+ }
847
+ for (const t of this.awaiting) {
848
+ if (mine(t) && !parked.has(t))
849
+ return true;
850
+ }
851
+ return false;
852
+ }
853
+ /**
854
+ * definitions.py `Store.tick` (line 597): resume one ready thread, bracketed
855
+ * by the reentrance gate for a host-initiated entry (`enter_from(None)` /
856
+ * `leave_to(None)`).
857
+ *
858
+ * Returns false when no thread was ready, so callers can distinguish
859
+ * "made progress" from "stuck" without inspecting the queue themselves.
860
+ */
861
+ tick() {
862
+ // One suspension resolved per turn.
863
+ //
864
+ // Settling a suspension hands control to wasm in a *microtask*, not
865
+ // 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)
874
+ return false;
875
+ // Same discipline, other edge: a settled-but-unserviced activation tail
876
+ // (see `settled`) is mid-"atomic resume" from the reference's point of
877
+ // view; scheduling anything before servicing it acts on phantom state.
878
+ //
879
+ // Only a SERVICEABLE tail gates: a tail DEFERRED on a non-enterable
880
+ // instance (issue #156) cannot be dispatched now, and gating on it would
881
+ // wedge the store (and hot-spin the drivers). It does not need to gate,
882
+ // because its instance is self-excluded from the candidate set by the
883
+ // enterability filter below — the same predicate on the same instance —
884
+ // so no thread of that instance can be resumed while its tail waits.
885
+ if (this.hasServiceableSettled())
886
+ return false;
887
+ // Ready is not sufficient: the thread's instance must also be enterable
888
+ // from the host. The reference *asserts* this in `Store.tick` — a waiting
889
+ // thread's instance is always re-enterable there, because its host entry
890
+ // has either left or is itself a waiting thread. That does not hold here.
891
+ //
892
+ // Instances of one linked graph share a Store and, with it, the synthetic
893
+ // per-instantiation root (plan v3 amendment 4): `enterFrom(null)` locks
894
+ // the callee AND the root, so while ANY instance is entered from the host
895
+ // — e.g. a sync export parked on an async host import, which in this
896
+ // runtime is a real suspension rather than a blocked OS thread — no
897
+ // instance in the graph is host-enterable. A sibling instance whose
898
+ // thread goes ready in that window (event-driven wakeups do this on every
899
+ // clock turn) would then trip the assertion, and the failure escapes
900
+ // through whatever host-import promise is in flight.
901
+ //
902
+ // So "ready but not enterable" is treated as no progress, exactly as the
903
+ // sync driving loop already does by restricting its candidate set to the
904
+ // callee instance (`driveSyncLift` below; definitions.py `canon_lift`).
905
+ // This cannot livelock: the entered call's host import settles from host
906
+ // JS independently of `tick`, and when that call returns, `leaveTo(null)`
907
+ // unlocks the root and the skipped threads run on the next turn.
908
+ const candidates = this.readyCandidates().filter((t) => t.task.inst.mayEnterFrom(null));
909
+ if (candidates.length === 0)
910
+ return false;
911
+ const thread = chooseCandidate(candidates);
912
+ const inst = thread.task.inst;
913
+ inst.enterFrom(null);
914
+ // Deliberately NOT a `finally`: if the resumed thread traps, the reference
915
+ // never reaches `leave_to` either (definitions.py `Store.tick`, line 597,
916
+ // where a Trap propagates out of `thread.resume()`), so the instance stays
917
+ // locked — the Component Model's instance poisoning. See the `poison`
918
+ // helper in exec/boundary.ts for the full rationale.
919
+ //
920
+ // Capability signals are the exception, for the same reason as there: a
921
+ // `NeedsJspi`/`PendingCapability` marks an operation this runtime cannot
922
+ // perform, not a component fault. In the reference that operation blocks
923
+ // and then completes, so `leave_to` *is* reached and the instance stays
924
+ // enterable — poisoning here would turn one unsupported operation into a
925
+ // permanently dead instance.
926
+ try {
927
+ thread.resume();
928
+ }
929
+ catch (e) {
930
+ if (e instanceof NeedsJspi || e instanceof PendingCapability) {
931
+ inst.leaveTo(null);
932
+ }
933
+ else {
934
+ // The bracket stays broken (instance poisoned, comment above), so
935
+ // its live stream/future ends can never rendezvous again — retire
936
+ // them so parked host peers settle instead of hanging (#66).
937
+ //
938
+ // The synthetic root (plan v3 amendment 4) is released, though: it is
939
+ // in this entry's entering set but must not turn per-instance
940
+ // poisoning into store-wide poisoning. See
941
+ // `ComponentInstanceState.releaseSyntheticRootOnPoison`.
942
+ //
943
+ // Routed through `notifyInstancePoisoned` (not the raw hook) so the
944
+ // poison MARKER is recorded too (polyengine#145): `Thread.resumeWith`'s
945
+ // quiet-retire of late settled tails and `dispatchableTail`'s
946
+ // dispatch-or-defer decision (#156) both read it, and without the
947
+ // marker a settled tail of this instance would hit the backstop
948
+ // assert or defer forever.
949
+ inst.releaseSyntheticRootOnPoison?.();
950
+ notifyInstancePoisoned(inst, e);
951
+ }
952
+ throw e;
953
+ }
954
+ inst.leaveTo(null);
955
+ return true;
956
+ }
957
+ }
958
+ // ---------------------------------------------------------------------------
959
+ // Host-call classification (shared by the drivers in exec/)
960
+ // ---------------------------------------------------------------------------
961
+ /**
962
+ * Host-activity "arm" promises, by identity: entries a driver parks in
963
+ * `Store.pendingHostCalls` purely to say "the embedder may still act". They
964
+ * are NOT outstanding work — treating them as such is the "activity keeps
965
+ * `pendingHostCalls` non-empty forever" hazard documented in
966
+ * exec/host_streams.ts — so the between-calls drivers filter them out via
967
+ * `hasRealHostCall`/`realHostCalls`. The registry lives here (rather than in
968
+ * exec/host_streams.ts, which mints the arms) so exec/boundary.ts's
969
+ * settlement pump can share the classification without an import cycle.
970
+ */
971
+ const hostActivityArms = new WeakSet();
972
+ /** Mark `p` as an activity arm (exec/host_streams.ts `HostActivity`). */
973
+ export function markHostActivityArm(p) {
974
+ hostActivityArms.add(p);
975
+ }
976
+ /** Is there host-call work outstanding that is not just an activity arm? */
977
+ export function hasRealHostCall(store) {
978
+ for (const p of store.pendingHostCalls) {
979
+ if (!hostActivityArms.has(p))
980
+ return true;
981
+ }
982
+ return false;
983
+ }
984
+ /** Every outstanding host call that is real work (not an activity arm). */
985
+ export function realHostCalls(store) {
986
+ const out = [];
987
+ for (const p of store.pendingHostCalls) {
988
+ if (!hostActivityArms.has(p))
989
+ out.push(p);
990
+ }
991
+ return out;
992
+ }
993
+ /**
994
+ * Is there anything left that only a turn of the event loop could advance?
995
+ * Activity arms do not count: they say "the embedder may still act", which is
996
+ * precisely the state in which a between-calls driver should stop and let the
997
+ * operation's promise stay pending (the documented hang, exec/host_streams.ts
998
+ * module header).
999
+ *
1000
+ * `store.settled` (settled-but-unserviced activation tails) DOES count: it
1001
+ * gates `tick`, so exiting with a tail queued is a lost wakeup — the store is
1002
+ * wedged until some other driver appears.
1003
+ */
1004
+ export function storeQuiescent(store) {
1005
+ return store.settled.length === 0 && store.awaiting.size === 0 &&
1006
+ !hasRealHostCall(store);
1007
+ }
1008
+ /**
1009
+ * The reference's `canon_lift` sync driving loop (line 2213):
1010
+ *
1011
+ * ```python
1012
+ * while task.state != Task.State.RESOLVED:
1013
+ * candidates = { t for t in inst.threads if t.ready() and t is not inst.exclusive_thread }
1014
+ * trap_if(not candidates)
1015
+ * random.choice(list(candidates)).resume()
1016
+ * ```
1017
+ *
1018
+ * Note the candidate set is `inst.threads` — threads *of the callee instance*
1019
+ * — and excludes the exclusive thread, and that an empty set is a **trap**
1020
+ * (the spec's deadlock trap), not a hang.
1021
+ */
1022
+ export function driveSyncLift(task) {
1023
+ while (task.state !== "resolved") {
1024
+ const candidates = [...task.inst.threads].filter((t) => t.ready() && t !== task.inst.exclusiveThread);
1025
+ trapIf(candidates.length === 0, "deadlock: synchronous task cannot resolve and no thread is ready");
1026
+ chooseCandidate(candidates).resume();
1027
+ }
1028
+ }