@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,1902 @@
1
+ // Host-boundary wiring: lifted-export invocation (reference `canon_lift`,
2
+ // sync path) and lowered-import bodies (reference `canon_lower`, sync path),
3
+ // built on the cabi v1 interpreter (runtime/src/cabi/) driven by the plan's
4
+ // canonical options — docs/architecture.md §4.3 items 2 and 5, degenerate sync case.
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
+ import { AssertionError, assert_ } from "../cabi/trap.js";
7
+ import { driveSyncLift, clearResumingThread, EventCode, withActivation, hasRealHostCall, hasResumingThread, dispatchableTail, NeedsJspi, needsJspi, setResumingThread, packSubtaskResult, PendingCapability, notifyInstancePoisoned, realHostCalls, storeQuiescent, Subtask, WaitableSet, SubtaskState, Task, Thread, withPoisonCause, } from "../task/mod.js";
8
+ import { currentTask } from "../task/scheduler.js";
9
+ import { PlanError } from "../plan/loader.js";
10
+ import { blockCurrentActivation, enterWasm, } from "../jspi/mod.js";
11
+ export function newStats() {
12
+ return {
13
+ liftedCalls: 0,
14
+ tasksResolved: 0,
15
+ postReturnsRun: 0,
16
+ loweredCalls: 0,
17
+ enterSyncCalls: 0,
18
+ exitSyncCalls: 0,
19
+ callbackInvocations: 0,
20
+ };
21
+ }
22
+ /**
23
+ * A `MemInst`-shaped view over a `WebAssembly.Memory` that never goes stale:
24
+ * `bytes`/`view` re-derive from `memory.buffer` whenever the buffer identity
25
+ * changes (memory.grow detaches the previous ArrayBuffer — a cached
26
+ * Uint8Array would silently drop writes). The provider indirection also
27
+ * covers plan-order effects: canonical options can reference a memory whose
28
+ * `extract-memory` initializer runs later; accesses before extraction fail
29
+ * with a PlanError.
30
+ *
31
+ * Structurally compatible with cabi's `MemInst` (same public surface).
32
+ */
33
+ export class LiveMemory {
34
+ addrType = "i32"; // memory64 components: out of M0 scope
35
+ #provider;
36
+ #label;
37
+ #buffer = null;
38
+ #bytes = new Uint8Array(0);
39
+ #view = new DataView(new ArrayBuffer(0));
40
+ constructor(provider, label) {
41
+ this.#provider = provider;
42
+ this.#label = label;
43
+ }
44
+ #memory() {
45
+ const m = this.#provider();
46
+ if (m === undefined) {
47
+ throw new PlanError(`${this.#label} accessed before its extract-memory initializer ran`);
48
+ }
49
+ return m;
50
+ }
51
+ #refresh() {
52
+ const buffer = this.#memory().buffer;
53
+ if (buffer !== this.#buffer) {
54
+ this.#buffer = buffer;
55
+ this.#bytes = new Uint8Array(buffer);
56
+ this.#view = new DataView(buffer);
57
+ }
58
+ }
59
+ get bytes() {
60
+ this.#refresh();
61
+ return this.#bytes;
62
+ }
63
+ get view() {
64
+ this.#refresh();
65
+ return this.#view;
66
+ }
67
+ get length() {
68
+ return this.#memory().buffer.byteLength;
69
+ }
70
+ ptrType() {
71
+ return this.addrType;
72
+ }
73
+ ptrSize() {
74
+ return 4;
75
+ }
76
+ }
77
+ // Compile-time proof that LiveMemory satisfies the MemInst surface.
78
+ const _memInstCheck = new LiveMemory(() => undefined, "check");
79
+ void _memInstCheck;
80
+ function require(resolver, what) {
81
+ if (resolver === null)
82
+ return null;
83
+ const v = resolver();
84
+ if (v === undefined) {
85
+ throw new PlanError(`${what} accessed before its extract initializer ran`);
86
+ }
87
+ return v;
88
+ }
89
+ /** cabi-facing options object (LiftLowerOptions + flatten inputs). */
90
+ export function cabiOptions(opts) {
91
+ return {
92
+ stringEncoding: opts.stringEncoding,
93
+ memory: opts.memory,
94
+ realloc: opts.realloc === null ? null : (o, os, a, n) => {
95
+ const realloc = require(opts.realloc, "realloc");
96
+ const p = callCore(realloc, [o, os, a, n]);
97
+ trapIf(p.length !== 1 || typeof p[0] !== "number", "realloc result");
98
+ return p[0] >>> 0;
99
+ },
100
+ postReturn: null, // post-return handled by the task layer, not cabi
101
+ async_: opts.async,
102
+ // Truthiness only: `flattenFunctype` branches on whether a callback
103
+ // exists (async lifts with a callback return a packed i32; stackful ones
104
+ // return nothing). Passing the resolver rather than `null` is what makes
105
+ // the callback-ABI core type come out right.
106
+ callback: opts.callback === null ? null : opts.callback,
107
+ };
108
+ }
109
+ /**
110
+ * Call a core function, mapping core-wasm exceptions to canonical-ABI traps
111
+ * (reference `call_and_trap_on_throw`). Component traps and internal errors
112
+ * of ours propagate unchanged.
113
+ */
114
+ /**
115
+ * Layering rule: a core-wasm trap's message is engine-specific text (V8,
116
+ * SpiderMonkey, JSC each word `unreachable` differently, for instance) and is
117
+ * passed through here UNTOUCHED — it is diagnostics only, "engine-flavored"
118
+ * and not normalized to any particular host's wording. The runtime never
119
+ * emulates another host's (e.g. wasmtime's) message text.
120
+ *
121
+ * Suite-wording normalization (matching the official test suite's
122
+ * `assert_trap` expectations, which are typically worded per wasmtime) lives
123
+ * in the harness instead: see `TRAP_MESSAGE_EQUIVALENTS` in
124
+ * harness/src/runner.ts, which maps engine-specific spellings to the
125
+ * suite-expected forms at comparison time. (The FACT *adapter* traps take a
126
+ * different route entirely — they arrive as numeric codes through the `trap`
127
+ * trampoline and are runtime-authored text, see `FACT_TRAP_MESSAGES` in
128
+ * intrinsics/mod.ts; that table is untouched by this layering rule.)
129
+ */
130
+ export function callCore(fn, args) {
131
+ let raw;
132
+ try {
133
+ raw = fn(...args);
134
+ }
135
+ catch (e) {
136
+ throw mapCoreException(e);
137
+ }
138
+ if (raw === undefined)
139
+ return [];
140
+ if (Array.isArray(raw))
141
+ return raw;
142
+ return [raw];
143
+ }
144
+ /**
145
+ * The `call_and_trap_on_throw` translation, factored so BOTH routes a core
146
+ * trap can take reach it:
147
+ *
148
+ * * a synchronous throw out of `fn(...args)` (`callCore` above — the plain
149
+ * path, and jspi pre-suspension);
150
+ * * a **rejection of a `promising` entry's Promise** (jspi pin (e): a trap
151
+ * after a resumption arrives as an ordinary rejection). That rejection
152
+ * carries the raw `WebAssembly.RuntimeError`, and before this helper was
153
+ * applied on the awaited path (`awaitCore` below), a post-suspension
154
+ * guest trap escaped to the embedder as `RuntimeError: unreachable`
155
+ * instead of the wasmtime-worded `Trap` — every deliberate guest trap
156
+ * under detection scored as a harness failure
157
+ * (big-interleaving-test.wast:836's assert_trap "unreachable").
158
+ */
159
+ function mapCoreException(e) {
160
+ if (e instanceof WebAssembly.RuntimeError) {
161
+ try {
162
+ trap(`guest trapped: ${e.message}`);
163
+ }
164
+ catch (t) {
165
+ return t;
166
+ }
167
+ }
168
+ return e;
169
+ }
170
+ /**
171
+ * Normalize raw JS-API core values to cabi's canonical lane representation:
172
+ * i32 lanes as unsigned numbers (the JS API yields signed), i64 lanes as
173
+ * unsigned bigints, floats as numbers.
174
+ */
175
+ export function normalizeCoreValues(values, lanes, what) {
176
+ if (values.length !== lanes.length) {
177
+ throw new AssertionError(`${what}: expected ${lanes.length} core values, got ${values.length}`);
178
+ }
179
+ return values.map((v, i) => {
180
+ switch (lanes[i]) {
181
+ case "i32":
182
+ assert_(typeof v === "number", `${what}[${i}]: i32 lane`);
183
+ return v >>> 0;
184
+ case "i64":
185
+ assert_(typeof v === "bigint", `${what}[${i}]: i64 lane`);
186
+ return BigInt.asUintN(64, v);
187
+ case "f32":
188
+ case "f64":
189
+ assert_(typeof v === "number", `${what}[${i}]: float lane`);
190
+ return v;
191
+ }
192
+ });
193
+ }
194
+ /** Map a resolved result list to the host-facing return value by arity. */
195
+ function resultsToHost(results) {
196
+ if (results.length === 0)
197
+ return undefined;
198
+ if (results.length === 1)
199
+ return results[0];
200
+ return results;
201
+ }
202
+ // ---------------------------------------------------------------------------
203
+ // Driving the scheduler from the host boundary
204
+ // ---------------------------------------------------------------------------
205
+ //
206
+ // run_tests.py's `lift_and_run` (line 55) is the reference embedding:
207
+ //
208
+ // ```python
209
+ // func_inst = inst.store.lift(callee, ft, opts, inst)
210
+ // _ = inst.store.invoke(func_inst, on_start, on_resolve)
211
+ // while inst.store.waiting:
212
+ // inst.store.tick()
213
+ // ```
214
+ //
215
+ // i.e. enter the component, then pump the store until nothing is waiting.
216
+ // `drive` below is that loop, with two additions the reference does not need:
217
+ //
218
+ // 1. **A deadlock verdict.** The reference's `while store.waiting` spins
219
+ // forever if no waiting thread is ready, because its host functions run
220
+ // on real OS threads and always eventually make progress. Ours cannot
221
+ // spin: when no thread is ready and no host promise is outstanding, the
222
+ // task can never resolve, which is the same condition `canon_lift`'s
223
+ // sync loop traps on (`trap_if(not candidates)`), so we trap too.
224
+ //
225
+ // 2. **Host promises.** A host import implemented as an `async` JS function
226
+ // resolves its subtask on a *microtask turn*, not on a thread. When the
227
+ // only way forward is such a promise, `drive` returns a Promise and the
228
+ // lifted export's return value becomes a Promise. This needs no JSPI:
229
+ // the guest is stackless (callback ABI), so nothing is suspended mid-wasm
230
+ // — the guest already returned WAIT and the host merely resumes it later.
231
+ //
232
+ // Consequence for callers: a lifted export returns `T` when the whole call
233
+ // completed synchronously, and `Promise<T>` when a host promise was involved.
234
+ // The conformance harness invokes exports synchronously
235
+ // (harness/src/runtime-executor.ts) and the official suite has no
236
+ // promise-returning host imports, so it only ever sees the synchronous shape.
237
+ /** True for thenables, which is what "is this host call asynchronous" means. */
238
+ function isPromiseLike(v) {
239
+ return (typeof v === "object" && v !== null &&
240
+ typeof v.then === "function");
241
+ }
242
+ // ---------------------------------------------------------------------------
243
+ // Handshake probe (M2 phase 3l)
244
+ // ---------------------------------------------------------------------------
245
+ //
246
+ // Env-gated tracing of the drive loops. This exists because site 1 is the
247
+ // first *lit* suspension site, so the `SuspensionPoint` <-> `Store.tick` <->
248
+ // `driveAsync` handshake had never executed before it; a pure-microtask stall
249
+ // there is invisible from the outside (no trap, no rejection -- just an await
250
+ // nothing settles). Off unless POLYENGINE_DRIVE_TRACE is set, and the getter is read
251
+ // once at module load so normal runs pay a boolean test.
252
+ const DRIVE_TRACE = (() => {
253
+ try {
254
+ return Deno.env.get("POLYENGINE_DRIVE_TRACE") === "1";
255
+ }
256
+ catch {
257
+ return false;
258
+ }
259
+ })();
260
+ let traceTurn = 0;
261
+ function describeWaiter(t) {
262
+ const w = t;
263
+ const kind = w?.constructor?.name ?? "?";
264
+ let verdict = "?";
265
+ try {
266
+ verdict = w.ready?.() ? "READY" : (w.readyFunc === null ? "explicit" : "not-ready");
267
+ }
268
+ catch (e) {
269
+ verdict = `threw:${e}`;
270
+ }
271
+ return `${kind}[${verdict}]`;
272
+ }
273
+ function traceDrive(loop, store, done, branch) {
274
+ if (!DRIVE_TRACE)
275
+ return;
276
+ let doneVerdict = "?";
277
+ try {
278
+ doneVerdict = String(done());
279
+ }
280
+ catch (e) {
281
+ doneVerdict = `threw:${e}`;
282
+ }
283
+ const waiters = store.waiting.map(describeWaiter).join(",");
284
+ const awaiters = [...store.awaiting].map((t) => {
285
+ const a = t;
286
+ return `${a?.constructor?.name ?? "?"}`;
287
+ }).join(",");
288
+ console.error(`[drive #${traceTurn++}] ${loop} branch=${branch} ` +
289
+ `ready=${store.readyCandidates().length} ` +
290
+ `waiting=${store.waiting.length}{${waiters}} ` +
291
+ `awaiting=${store.awaiting.size} ` +
292
+ `hostCalls=${store.pendingHostCalls.size} ` +
293
+ `awaiters={${awaiters}} claim=${hasResumingThread()} done=${doneVerdict}`);
294
+ }
295
+ /**
296
+ * Pump `store` until `done()` holds. Returns `undefined` if that was achieved
297
+ * synchronously, or a Promise that settles when it has been.
298
+ */
299
+ function drive(store, done, what) {
300
+ for (;;) {
301
+ traceDrive("drive", store, done, "top");
302
+ // The synchronous drain must not run while a thread is parked on a
303
+ // Promise: `tick` cannot see those, so a thread that re-parks READY on
304
+ // every resume (a callback-ABI guest spinning YIELD) would hold this
305
+ // loop forever while the promise-parked thread that would stop the spin
306
+ // never gets serviced (drop-subtask.wast:139 under detection: the
307
+ // Looper spins YIELD until `return` runs, and `return`'s caller sat
308
+ // parked on its activation promise). In jspi mode the lifted export
309
+ // returns a Promise anyway, so handing off to `driveAsync` — whose
310
+ // drain interleaves fairly — costs nothing; in plain mode `awaiting`
311
+ // is always empty and this loop is bit-for-bit what it was.
312
+ while (store.awaiting.size === 0 && store.tick()) {
313
+ traceDrive("drive", store, done, "ticked");
314
+ if (store.hostFailure !== undefined)
315
+ throw takeHostFailure(store);
316
+ }
317
+ if (store.hostFailure !== undefined)
318
+ throw takeHostFailure(store);
319
+ if (done()) {
320
+ traceDrive("drive", store, done, "EXIT-done");
321
+ // Fully-synchronous completion: no `driveAsync` ran, so its exit hook
322
+ // will not fire — arm the settlement pump here for any host calls the
323
+ // guest registered fire-and-forget during this drive.
324
+ ensureSettlementPump(store);
325
+ return;
326
+ }
327
+ // A thread parked on a Promise (jspi) can only progress after a microtask
328
+ // turn, exactly like an outstanding host call. So can an outstanding
329
+ // ambient claim: a suspension has been settled and its activation has not
330
+ // run yet (see `Store.tick`).
331
+ if (store.awaiting.size > 0 || hasResumingThread()) {
332
+ traceDrive("drive", store, done, "->async(awaiting/claim)");
333
+ return driveAsync(store, done, what);
334
+ }
335
+ if (store.pendingHostCalls.size === 0) {
336
+ traceDrive("drive", store, done, "DEADLOCK-TRAP");
337
+ trapIf(true, `wasm trap: deadlock detected: event loop cannot make further ` +
338
+ `progress (${what}: no thread is ready and no host call is ` +
339
+ `outstanding)`);
340
+ }
341
+ traceDrive("drive", store, done, "->async(hostcalls)");
342
+ return driveAsync(store, done, what);
343
+ }
344
+ }
345
+ /**
346
+ * Tagged promises, memoized by the *promise* (not the thread) so re-racing on
347
+ * every turn does not attach a fresh continuation to the same promise, and so
348
+ * a thread that parks again later can never pick up a stale tag.
349
+ */
350
+ const taggedAwaits = new WeakMap();
351
+ function tagAwait(t) {
352
+ const p = t.awaiting;
353
+ let tag = taggedAwaits.get(p);
354
+ if (tag === undefined) {
355
+ tag = p.then((value) => ({ t, p, value, failure: undefined }), (e) => ({ t, p, value: undefined, failure: { error: e } }));
356
+ taggedAwaits.set(p, tag);
357
+ }
358
+ return tag;
359
+ }
360
+ /**
361
+ * THE asynchronous driving loop, exported for the one other driver in the
362
+ * runtime: `HostActivity` in exec/host_streams.ts, which must pump the store
363
+ * BETWEEN export calls (when no lifted call is in flight) with exactly these
364
+ * semantics — service settled tails, tick to quiescence, then await the race
365
+ * of every outstanding promise (parked activations AND `pendingHostCalls`),
366
+ * repeat. Reimplementing it there diverged: that copy only drained
367
+ * `store.awaiting` and never awaited `pendingHostCalls`, so a guest parked on
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).
370
+ *
371
+ * Callers that must not hit the deadlock traps below (the host pump: an
372
+ * embedder that never does its half is documented to hang, not trap) can
373
+ * exclude them entirely — BOTH trap sites require
374
+ * `store.pendingHostCalls.size === 0`, and both are reached only through the
375
+ * synchronous fall-through from `done()`, so a `done` that returns true
376
+ * whenever `pendingHostCalls` is empty provably never traps.
377
+ */
378
+ export async function driveStoreAsync(store, done, what) {
379
+ return await driveAsync(store, done, what);
380
+ }
381
+ /**
382
+ * How many `driveAsync` loops are live on a store.
383
+ *
384
+ * THE INVARIANT is not "only one loop may ever run" — concurrent export calls
385
+ * have always produced concurrent loops, and the host-stream pump's stand-down
386
+ * below is cooperative, so a *bounded overlap window* remains by construction
387
+ * (an export call can start while the pump is parked mid-`await`; the pump
388
+ * only notices at its next `done()` evaluation). The invariant is:
389
+ *
390
+ * **no activation is resumed twice for one settlement, and no activation is
391
+ * resumed with a value from a settlement it has already consumed.**
392
+ *
393
+ * Overlap is benign for that invariant because of three mechanisms, in
394
+ * decreasing order of how much weight they carry:
395
+ *
396
+ * (a) LOAD-BEARING — `resumeWith` synchronously deletes the thread from
397
+ * `store.awaiting` (task/thread.ts `Thread.resumeWith`), and every
398
+ * resumption site here is guarded by an `store.awaiting.has(...)` test
399
+ * evaluated synchronously immediately before the call. The loser of a
400
+ * race therefore sees the deletion. The ordering that makes this
401
+ * airtight is microtask FIFO: both loops' race continuations were
402
+ * queued when the *tag* settled, which is strictly before the winner's
403
+ * `resumeWith` can run and therefore strictly before any re-park the
404
+ * resumed activation performs can queue a new settlement. So the loser
405
+ * observes "deleted", never a re-park that restored membership.
406
+ * (b) `tagAwait` memoizes per PROMISE (not per thread), so overlapping loops
407
+ * racing the same parked thread await the *same* tag object and see one
408
+ * settlement, not two independent ones. This is what makes (a)'s
409
+ * "queued at tag settlement" premise hold across loops.
410
+ * (c) The ambient resume claim (`setResumingThread`) serializes the claim
411
+ * path: a second claimant while one is live is asserted against, and
412
+ * every loop yields at its top while `hasResumingThread()`.
413
+ *
414
+ * (a) is the guarantee; (b) and (c) are what make (a) apply across loops
415
+ * rather than only within one. The one corner (a) does NOT cover — a thread
416
+ * resumed by the *other* loop's `tick`, re-parked on a NEW promise, whose OLD
417
+ * promise then settles late — is closed separately at the resumption site
418
+ * below by comparing promise identity, not just membership.
419
+ *
420
+ * What overlap is NOT benign for is throughput and blame: two loops ticking
421
+ * the same store interleave their `serviceSettled`/`tick` phases, and the
422
+ * host-stream pump was observed to trip `Trap: table entry empty` out of
423
+ * `runCallbackLoop` when it drove unconditionally alongside an export call's
424
+ * loop. Export calls own their loops and cannot yield to anyone; the pumps
425
+ * are *fallback* drivers — the host-activity pump for embedder operations
426
+ * that land BETWEEN export calls, the settlement pump (below) for host-call
427
+ * settlements that land between them — so they are the side that stands
428
+ * down, using the two accessors below, narrowing the window to the
429
+ * cooperative residue described above. When an export call's loop is live it
430
+ * already races `pendingHostCalls` and `store.awaiting`, i.e. it pumps host
431
+ * activity on the embedder's behalf.
432
+ */
433
+ const driverDepth = new WeakMap();
434
+ const driverIdle = new WeakMap();
435
+ export function storeDriverDepth(store) {
436
+ return driverDepth.get(store) ?? 0;
437
+ }
438
+ /** Resolves once no `driveAsync` loop is live on `store`. */
439
+ export function whenStoreDriverIdle(store) {
440
+ if (storeDriverDepth(store) === 0)
441
+ return Promise.resolve();
442
+ let w = driverIdle.get(store);
443
+ if (w === undefined) {
444
+ let r;
445
+ const p = new Promise((res) => (r = res));
446
+ w = { p, r };
447
+ driverIdle.set(store, w);
448
+ }
449
+ return w.p;
450
+ }
451
+ // ---------------------------------------------------------------------------
452
+ // The settlement pump: liveness between export calls
453
+ // ---------------------------------------------------------------------------
454
+ //
455
+ // A host-import promise that settles while a driver is live is serviced by
456
+ // that driver (`driveAsync` races `store.pendingHostCalls`). One that settles
457
+ // while NO driver is live only mutates scheduler state — the registration
458
+ // site's continuation delivers results and readies threads, but nothing calls
459
+ // `serviceSettled`/`tick`, so the work sits queued until the next export call
460
+ // or host stream/future operation happens to drive the store. For a guest
461
+ // with genuinely background work — the canonical shape is a task parked WAIT
462
+ // on a waitable set whose pending host call is a clock (a componentize-go
463
+ // keep-alive ticker, a wasi:clocks `wait-for`) — that turned "the host will
464
+ // wake me" into "the embedder's next unrelated call will wake me": a liveness
465
+ // gap, not a policy (wasmtime's event loop delivers such wakeups whenever the
466
+ // embedder dwells in `run_concurrent`; on a JS host the event loop is always
467
+ // dwelling).
468
+ //
469
+ // The settlement pump closes the gap: whenever a driver exits leaving real
470
+ // host calls outstanding (`hasRealHostCall` — activity arms excluded, they
471
+ // mean "the embedder may still act", not "the host owes an event"), a
472
+ // detached keeper parks on `Promise.race` of those calls and, when one
473
+ // settles, drives the store to quiescence with the same loop and the same
474
+ // cooperative discipline as the host-activity pump above it in the driver
475
+ // hierarchy:
476
+ //
477
+ // * it stands down whenever an export call's loop is live
478
+ // (`storeDriverDepth` / `whenStoreDriverIdle`, plus the `> 1` clause in
479
+ // its `done`, exactly as `HostActivity.#pumpAsync`);
480
+ // * its `done` returns true whenever `pendingHostCalls` is empty, which is
481
+ // the precondition of BOTH deadlock traps in `driveAsync` — the pump can
482
+ // therefore never convert the documented embedder-never-acts hang into a
483
+ // trap (see the `driveStoreAsync` note above);
484
+ // * failures park on `store.hostFailure` for the next embedder call to
485
+ // surface, the channel every between-calls driver already uses.
486
+ //
487
+ // Every real `pendingHostCalls` entry is born during guest execution, i.e.
488
+ // inside some driver, so arming at driver exit (`driveAsync`'s finally and
489
+ // `drive`'s synchronous completion) observes every registration. A
490
+ // HOST-initiated resource dtor (embedder `drop()` between calls) is no
491
+ // exception since #160: it is a lifted call like any other, so it brings its
492
+ // own driver, and any host call its activation makes is registered inside
493
+ // that driver.
494
+ //
495
+ // STALE SNAPSHOTS: the keeper races the real host calls it saw when it
496
+ // parked. A drive it performs can register NEW calls (the keep-alive ticker
497
+ // re-arming is the routine case), and `ensureSettlementPump` may be called
498
+ // while the keeper is already parked. Both are handled by a nudge promise
499
+ // raced alongside the snapshot: arming an already-live pump fires the nudge,
500
+ // the keeper wakes, re-snapshots, and re-parks.
501
+ const settlementPumps = new WeakSet();
502
+ const settlementNudges = new WeakMap();
503
+ function armSettlementNudge(store) {
504
+ let n = settlementNudges.get(store);
505
+ if (n === undefined) {
506
+ let r;
507
+ const p = new Promise((res) => (r = res));
508
+ n = { p, r };
509
+ settlementNudges.set(store, n);
510
+ }
511
+ return n.p;
512
+ }
513
+ function fireSettlementNudge(store) {
514
+ const n = settlementNudges.get(store);
515
+ if (n !== undefined) {
516
+ settlementNudges.delete(store);
517
+ n.r();
518
+ }
519
+ }
520
+ /**
521
+ * Ensure a settlement pump is watching `store`'s real outstanding host calls.
522
+ * Idempotent and cheap; called at every driver exit. Never throws.
523
+ */
524
+ export function ensureSettlementPump(store) {
525
+ if (settlementPumps.has(store)) {
526
+ // Already parked (or driving): wake it so it re-snapshots the race —
527
+ // this call may be reporting host calls registered after it parked.
528
+ fireSettlementNudge(store);
529
+ return;
530
+ }
531
+ if (store.hostFailure !== undefined)
532
+ return;
533
+ if (!hasRealHostCall(store))
534
+ return;
535
+ settlementPumps.add(store);
536
+ void settlementPumpLoop(store);
537
+ }
538
+ async function settlementPumpLoop(store) {
539
+ let failed = false;
540
+ try {
541
+ for (;;) {
542
+ // Stand down while any driver is live: it races `pendingHostCalls`
543
+ // itself and services settlements on the guest's behalf.
544
+ while (storeDriverDepth(store) > 0) {
545
+ await whenStoreDriverIdle(store);
546
+ }
547
+ // A parked failure belongs to the next embedder call (the only place
548
+ // it can surface); driving into it here would just consume and re-park
549
+ // it in a loop.
550
+ if (store.hostFailure !== undefined)
551
+ return;
552
+ const real = realHostCalls(store);
553
+ if (real.length === 0)
554
+ return;
555
+ const nudge = armSettlementNudge(store);
556
+ // Rejections are not this pump's to report: the registration site's
557
+ // own continuation parks them on `store.hostFailure`.
558
+ await Promise.race([
559
+ ...real.map((p) => p.then(() => { }, () => { })),
560
+ nudge,
561
+ ]);
562
+ if (storeDriverDepth(store) > 0)
563
+ continue;
564
+ // Drive unconditionally after a wake: `storeQuiescent` cannot see a
565
+ // READY waiting thread (the usual product of a settlement — the
566
+ // continuation readied the guest and deleted its own host call), so
567
+ // gating the drive on it skips exactly the work this pump exists to
568
+ // do. `driveAsync` drains ready threads before consulting `done`, and
569
+ // a vacuous round exits on its first `done` evaluation.
570
+ await driveStoreAsync(store,
571
+ // Quiescence, not completion — and the same three exit clauses as
572
+ // the host-activity pump: nothing only an event-loop turn could
573
+ // advance; `pendingHostCalls` empty (the deadlock traps'
574
+ // precondition, so this pump provably never traps); another driver
575
+ // appeared (ours is the 1).
576
+ () => store.pendingHostCalls.size === 0 ||
577
+ storeQuiescent(store) ||
578
+ storeDriverDepth(store) > 1, "settlement pump");
579
+ }
580
+ }
581
+ catch (e) {
582
+ failed = true;
583
+ store.hostFailure ??= e;
584
+ }
585
+ finally {
586
+ settlementPumps.delete(store);
587
+ // Close the exit race: an `ensureSettlementPump` that saw us live and
588
+ // fired the nudge after our last snapshot check must not be lost.
589
+ if (!failed && store.hostFailure === undefined &&
590
+ storeDriverDepth(store) === 0 && hasRealHostCall(store)) {
591
+ ensureSettlementPump(store);
592
+ }
593
+ }
594
+ }
595
+ async function driveAsync(store, done, what) {
596
+ driverDepth.set(store, storeDriverDepth(store) + 1);
597
+ try {
598
+ let claimHops = 0;
599
+ for (;;) {
600
+ traceDrive("driveAsync", store, done, "top");
601
+ // FIRST: service every settled-but-unserviced activation tail, in settle
602
+ // order (`Store.settled` — armed eagerly at park time). A settled
603
+ // `awaitValue` is the rest of an activation that already finished its
604
+ // wasm; the reference runs that bookkeeping atomically inside
605
+ // `Thread.resume`, so nothing may be scheduled past it (`Store.tick`
606
+ // refuses while the queue is non-empty). Servicing after ticking let a
607
+ // freshly-resumed caller race into an entry gate while a finished
608
+ // callee's body had yet to release the exclusive slot — cancellable.wast
609
+ // then reported STARTING for an entry the reference admits.
610
+ store.serviceSettled();
611
+ if (store.hostFailure !== undefined)
612
+ throw takeHostFailure(store);
613
+ // A live claim is an engine-driven resumption in flight: its activation
614
+ // has not yet parked again or finished. It will die on its own — parking
615
+ // consumes it (`blockCurrentActivation`), finishing releases it
616
+ // (`Store.noteAwaiting`'s settle continuation) — so yield microtasks
617
+ // until it does. The driver must NOT blanket-clear here: the claim may
618
+ // have been taken by a guest built-in settling another activation's
619
+ // suspension (`subtask.cancel` delivering a cancellation), and clearing
620
+ // it before that activation runs re-opens the mis-attribution window the
621
+ // claim exists to close.
622
+ if (hasResumingThread()) {
623
+ traceDrive("driveAsync", store, done, "yield-claim");
624
+ // Bounded: a claim that never dies is an internal bug (every path out
625
+ // of a resumed activation releases it — park, finish, trap), and a
626
+ // pure-microtask wait would otherwise starve the event loop and every
627
+ // stall timer with it. Interleave macrotask hops so timers stay alive,
628
+ // and fail loudly rather than spin forever.
629
+ claimHops++;
630
+ assert_(claimHops < 10_000, "driveAsync: a resumed-activation claim was never released " +
631
+ "(the activation neither parked, finished, nor trapped)");
632
+ if (claimHops % 100 === 0) {
633
+ await new Promise((r) => setTimeout(r, 0));
634
+ }
635
+ else {
636
+ await Promise.resolve();
637
+ }
638
+ continue;
639
+ }
640
+ claimHops = 0;
641
+ while (store.tick()) {
642
+ if (store.hostFailure !== undefined)
643
+ throw takeHostFailure(store);
644
+ // FAIRNESS between tick-able threads and promise-parked ones. A thread
645
+ // that is READY again on every resume (the callback-ABI YIELD spin)
646
+ // would otherwise monopolize this drain while a parked thread's
647
+ // settled promise waits (the starvation that hung
648
+ // drop-subtask.wast:139), and the engine's own continuations (jspi
649
+ // pin (j)) only ever land on microtask turns. One hop per tick; bail
650
+ // to the top the moment an activation tail lands.
651
+ if (store.awaiting.size > 0) {
652
+ await Promise.resolve();
653
+ if (store.hasServiceableSettled())
654
+ break;
655
+ }
656
+ }
657
+ if (store.hostFailure !== undefined)
658
+ throw takeHostFailure(store);
659
+ if (done()) {
660
+ traceDrive("driveAsync", store, done, "EXIT-done");
661
+ return;
662
+ }
663
+ // Only a SERVICEABLE tail is a reason to loop again: a queue holding
664
+ // only tails DEFERRED on a non-enterable instance (issue #156) would
665
+ // spin this loop hot — nothing in the cycle awaits.
666
+ if (store.hasServiceableSettled() || hasResumingThread()) {
667
+ continue;
668
+ }
669
+ // Service promise-parked threads (jspi).
670
+ //
671
+ // This must NOT block on one chosen thread's promise. A thread parked on a
672
+ // promising-wrapped nested activation only settles once that activation's
673
+ // own suspension points have been resumed -- and resuming those is
674
+ // `Store.tick`'s job, i.e. *this loop's* job. Awaiting a single promise
675
+ // therefore stops the scheduler while waiting for something that needs the
676
+ // scheduler: a pure-microtask stall with no trap and no rejection.
677
+ // Observed on `async/async-calls-sync.wast` the moment site 1 became the
678
+ // first lit suspension site (M2 phase 3l): turn N serviced a promise that
679
+ // never settled while three other parked threads and three ready-able
680
+ // suspension points went unexamined.
681
+ //
682
+ // So: race every outstanding promise (parked threads AND host calls) and
683
+ // service whichever settles first, re-ticking each turn. The claim is
684
+ // taken in the tagged continuation -- as close to settlement as we can get
685
+ // -- so pin (i)'s window (engine-driven wasm resumption running built-ins
686
+ // before our continuation) is still covered for the thread that actually
687
+ // resumed, without falsely claiming the ambient for threads that did not.
688
+ if (store.awaiting.size > 0) {
689
+ // Is this actually progress, or a deadlock wearing its clothes?
690
+ //
691
+ // Everything in `store.awaiting` is an INTERNAL promise: a
692
+ // promising-wrapped wasm activation. Such a promise settles either on
693
+ // its own (the activation ran to completion -- which happens within one
694
+ // macrotask turn, since the work is already done and only the microtask
695
+ // hop remains) or because WE resume a suspension point it is waiting
696
+ // behind. If no thread is ready, no host call is outstanding, and a full
697
+ // macrotask turn passes with nothing settling, then nobody can move: the
698
+ // awaited promises need us and we need them. That is the deadlock trap
699
+ // (definitions.py `canon_lift`'s empty-candidate-set `trap_if`), and
700
+ // without this check it presents as a silent stall instead -- which is
701
+ // exactly what `tests/jspi/deadlock_test.ts` caught the moment site 2
702
+ // was lit.
703
+ if (store.pendingHostCalls.size === 0 && !hasResumingThread()) {
704
+ traceDrive("driveAsync", store, done, "deadlock-probe");
705
+ // Exclude threads whose settle is already QUEUED in `store.settled`
706
+ // (issue #156): their promise has settled, so racing them wins
707
+ // instantly off the memoized `tagAwait` tag, forever, in an unbounded
708
+ // microtask chain — the tail is `serviceSettled`'s to run.
709
+ const queued = new Set(store.settled.map((s) => s.t));
710
+ const parked = [...store.awaiting].filter((t) => !queued.has(t));
711
+ const progressed = await Promise.race([
712
+ ...parked.map((t) => tagAwait(t).then(() => true)),
713
+ new Promise((r) => setTimeout(() => r(false), 0)),
714
+ ]);
715
+ traceDrive("driveAsync", store, done, `deadlock-probe:progressed=${progressed}`);
716
+ if (!progressed) {
717
+ // The race covered a SNAPSHOT of the awaiting set. A thread that
718
+ // parked during the macrotask turn (a promising callee's body
719
+ // yielding its awaitValue mid-hop — jspi pin (j) makes this
720
+ // routine) was not raced, and its promise may already be settled;
721
+ // trapping now would declare a deadlock one iteration before the
722
+ // loop would have serviced it. Membership change ⇒ re-probe.
723
+ //
724
+ // `fresh` gets the SAME queued-entry filter `parked` got (issue
725
+ // #156), against a RECOMPUTED queued set — the settled queue can
726
+ // change across the probe's await. Comparing a filtered snapshot
727
+ // against an unfiltered one would read "changed" on every turn in
728
+ // the all-deferred wedge state, so the verdict below could never
729
+ // be reached and the wedge would present as a silent
730
+ // macrotask-paced busy idle instead of a trap.
731
+ const freshQueued = new Set(store.settled.map((s) => s.t));
732
+ const fresh = [...store.awaiting].filter((t) => !freshQueued.has(t));
733
+ const changed = fresh.length !== parked.length ||
734
+ fresh.some((t, i) => t !== parked[i]);
735
+ if (changed)
736
+ continue;
737
+ // The probe's precondition can also expire WITHOUT the awaiting
738
+ // set changing: the same activation resumes off an engine
739
+ // continuation chunk during the probe's macrotask turn (jspi
740
+ // pin (j) — a sync-completing Suspending import still defers its
741
+ // continuation), runs, and re-parks through the A1 arm, which
742
+ // registers a fresh `pendingHostCalls` entry. The activation
743
+ // promise never settled and `awaiting` membership is unchanged,
744
+ // but the park is externally wakeable now — the verdict's own
745
+ // precondition (`pendingHostCalls.size === 0`) no longer holds.
746
+ // Observed on wasi-shims' A5 poll (sync fast path): probe sampled
747
+ // hostCalls=0 between a settled park and the next one, then
748
+ // trapped a live workload with hostCalls=1. Re-check ⇒ re-probe.
749
+ // Likewise a SERVICEABLE settled entry (issue #156): dispatching
750
+ // it is progress, so this is not a deadlock verdict — re-probe.
751
+ // A deferred-only queue deliberately does NOT re-probe: nothing
752
+ // can dispatch it while the lock is held, and if no host call is
753
+ // outstanding nothing will ever release that lock, so it falls
754
+ // THROUGH to the verdict below — the same loud-wedge treatment the
755
+ // servicing race's own all-deferred fallthrough gets. Per the #156
756
+ // analysis that state is unreachable (a lock spanning this loop's
757
+ // await always has a `pendingHostCalls` entry, which fails this
758
+ // probe's precondition); keeping it loud is what makes it an
759
+ // internal-wedge detector rather than dead code.
760
+ if (store.pendingHostCalls.size > 0 || hasResumingThread() ||
761
+ store.hasServiceableSettled()) {
762
+ continue;
763
+ }
764
+ if (store.readyCandidates().length === 0) {
765
+ trapIf(true, `wasm trap: deadlock detected: event loop cannot make ` +
766
+ `further progress (${what}: every suspended activation is ` +
767
+ `waiting on a suspension only this scheduler could resume, ` +
768
+ `and none is ready)`);
769
+ }
770
+ // No promise settled, but a thread became READY while we waited --
771
+ // typically a suspension point whose `readyFunc` turned true because
772
+ // another activation ran during the macrotask turn. The way forward
773
+ // is `Store.tick`, not a promise: go back to the top and resume it.
774
+ // Falling through to the servicing block instead would await
775
+ // promises that nothing will settle while a runnable thread sits
776
+ // there -- the `async/sync-barges-in.wast` stall exactly.
777
+ continue;
778
+ }
779
+ // Progress IS possible: fall through to the normal servicing below,
780
+ // which resumes the settled thread. Returning to the top instead would
781
+ // spin -- the memoized tag is already settled, so the race would win
782
+ // instantly, forever, without anyone being resumed.
783
+ }
784
+ // Re-check membership: the deadlock probe above AWAITS, and everything
785
+ // below reads `[...store.awaiting][0]` as if the set were still
786
+ // non-empty. A thread resumed during the probe (its settle continuation
787
+ // runs `resumeWith`, which deletes it) can empty the set, and the
788
+ // snapshot's `parked[0]` is then `undefined` — the exact check-then-act
789
+ // shape that made the host pump's copy of this loop throw
790
+ // `TypeError: ... (reading 'awaiting')` into `store.hostFailure`, where
791
+ // it poisoned a later unrelated call (C0 finding R-2). Nothing to
792
+ // service ⇒ go back to the top and re-evaluate `done`.
793
+ // Same re-check for the settled queue, and for the same reason: the
794
+ // probe's macrotask turn can land a fresh, SERVICEABLE activation tail
795
+ // (that is exactly what "progress IS possible" above usually means).
796
+ // The queue owns those threads — the race below deliberately excludes
797
+ // them (issue #156) — so the way forward is the top of the loop, where
798
+ // `serviceSettled` dispatches them. Without this, filtering the
799
+ // just-settled thread out of the race left the loop awaiting promises
800
+ // that only its dispatch could settle (observed: tests/jspi/
801
+ // handshake_test.ts stalled, then tripped the claim assert).
802
+ if (store.awaiting.size === 0 || store.hasServiceableSettled())
803
+ continue;
804
+ // Claim the ambient for ONE parked thread and await its promise -- as
805
+ // before, so pin (i)'s window is covered exactly as it was -- but race
806
+ // that promise against every other outstanding promise so this loop can
807
+ // never be held hostage by it. The claimed thread's promise may only be
808
+ // settleable by further scheduler progress (a promising-wrapped nested
809
+ // activation whose own suspension points this loop must still resume);
810
+ // blocking on it alone is the pure-microtask stall of M2 phase 3l.
811
+ // Same exclusion as the probe (issue #156): a thread whose tail is
812
+ // already queued in `store.settled` must not be raced — its tag is
813
+ // settled, so it re-wins instantly and livelocks the event loop,
814
+ // starving the very host-call settle that would release the lock.
815
+ const queued = new Set(store.settled.map((s) => s.t));
816
+ const parked = [...store.awaiting].filter((t) => !queued.has(t));
817
+ if (parked.length === 0) {
818
+ // Every awaiting thread's settle is deferred on a non-enterable
819
+ // instance. The way out is the lock holder finishing, and the only
820
+ // await-spanning host-entry lock is the async-dtor bracket, which
821
+ // registers in `pendingHostCalls` — so park on those.
822
+ if (store.pendingHostCalls.size > 0) {
823
+ await Promise.race([...store.pendingHostCalls]).catch(() => { });
824
+ continue;
825
+ }
826
+ // Per the issue #156 analysis this is unreachable (a spanning lock
827
+ // always has a `pendingHostCalls` entry; a synchronous lock cannot
828
+ // span this loop's await). An internal-wedge detector, not expected
829
+ // behavior.
830
+ traceDrive("driveAsync", store, done, "DEADLOCK-TRAP-deferred");
831
+ trapIf(true, `wasm trap: deadlock detected: event loop cannot make further ` +
832
+ `progress (${what}: every settled activation tail is deferred ` +
833
+ `on a non-enterable instance and no host call is outstanding)`);
834
+ }
835
+ const chosen = parked[0];
836
+ const chosenTag = tagAwait(chosen);
837
+ const others = parked.slice(1).map(tagAwait);
838
+ for (const h of store.pendingHostCalls) {
839
+ others.push(h.then(() => null, () => null));
840
+ }
841
+ // A SPECULATIVE claim: the chosen thread is a promising-wrapped
842
+ // activation, and the engine may run its wasm during this await (pin
843
+ // (i)). It is released unconditionally on the way out — if the
844
+ // activation is genuinely mid-resumption its own exact claim (minted by
845
+ // `SuspensionPoint.resume`) is what carries it, and releasing a claim
846
+ // that names a thread already gone from the queue is a no-op.
847
+ setResumingThread(chosen);
848
+ let winner;
849
+ try {
850
+ winner = await Promise.race([chosenTag, ...others]);
851
+ }
852
+ finally {
853
+ clearResumingThread();
854
+ }
855
+ // Resume whichever thread actually settled -- not necessarily the one we
856
+ // claimed. Resuming only the claimed thread would spin: its promise may
857
+ // never settle, the same thread would be chosen again next turn, and the
858
+ // already-settled tags would win the race instantly forever (observed as
859
+ // an OOM, not a hang). The claim is cleared above before any resumption,
860
+ // exactly as on the original single-promise path, so this does not widen
861
+ // the ambient window; it only ensures the loop always makes progress.
862
+ // Membership is not enough: the corner it misses is a thread the OTHER
863
+ // overlapping loop resumed via `tick`, which then re-parked on a NEW
864
+ // promise, after which its OLD promise settles late — membership is
865
+ // true again but the tag's value belongs to a settlement this thread
866
+ // has already consumed. Compare promise identity too.
867
+ if (winner !== null && store.awaiting.has(winner.t) &&
868
+ winner.t.awaiting === winner.p &&
869
+ // Dispatch guard, the same predicate `Store.serviceSettled` uses
870
+ // (issue #156): never resume into an instance that is not
871
+ // host-enterable. The entry is (also) queued in `store.settled` by
872
+ // `noteAwaiting`'s continuation, and `serviceSettled` owns it once
873
+ // the lock releases.
874
+ dispatchableTail(winner.t)) {
875
+ winner.t.resumeWith(winner.value, winner.failure);
876
+ }
877
+ continue;
878
+ }
879
+ if (store.pendingHostCalls.size === 0) {
880
+ traceDrive("driveAsync", store, done, "DEADLOCK-TRAP");
881
+ trapIf(true, `wasm trap: deadlock detected: event loop cannot make further ` +
882
+ `progress (${what}: no thread is ready and no host call is ` +
883
+ `outstanding)`);
884
+ }
885
+ traceDrive("driveAsync", store, done, "await-race");
886
+ // Settlement order among several outstanding host calls is the host's,
887
+ // not ours — this is genuine, unavoidable nondeterminism at the boundary
888
+ // (the reference has the same freedom in `Store.tick`). Everything
889
+ // *inside* the component stays deterministic per scheduler.ts.
890
+ await Promise.race([...store.pendingHostCalls]).catch(() => { });
891
+ }
892
+ }
893
+ finally {
894
+ const left = storeDriverDepth(store) - 1;
895
+ driverDepth.set(store, left);
896
+ if (left === 0) {
897
+ const w = driverIdle.get(store);
898
+ driverIdle.delete(store);
899
+ w?.r();
900
+ // The store just went driver-idle; if real host calls remain, hand
901
+ // liveness to the settlement pump (which stands down again the moment
902
+ // any driver starts).
903
+ ensureSettlementPump(store);
904
+ }
905
+ }
906
+ }
907
+ function takeHostFailure(store) {
908
+ const e = store.hostFailure;
909
+ store.hostFailure = undefined;
910
+ return e;
911
+ }
912
+ // ---------------------------------------------------------------------------
913
+ // canon lift
914
+ // ---------------------------------------------------------------------------
915
+ /**
916
+ * Build the host-callable function for one lifted export (reference
917
+ * `Store.lift` + `canon_lift`, definitions.py lines 578 and 2154).
918
+ *
919
+ * All three lift shapes go through one `Task` + implicit `Thread`:
920
+ *
921
+ * * **sync** (`not ft.async`) — call, lift results, `task.return_`,
922
+ * post-return, then the sync driving loop until the task resolves;
923
+ * * **async + callback** (stackless) — the packed-code loop
924
+ * (EXIT / YIELD / WAIT), fully implemented here;
925
+ * * **async, no callback** (stackful) — the guest blocks mid-stack, which
926
+ * needs genuine wasm-frame suspension: `needsJspi`, at the precise point.
927
+ */
928
+ /**
929
+ * The plain-entered variant of a `[constructor]` export in jspi mode,
930
+ * attached to the promising-wrapped lifted function under this symbol.
931
+ *
932
+ * A WIT constructor is surfaced as a JS class constructor
933
+ * (contracts/embedder-api.md §"Resources"), and a JS constructor cannot
934
+ * await — but in jspi mode every promising-wrapped entry returns a Promise
935
+ * even when the activation completes without suspending (jspi pin (e)). So
936
+ * constructor exports carry a second lifted function whose ENTRY is plain
937
+ * (unwrapped): a constructor that completes synchronously — the
938
+ * overwhelmingly common case; WIT constructors are always sync-typed —
939
+ * returns its rep synchronously through it.
940
+ *
941
+ * The cost is confined to genuinely-suspending constructors, which no JS
942
+ * host can surface as `new` anyway: a blocking built-in reached through the
943
+ * plain entry signals `NeedsJspi` (a capability error, instance left
944
+ * enterable), and a Suspending-wrapped host import reached from the
945
+ * unwrapped frame fails as a trap. Both name the constructor rather than
946
+ * silently deadlocking.
947
+ */
948
+ export const CONSTRUCTOR_SYNC_ENTRY = Symbol("polyengine.constructorSyncEntry");
949
+ export function createLiftedFunction(input) {
950
+ const { name, ft, opts, core, stats, trapState, syncCallStack, allInstances, } = input;
951
+ const inst = opts.instance;
952
+ const store = inst.store;
953
+ const mode = input.suspensionMode ?? "plain";
954
+ // Entry wrapping, half of jspi/bridge.ts's invariant: a lifted export's core
955
+ // function is one of the three activations that can reach a blocking
956
+ // built-in, so it is `promising`-wrapped exactly when the imports are
957
+ // `Suspending`-wrapped.
958
+ const enteredCore = enterWasm(core, mode);
959
+ const taskOpts = {
960
+ async_: opts.async,
961
+ callback: opts.callback !== null,
962
+ stringEncoding: opts.stringEncoding,
963
+ memory: opts.memory,
964
+ };
965
+ // definitions.py `canon_lift` only ever sees consistent combinations; the
966
+ // plan could in principle carry others, so reject at instantiate time.
967
+ if (opts.callback !== null && !opts.async) {
968
+ throw new PlanError(`export '${name}': canonical options carry a callback but are not ` +
969
+ `async (callback is meaningless for a sync lift)`);
970
+ }
971
+ // Instantiate-time consistency check (descriptor-ir.md "Flattening"):
972
+ // flattening computed from the type must agree with the shim's coreType.
973
+ const computed = flattenFunctype(cabiOptions(opts), ft, "lift");
974
+ if (!coreFuncTypeEquals(computed, opts.coreType)) {
975
+ throw new PlanError(`export '${name}': computed flat type ${JSON.stringify(computed)} ` +
976
+ `!= plan coreType ${JSON.stringify(opts.coreType)}`);
977
+ }
978
+ const invokeNow = (hostArgs) => {
979
+ stats.liftedCalls++;
980
+ // A trap remembered during an earlier call must never be attributed to
981
+ // this one (see intrinsics `HostTrapState`).
982
+ if (trapState !== undefined)
983
+ trapState.pending = undefined;
984
+ // Depth of the sync-call scope stack on entry; see the `finally` below.
985
+ const syncCallDepth = syncCallStack?.length ?? 0;
986
+ // Reference `Store.lift` (line 578): the host is the caller, so the
987
+ // entering set is the callee's `self_and_ancestors()`.
988
+ // On refusal, distinguish the corpse from the crowd: a poisoned
989
+ // instance's refusal names the original trap (polyengine#145 ask 1).
990
+ if (!inst.mayEnterFrom(null)) {
991
+ trap(withPoisonCause(inst, `cannot enter component instance ${inst.index} (reentrance forbidden)`));
992
+ }
993
+ // The set this entry locked (definitions.py `ComponentInstance.enter_from`
994
+ // iterates `entering_set`). Remembered so a trap can leave exactly these
995
+ // locked and no others.
996
+ const enteredSet = inst.enteringSet(null);
997
+ inst.enterFrom(null);
998
+ let entered = true;
999
+ let completed = false;
1000
+ let resolved = null;
1001
+ let resolvedSeen = false;
1002
+ const task = new Task(ft, taskOpts, inst, () => hostArgs, (result) => {
1003
+ resolved = result;
1004
+ resolvedSeen = true;
1005
+ stats.tasksResolved++;
1006
+ });
1007
+ const thread = new Thread(task, liftBody({
1008
+ name,
1009
+ ft,
1010
+ opts,
1011
+ core: enteredCore,
1012
+ stats,
1013
+ task,
1014
+ thread: () => thread,
1015
+ mode,
1016
+ }));
1017
+ const finishHostEntry = () => {
1018
+ completed = true;
1019
+ trapIf(!resolvedSeen, `${name}: task finished without resolving (deadlock)`);
1020
+ if (resolved === null) {
1021
+ // definitions.py `Task.cancel`: `on_resolve(None)`. A host-initiated
1022
+ // call has no way to express "cancelled" in its return value, and the
1023
+ // host never requests cancellation, so reaching this is a bug.
1024
+ throw new AssertionError(`${name}: task resolved as cancelled, but the host never ` +
1025
+ `requested cancellation`);
1026
+ }
1027
+ return resultsToHost(resolved);
1028
+ };
1029
+ const unwind = () => {
1030
+ // Unwind any FACT sync-call brackets a trap escaped.
1031
+ //
1032
+ // A trap thrown inside an adapter skips that adapter's
1033
+ // `exit-sync-call`, so its `SyncCallScope` (and the `num_lends` it
1034
+ // holds on the caller's handles) would otherwise survive the call.
1035
+ // wasmtime does not need this: it poisons the whole store on trap
1036
+ // (`Store::call_hook`/panic-on-reuse semantics), so no later call can
1037
+ // observe the stale state. This runtime deliberately supports
1038
+ // post-trap re-entry — the `trapState.pending` reset above exists for
1039
+ // exactly that — so the state has to be unwound instead. Leaving it
1040
+ // would attach the next `transfer-borrow` to a dead scope and leave
1041
+ // lent handles permanently un-droppable ("while borrowed" forever).
1042
+ if (completed)
1043
+ return;
1044
+ // Per-ACTIVATION now (see `Thread.syncCallStack`): unwind the brackets
1045
+ // of every activation this task owns, which a trap inside a FACT adapter
1046
+ // skipped. A task can have several threads, so the loop is over threads.
1047
+ for (const t of task.threads) {
1048
+ while (t.syncCallStack.length > 0) {
1049
+ t.syncCallStack.pop().releaseLenders();
1050
+ }
1051
+ }
1052
+ void syncCallStack;
1053
+ void syncCallDepth;
1054
+ // FACT clears the callee's / caller's `may_leave` flag around each
1055
+ // lift and lower (`fact/trampoline.rs`, `set_may_leave_false`) and
1056
+ // restores it afterwards. A trap in between skips the restore, so an
1057
+ // instance can be left permanently unable to leave — every later call
1058
+ // through an adapter then trips FACT's own `CannotLeaveComponent`
1059
+ // check. With the stack unwound to the host boundary no lift or lower
1060
+ // is in flight, so `may_leave` is true for every instance by
1061
+ // definition; assert that resting state rather than leaving the
1062
+ // component bricked.
1063
+ //
1064
+ // The *entered* instances are excluded: they are poisoned by this trap
1065
+ // (see `poison` below) and must stay exactly as the trap left them.
1066
+ // Restoring their `may_leave` would be tidying the state of an instance
1067
+ // that is no longer allowed to run at all.
1068
+ for (const i of allInstances?.() ?? []) {
1069
+ if (!enteredSet.has(i)) {
1070
+ i.mayLeave = true;
1071
+ }
1072
+ }
1073
+ };
1074
+ const leave = () => {
1075
+ if (!entered)
1076
+ return;
1077
+ entered = false;
1078
+ inst.leaveTo(null);
1079
+ };
1080
+ /**
1081
+ * A trap escaped the task: **do not** release the reentrance lock.
1082
+ *
1083
+ * definitions.py `Store.lift` (line 578) is
1084
+ *
1085
+ * ```python
1086
+ * trap_if(not inst.may_enter_from(caller))
1087
+ * inst.enter_from(caller)
1088
+ * on_cancel = canon_lift(...) # <-- a Trap propagates out of here
1089
+ * inst.leave_to(caller) # <-- and so this never runs
1090
+ * ```
1091
+ *
1092
+ * so a trapping task leaves every instance it entered with
1093
+ * `may_enter == False` permanently. That is the Component Model's
1094
+ * "poisoning": a component that trapped is not in a known state, so it may
1095
+ * never be entered again, and the next call reports `cannot enter
1096
+ * component instance`. `test/async/builtin-trap-poisons-instance.wast`
1097
+ * asserts exactly this, twice.
1098
+ *
1099
+ * Only the entered set is affected; sibling instances stay usable, which
1100
+ * is why the lock is released per-instance rather than by poisoning a
1101
+ * whole store the way wasmtime does.
1102
+ *
1103
+ * Poisoned instances can never rendezvous again, so their handle tables'
1104
+ * live stream/future ends are retired here (#66): parked host operations
1105
+ * settle (DROPPED) instead of hanging forever, and the recorded failure
1106
+ * lets the embedder layer reject them loudly.
1107
+ */
1108
+ const poison = (e) => {
1109
+ entered = false; // consumed: the lock is now permanent
1110
+ // ...for the leaf. The synthetic per-instantiation root (plan v3
1111
+ // amendment 4) is in `enteredSet` too, and leaving IT locked would
1112
+ // poison every instance of the component — exactly the store-wide
1113
+ // behaviour the paragraph above says this runtime deliberately does not
1114
+ // have. Released; see `releaseSyntheticRootOnPoison` in task/mod.ts.
1115
+ inst.releaseSyntheticRootOnPoison();
1116
+ // Through the seam (not retireInstanceAsyncEnds directly) so the
1117
+ // poison marker is recorded too — `Thread.resumeWith` retires this
1118
+ // instance's late settles against it instead of assert-cascading.
1119
+ for (const i of enteredSet) {
1120
+ if (i.isSyntheticRoot)
1121
+ continue;
1122
+ notifyInstancePoisoned(i, e);
1123
+ }
1124
+ };
1125
+ /**
1126
+ * Is `e` a *capability* signal rather than a genuine trap?
1127
+ *
1128
+ * `NeedsJspi` and `PendingCapability` mean "this runtime is incomplete",
1129
+ * not "the component faulted". Poisoning on them is wrong on the
1130
+ * reference's own terms: the operation they stand in for — a synchronous
1131
+ * stream copy, `waitable-set.wait`, a blocking cross-component call —
1132
+ * *blocks and then completes* in definitions.py. `Store.lift` reaches
1133
+ * `leave_to` in every one of those executions, so the instance stays
1134
+ * enterable. Poisoning would attribute a permanent fault to a component
1135
+ * that, on a complete runtime, is perfectly healthy — and it cascades:
1136
+ * one unsupported operation made every later call on that instance report
1137
+ * `cannot enter component instance`, which is neither our real behaviour
1138
+ * nor the reference's.
1139
+ *
1140
+ * What unwinding must still do on this path, and what it must not:
1141
+ *
1142
+ * - MUST release the reentrance lock (`leave`) — the call is over and no
1143
+ * activation of this instance survives it.
1144
+ * - MUST unwind the FACT sync-call scopes and restore `may_leave`
1145
+ * (`unwind`), for exactly the reasons it does after a trap: a bail-out
1146
+ * mid-adapter skips `exit-sync-call` and the `may_leave` restore, and
1147
+ * that state is shared with sibling instances.
1148
+ * - MUST NOT try to "finish" the abandoned operation. A stream end left
1149
+ * in `CopyState.COPYING` with its buffer parked in the shared object is
1150
+ * the honest record of "this copy never happened"; the counterpart has
1151
+ * not been notified and must not be, because on a complete runtime the
1152
+ * copy would still be pending. Likewise a `prepare-call` slot consumed
1153
+ * by a `*-start-call` that then bailed is already cleared by
1154
+ * `takePrepared`, so nothing leaks there.
1155
+ * - MUST NOT resolve or cancel the task: the host call fails, and the
1156
+ * task simply never resolved.
1157
+ *
1158
+ * In other words the instance is left exactly as a *pending* operation
1159
+ * would leave it, which is the truthful state, and the only thing the
1160
+ * embedder loses is the result of this one call.
1161
+ */
1162
+ const isCapabilitySignal = (e) => e instanceof NeedsJspi || e instanceof PendingCapability;
1163
+ try {
1164
+ thread.resume();
1165
+ // definitions.py `canon_lift` (line 2213): the sync driving loop runs
1166
+ // *inside* the enter/leave bracket, over the callee instance's threads.
1167
+ //
1168
+ // It is skipped in jspi mode, and must be. That loop resumes *ready*
1169
+ // threads and traps when there are none — the reference's deadlock
1170
+ // trap. A thread parked on a Promise is neither ready nor waiting: only
1171
+ // a microtask turn can advance it, which a synchronous loop cannot give.
1172
+ // Running it anyway declared a bogus deadlock the moment a sync-lifted
1173
+ // export's activation suspended, which then trap-poisoned the instance
1174
+ // and abandoned the activation mid-bracket — the orphaned
1175
+ // `exit-sync-call` traced across phases 3h-3j.
1176
+ //
1177
+ // `drive` below is the correct driver in that mode: it knows about
1178
+ // `store.awaiting`, still enforces the deadlock trap (no ready thread,
1179
+ // no pending host call, nothing awaiting), and returns a Promise, which
1180
+ // a jspi-mode lifted export returns anyway.
1181
+ if (!ft.async && mode !== "jspi" && !input.allowAsyncCompletion) {
1182
+ driveSyncLift(task);
1183
+ }
1184
+ }
1185
+ catch (e) {
1186
+ unwind();
1187
+ if (isCapabilitySignal(e))
1188
+ leave();
1189
+ else
1190
+ poison(e);
1191
+ throw e;
1192
+ }
1193
+ // The reentrance gate is released here, before the store is pumped:
1194
+ // `Store.tick` re-enters each waiting thread's instance itself
1195
+ // (`enter_from(None)` / `leave_to(None)`), exactly as in the reference,
1196
+ // where `lift_and_run` ticks after `store.invoke` has returned.
1197
+ leave();
1198
+ let pending;
1199
+ try {
1200
+ // Completion is "the task resolved AND its threads have drained", not
1201
+ // merely "resolved". `task.return` resolves the task, but the activation
1202
+ // is not finished until its implicit thread reaches
1203
+ // `exit_implicit_thread` — for a callback task that means running the
1204
+ // loop out to EXIT, which releases `inst.exclusiveThread`.
1205
+ //
1206
+ // In plain mode the two almost always coincide, because the generator
1207
+ // runs to completion inside one `resume()`. Under JSPI they do not: the
1208
+ // guest calls `task.return` while the activation is still suspended, so
1209
+ // the old predicate let the driver return early and the thread was
1210
+ // abandoned mid-loop — leaking the exclusive thread and its table slot.
1211
+ // The lifted call is over when the task has resolved AND this task's
1212
+ // activation is no longer mid-wasm-call. Those are two different events
1213
+ // and both matter (M2 phase 3e):
1214
+ //
1215
+ // * "task resolved" alone abandons a still-running activation. Under
1216
+ // JSPI the guest calls `task.return` while suspended, so returning
1217
+ // there left the callback loop parked forever — leaking the
1218
+ // exclusive thread and its table slot.
1219
+ // * "activation finished" alone deadlocks a *producer* guest, which
1220
+ // legitimately keeps forwarding after `task.return`
1221
+ // (wit-bindgen `wit_stream::new()` + a spawned loop).
1222
+ //
1223
+ // The distinguishing question is *what* the thread is parked on. An
1224
+ // `awaitValue` park means a wasm call is in flight and will settle on
1225
+ // its own, so we must keep draining. A park in `store.waiting` means the
1226
+ // activation is waiting on a scheduler condition only the embedder can
1227
+ // satisfy — that is a **background activation**: we return to the host
1228
+ // and leave the thread live, and later `drive`/`pump` calls (host stream
1229
+ // writes, the next export call) go on servicing it.
1230
+ const midWasmCall = () => task.threads.some((t) => store.awaiting.has(t));
1231
+ pending = drive(store, () => resolvedSeen && !midWasmCall(), `export '${name}'`);
1232
+ }
1233
+ catch (e) {
1234
+ unwind();
1235
+ throw e;
1236
+ }
1237
+ if (pending === undefined) {
1238
+ try {
1239
+ return finishHostEntry();
1240
+ }
1241
+ catch (e) {
1242
+ unwind();
1243
+ throw e;
1244
+ }
1245
+ }
1246
+ return pending.then(finishHostEntry, (e) => {
1247
+ unwind();
1248
+ throw e;
1249
+ });
1250
+ };
1251
+ return (...hostArgs) => {
1252
+ if (hostArgs.length !== ft.params.length) {
1253
+ throw new TypeError(`${name}: expected ${ft.params.length} argument(s), got ${hostArgs.length}`);
1254
+ }
1255
+ // THE HOP-QUIESCENCE GATE (jspi mode only; hop_atomicity_test.ts).
1256
+ //
1257
+ // A promising-wrapped entry settles a microtask AFTER the guest's core
1258
+ // call returns, even when nothing suspended (jspi pin (j)) — so there
1259
+ // is a hop between core return and the host-side result LIFT, and the
1260
+ // reentrance bracket has already been released by then (`leave()` runs
1261
+ // when the first segment parks). In the reference no such window
1262
+ // exists: `canon_lift` for sync options runs core + lift atomically
1263
+ // inside one entered bracket. Admitting another host call into the
1264
+ // window lets a full guest turn mutate the memory the pending lift
1265
+ // will read — observed as `Trap: list too long` lifting the wosh
1266
+ // engine's `tick` (`list<list<u8>>`) after a concurrent `feed-keys`
1267
+ // turn reused the return area.
1268
+ //
1269
+ // The gate: defer this call until the instance has no HOP-parked
1270
+ // activation. A hop-park is an `awaiting` thread with no owning
1271
+ // `SuspensionPoint` — the same discriminator `hasRunnableWork` uses;
1272
+ // genuinely JSPI-suspended activations (SuspensionPoint-owned) keep
1273
+ // today's documented interleaving (the wasmtime-tracking divergence in
1274
+ // jspi/bridge.ts), which host-import re-entry patterns rely on.
1275
+ // Plain mode has no hops and keeps its synchronous fast path exactly.
1276
+ if (mode === "jspi" && entryHopThreads(store, inst).length > 0) {
1277
+ return awaitHopQuiescence(store, inst).then(() => invokeNow(hostArgs));
1278
+ }
1279
+ return invokeNow(hostArgs);
1280
+ };
1281
+ }
1282
+ /**
1283
+ * Threads of `inst` parked on a promising-entry hop: in `store.awaiting`
1284
+ * with no `SuspensionPoint` owner in `store.waiting` (that would be a
1285
+ * genuine JSPI suspension). Mirrors `Store.hasRunnableWork`'s (b)/(c)
1286
+ * split.
1287
+ */
1288
+ function entryHopThreads(store, inst) {
1289
+ if (store.awaiting.size === 0)
1290
+ return [];
1291
+ const suspended = new Set();
1292
+ for (const w of store.waiting) {
1293
+ const owner = w.owner;
1294
+ if (owner !== undefined && owner !== null)
1295
+ suspended.add(owner);
1296
+ }
1297
+ const out = [];
1298
+ for (const t of store.awaiting) {
1299
+ const tt = t;
1300
+ if (tt.task.inst === inst && !suspended.has(t))
1301
+ out.push(tt);
1302
+ }
1303
+ return out;
1304
+ }
1305
+ /**
1306
+ * Wait until `inst` has no hop-parked activation. Each settled hop is
1307
+ * serviced synchronously (`serviceSettled` runs the lift segment), after
1308
+ * which the activation either completed or re-parked; re-derive and
1309
+ * repeat. Progress is guaranteed: a hop promise settles on the engine's
1310
+ * own schedule, independent of any other activation of the instance, and
1311
+ * a settled-but-unserviced hop resolves the race instantly. Multiple
1312
+ * gated callers re-derive independently (no strict FIFO; starvation-free
1313
+ * in practice because hops are sub-microtask).
1314
+ */
1315
+ async function awaitHopQuiescence(store, inst) {
1316
+ for (;;) {
1317
+ const hops = entryHopThreads(store, inst);
1318
+ if (hops.length === 0)
1319
+ return;
1320
+ await Promise.race(hops.map((t) => (t.awaiting ?? Promise.resolve()).then(() => undefined, () => undefined)));
1321
+ store.serviceSettled();
1322
+ }
1323
+ }
1324
+ // ---------------------------------------------------------------------------
1325
+ // Host-initiated resource destructors (#160)
1326
+ // ---------------------------------------------------------------------------
1327
+ /**
1328
+ * The canonical function type of a destructor: definitions.py
1329
+ * `canon_resource_drop` (line 2326) — `FuncType([U32Type()], [], async_ = False)`.
1330
+ */
1331
+ const DTOR_FT = {
1332
+ params: [{ kind: "u32" }],
1333
+ results: [],
1334
+ async: false,
1335
+ };
1336
+ /**
1337
+ * `CanonicalOptions(async_ = False)` (definitions.py line 2325): every field
1338
+ * at its inert default. A dtor takes one flat `i32` and returns nothing, so
1339
+ * no memory / realloc / post-return / callback is ever reached.
1340
+ */
1341
+ function dtorOptions(instance) {
1342
+ return {
1343
+ stringEncoding: "utf8",
1344
+ memory: null,
1345
+ realloc: null,
1346
+ postReturn: null,
1347
+ callback: null,
1348
+ async: false,
1349
+ cancellable: false,
1350
+ coreType: { params: ["i32"], results: [] },
1351
+ instance,
1352
+ };
1353
+ }
1354
+ /**
1355
+ * Build the host-callable entry for a resource destructor — a full canonical
1356
+ * **lift**, exactly as definitions.py `canon_resource_drop` (line 2319) does:
1357
+ *
1358
+ * ```python
1359
+ * opts = CanonicalOptions(async_ = False)
1360
+ * ft = FuncType([U32Type()], [], async_ = False)
1361
+ * dtor = rt.dtor or (lambda rep: [])
1362
+ * callee = inst.store.lift(dtor, ft, opts, rt.impl)
1363
+ * ```
1364
+ *
1365
+ * Before #160 the host-initiated path (embedder `drop()`, the GC backstop,
1366
+ * `dropOwn`) hand-rolled the bracket in cabi/handles.ts `callDtorGated`: a
1367
+ * bare call to the dtor with `enterFrom(null)` HELD across the returned
1368
+ * promise. Three defects followed from having no Task/Thread behind the
1369
+ * activation:
1370
+ *
1371
+ * - **#160 itself**: the held bracket left the impl instance non-enterable,
1372
+ * so `Store.tick`'s enterability filter (#155) could never resume a
1373
+ * suspension point belonging to the dtor's own activation. The completion
1374
+ * promise sat in `pendingHostCalls` looking like external work, and every
1375
+ * driver parked on it forever.
1376
+ * - it was the runtime's only `enterFrom(null)` bracket spanning an await —
1377
+ * the macro-scale reachability window of the #156 class, through which a
1378
+ * sibling instance looked non-enterable from the synthetic root.
1379
+ * - built-ins reached inside the dtor had no ambient task (`currentTask()`
1380
+ * → `PendingCapability`, or a foreign-task misattribution, the #24 class).
1381
+ *
1382
+ * Under the lift harness all three go away structurally: the activation has a
1383
+ * real `Task` + implicit `Thread`, the entry bracket is released when the
1384
+ * first segment parks (`leave()` before `drive`), and settled tails flow
1385
+ * through `serviceSettled` like any other lifted sync call.
1386
+ *
1387
+ * The returned function takes the rep and returns either `undefined` (the
1388
+ * activation completed synchronously — the overwhelmingly common case) or a
1389
+ * Promise, exactly like any lifted sync export in jspi mode.
1390
+ */
1391
+ export function createDtorEntry(input) {
1392
+ const mode = input.suspensionMode ?? "plain";
1393
+ const raw = input.dtor ?? (() => undefined);
1394
+ // A dtor's core type is `(i32) -> ()`, but the *host*-supplied dtors this
1395
+ // helper also serves (embedder test doubles, `ResourceTypeInfo` built
1396
+ // directly) are ordinary JS functions whose incidental return value would
1397
+ // otherwise trip `normalizeCoreValues`' arity check. Discard it — except a
1398
+ // thenable, which is the activation itself and must reach `awaitCore`'s
1399
+ // park. Not applied in jspi mode: `WebAssembly.promising` only accepts a
1400
+ // wasm callable, so the core must be passed through untouched there (and a
1401
+ // real wasm dtor returns nothing by construction).
1402
+ const core = mode === "jspi" ? raw : ((rep) => {
1403
+ const r = raw(rep);
1404
+ return isPromiseLike(r) ? r : undefined;
1405
+ });
1406
+ const lifted = createLiftedFunction({
1407
+ name: input.name ?? "[resource-dtor]",
1408
+ ft: DTOR_FT,
1409
+ opts: dtorOptions(input.instance),
1410
+ core,
1411
+ stats: input.stats ?? newStats(),
1412
+ suspensionMode: mode,
1413
+ trapState: input.trapState,
1414
+ syncCallStack: input.syncCallStack,
1415
+ allInstances: input.allInstances,
1416
+ // The host does not wait for a destructor: `drop(): void` is
1417
+ // non-blocking, and an unfinished dtor's tail is driven by the store.
1418
+ allowAsyncCompletion: true,
1419
+ });
1420
+ return (rep) => lifted(rep);
1421
+ }
1422
+ /**
1423
+ * Run a host-initiated drop of a guest (or host-implemented) resource rep —
1424
+ * the observable remainder of `canon_resource_drop` for an owning handle when
1425
+ * the holder is the host (`caller = None`, `Store.invoke`).
1426
+ *
1427
+ * A failure that arrives asynchronously has no frame to propagate into, so it
1428
+ * is parked on the store's host-failure channel (first failure wins), where
1429
+ * the next driven call surfaces it. The completion promise is deliberately
1430
+ * NOT registered in `store.pendingHostCalls`: that registration was #160's
1431
+ * lie — it claims *external* work for a promise whose settlement may need
1432
+ * this very scheduler. The dtor's genuine external dependencies (its host
1433
+ * imports) register themselves when they park. Poisoning on a trap now
1434
+ * happens inside the lift harness (`poison()` in `createLiftedFunction`).
1435
+ */
1436
+ export function hostDtorCall(rt, rep) {
1437
+ const impl = rt.impl;
1438
+ // An imported (host-implemented) resource has `impl === null` by
1439
+ // construction (executor `bindImportedResources`): there is no component
1440
+ // instance to gate entry into, so the dtor is called directly, as before.
1441
+ if (impl === null) {
1442
+ rt.dtor?.(rep);
1443
+ return;
1444
+ }
1445
+ if (rt.dtorHost === null) {
1446
+ // The executor pre-wires `dtorHost` for every defined resource; this is
1447
+ // the direct-construction path (embedder test doubles, and any token that
1448
+ // reached the host without going through the `resource` initializer).
1449
+ rt.dtorHost = createDtorEntry({
1450
+ dtor: rt.dtor,
1451
+ instance: impl,
1452
+ });
1453
+ }
1454
+ const out = rt.dtorHost(rep);
1455
+ if (isPromiseLike(out)) {
1456
+ const store = impl.store;
1457
+ Promise.resolve(out).catch((e) => {
1458
+ if (store !== undefined && store.hostFailure === undefined) {
1459
+ store.hostFailure = e;
1460
+ }
1461
+ });
1462
+ }
1463
+ }
1464
+ /**
1465
+ * Call into wasm and hand back the result, awaiting it only if it is a
1466
+ * Promise.
1467
+ *
1468
+ * This is the whole of the jspi entry seam. In **plain** mode the entry is not
1469
+ * `promising`-wrapped, `callCore` returns core values, and this returns them
1470
+ * without yielding — no await, no Promise allocation, the identical
1471
+ * synchronous path M1 shipped. In **jspi** mode the entry *is* wrapped, so the
1472
+ * call returns a Promise (jspi pin (e)) and we park the thread on it via the
1473
+ * `awaitValue` block request; the driving loop resumes us with the values, or
1474
+ * throws the rejection in (a post-resume trap).
1475
+ */
1476
+ export function* awaitCore(fn, args,
1477
+ // deno-lint-ignore no-explicit-any
1478
+ thread) {
1479
+ // Enter wasm with the activation-attached ambient in scope. In jspi mode the
1480
+ // engine captures this context when it registers its resumption, so a
1481
+ // built-in called by the resumed activation can recover its thread even when
1482
+ // nobody is driving (see `withActivation`).
1483
+ const raw = withActivation(thread, () => callCore(fn, args));
1484
+ // `callCore` normalizes a bare value to a one-element array; a promising
1485
+ // entry yields `[Promise]`.
1486
+ if (raw.length === 1 && isPromiseLike(raw[0])) {
1487
+ const settled = yield {
1488
+ readyFunc: null,
1489
+ cancellable: false,
1490
+ // A rejection of the promising Promise is a core trap by another route
1491
+ // (jspi pin (e)); translate it exactly as `callCore` translates a
1492
+ // synchronous throw, so the embedder sees one `Trap` vocabulary in both
1493
+ // modes (see `mapCoreException`).
1494
+ awaitValue: Promise.resolve(raw[0]).then(undefined, (e) => {
1495
+ throw mapCoreException(e);
1496
+ }),
1497
+ };
1498
+ if (settled === undefined)
1499
+ return [];
1500
+ return Array.isArray(settled) ? settled : [settled];
1501
+ }
1502
+ return raw;
1503
+ }
1504
+ /** definitions.py `CallbackCode` (line 2220). */
1505
+ var CallbackCode;
1506
+ (function (CallbackCode) {
1507
+ CallbackCode[CallbackCode["EXIT"] = 0] = "EXIT";
1508
+ CallbackCode[CallbackCode["YIELD"] = 1] = "YIELD";
1509
+ CallbackCode[CallbackCode["WAIT"] = 2] = "WAIT";
1510
+ })(CallbackCode || (CallbackCode = {}));
1511
+ const CALLBACK_CODE_MAX = 2;
1512
+ /** definitions.py `unpack_callback_result` (line 2226). */
1513
+ export function unpackCallbackResult(packed) {
1514
+ // Reference parity insurance only: callers already guarantee this range via
1515
+ // core-result normalization before calling in.
1516
+ assert_(packed >= 0 && packed < 2 ** 32, `unpack-callback-result: packed out of range: ${packed}`);
1517
+ const code = packed & 0xf;
1518
+ trapIf(code > CALLBACK_CODE_MAX, `invalid callback code ${code}`);
1519
+ return [code, packed >>> 4];
1520
+ }
1521
+ /**
1522
+ * The body of `canon_lift`'s implicit thread (definitions.py line 2155),
1523
+ * as a generator so its block points are real suspension points of the
1524
+ * host-side thread model (see task/scheduler.ts).
1525
+ */
1526
+ function* liftBody(input) {
1527
+ const { name, ft, opts, core, stats, task } = input;
1528
+ const thread = input.thread();
1529
+ const inst = opts.instance;
1530
+ if (!(yield* task.enterImplicitThread(thread)))
1531
+ return;
1532
+ const cx = new LiftLowerContext(cabiOptions(opts), inst, task);
1533
+ const args = task.start();
1534
+ const flatArgs = lowerFlatValues(cx, MAX_FLAT_PARAMS, args, ft.params);
1535
+ if (!opts.async) {
1536
+ const flatResults = normalizeCoreValues(yield* awaitCore(core, flatArgs, thread), opts.coreType.results, `${name} results`);
1537
+ const results = liftFlatValues(cx, MAX_FLAT_RESULTS, new CoreValueIter(flatResults), ft.results);
1538
+ task.return_(results);
1539
+ // Post-return runs after the results were read out of guest memory,
1540
+ // with may_leave cleared (reference canon_lift).
1541
+ const postReturn = require(opts.postReturn, `${name} post-return`);
1542
+ if (postReturn !== null) {
1543
+ assert_(inst.mayLeave, "post-return with may_leave already false");
1544
+ inst.mayLeave = false;
1545
+ callCore(postReturn, flatResults);
1546
+ inst.mayLeave = true;
1547
+ stats.postReturnsRun++;
1548
+ }
1549
+ task.exitImplicitThread(thread);
1550
+ return;
1551
+ }
1552
+ if (opts.callback === null) {
1553
+ // definitions.py line 2179: `[] = call_and_trap_on_throw(callee, flat_args)`
1554
+ // — the guest keeps running on its own stack and blocks inside wasm at
1555
+ // whatever built-in it chooses. There is no return-to-host between the
1556
+ // call and the block, so the only way to model it is genuine wasm-frame
1557
+ // suspension.
1558
+ //
1559
+ // In jspi mode that is exactly what happens and no special handling is
1560
+ // needed: the entry is `promising`-wrapped, so the activation suspends on
1561
+ // whichever blocking built-in it reaches and `awaitCore` parks this thread
1562
+ // until it finishes. Results arrive through `task.return`, so there is
1563
+ // nothing to lift here.
1564
+ if (input.mode !== "jspi") {
1565
+ needsJspi(`stackful async lift of export '${name}' (async canonical options ` +
1566
+ `without a callback)`);
1567
+ }
1568
+ yield* awaitCore(core, flatArgs, thread);
1569
+ task.exitImplicitThread(thread);
1570
+ return;
1571
+ }
1572
+ // --- callback ABI (definitions.py lines 2183-2214) ----------------------
1573
+ //
1574
+ // Stackless by construction: every wasm activation *returns* a packed code,
1575
+ // and all waiting happens on the host side between activations. This is the
1576
+ // path wit-bindgen 0.60 emits for every async export, and it needs no JSPI.
1577
+ // The callback export is the second of the three entries that can reach a
1578
+ // blocking built-in (jspi/bridge.ts's invariant), so it is wrapped exactly
1579
+ // like the lifted core. Leaving it plain while the core was promising was a
1580
+ // *mixed* activation, which pin (c) punishes: the first Suspending import
1581
+ // it reached would trap.
1582
+ const callback = enterWasm(require(opts.callback, `${name} callback`), input.mode);
1583
+ const [packed] = normalizeCoreValues(yield* awaitCore(core, flatArgs, thread), opts.coreType.results, `${name} results`);
1584
+ yield* runCallbackLoop({ name, task, thread, inst, callback, packed, stats });
1585
+ task.exitImplicitThread(thread);
1586
+ }
1587
+ // ---------------------------------------------------------------------------
1588
+ // canon lower
1589
+ // ---------------------------------------------------------------------------
1590
+ /**
1591
+ * Build the core-callable body for one lowered host import (reference
1592
+ * `canon_lower`, definitions.py line 2242).
1593
+ *
1594
+ * Sync and async lowers share one `Subtask` and one pair of
1595
+ * `on_start`/`on_resolve` closures, exactly as the reference does; the sync
1596
+ * case is the degenerate one where the callee resolves before returning.
1597
+ *
1598
+ * The host callee is a plain JS function. If it returns a **Promise**, the
1599
+ * subtask resolves when that promise settles:
1600
+ *
1601
+ * * async lower — fully supported and JSPI-free. The guest gets a STARTED
1602
+ * subtask back, joins it to a waitable set, returns WAIT from its
1603
+ * callback, and the scheduler delivers the SUBTASK event once the promise
1604
+ * settles. This is the flagship capability of this phase: an ordinary
1605
+ * `async` JS function is a valid Component Model async import.
1606
+ * * sync lower — the guest's wasm frame would have to block
1607
+ * (`thread.wait_until(subtask.resolved)`, line 2286), so: `needsJspi`.
1608
+ */
1609
+ export function createLoweredImport(input) {
1610
+ const { name, ft, opts, hostFn, stats, mode, suspendable } = input;
1611
+ const inst = opts.instance;
1612
+ const store = inst.store;
1613
+ const computed = flattenFunctype(cabiOptions(opts), ft, "lower");
1614
+ if (!coreFuncTypeEquals(computed, opts.coreType)) {
1615
+ throw new PlanError(`import '${name}': computed flat type ${JSON.stringify(computed)} ` +
1616
+ `!= plan coreType ${JSON.stringify(opts.coreType)}`);
1617
+ }
1618
+ // definitions.py lines 2250-2256.
1619
+ const maxFlatParams = opts.async ? MAX_FLAT_ASYNC_PARAMS : MAX_FLAT_PARAMS;
1620
+ const maxFlatResults = opts.async ? 0 : MAX_FLAT_RESULTS;
1621
+ return (...rawFlatArgs) => {
1622
+ stats.loweredCalls++;
1623
+ // Reference canon_lower: trap_if(!inst.may_leave).
1624
+ trapIf(!inst.mayLeave, `cannot leave component instance ${inst.index} (may_leave violation)`);
1625
+ const subtask = new Subtask();
1626
+ const cx = new LiftLowerContext(cabiOptions(opts), inst, subtask);
1627
+ const vi = new CoreValueIter(normalizeCoreValues(rawFlatArgs, opts.coreType.params, `${name} args`));
1628
+ /**
1629
+ * definitions.py's `maybe_on_progress`: a no-op until the subtask has been
1630
+ * given a handle index, then the pending-event setter. Assigning it only
1631
+ * after the callee returned unresolved is deliberate in the reference —
1632
+ * an eagerly-resolving callee must never produce an event.
1633
+ */
1634
+ let onProgress = () => { };
1635
+ const onStart = () => {
1636
+ onProgress();
1637
+ assert_(subtask.state === SubtaskState.STARTING, `${name}: on_start on a started subtask`);
1638
+ subtask.state = SubtaskState.STARTED;
1639
+ return liftFlatValues(cx, maxFlatParams, vi, ft.params);
1640
+ };
1641
+ const onResolve = (result) => {
1642
+ onProgress();
1643
+ if (result === null) {
1644
+ assert_(subtask.cancellationRequested, `${name}: resolved as cancelled without a cancellation request`);
1645
+ subtask.resolve(subtask.state === SubtaskState.STARTING
1646
+ ? SubtaskState.CANCELLED_BEFORE_STARTED
1647
+ : SubtaskState.CANCELLED_BEFORE_RETURNED, []);
1648
+ return;
1649
+ }
1650
+ assert_(subtask.state === SubtaskState.STARTED, `${name}: on_resolve on a subtask that never started`);
1651
+ // Spilled results use the trailing retptr lane(s) of the flat args
1652
+ // (reference passes the same iterator as out_param).
1653
+ const flatResults = lowerFlatValues(cx, maxFlatResults, result, ft.results, vi);
1654
+ subtask.resolve(SubtaskState.RETURNED, flatResults);
1655
+ };
1656
+ // --- invoke the host callee (the reference's `callee(...)`, line 2283) --
1657
+ //
1658
+ // definitions.py assigns the callee's `OnCancel` here:
1659
+ // `subtask.on_cancel = callee(on_start, on_resolve, caller = ...)`
1660
+ //
1661
+ // A host import is a plain JS function and offers no cancellation
1662
+ // channel — there is nothing to forward a request to. The faithful model
1663
+ // is therefore a handler that *accepts and ignores* the request, which is
1664
+ // exactly what the reference permits: `canon_subtask_cancel` (line 2469)
1665
+ // calls `on_cancel` and then re-checks `subtask.resolved()`; a callee that
1666
+ // declines to cancel promptly leaves the subtask unresolved, and the async
1667
+ // form returns BLOCKED while the sync form waits. The subtask still
1668
+ // resolves normally when the promise settles — cancellation is a request,
1669
+ // not a guarantee.
1670
+ //
1671
+ // Leaving `on_cancel` null instead made a *legal* `subtask.cancel` crash
1672
+ // with an internal AssertionError, which is neither reference behaviour
1673
+ // nor a sanctioned incompleteness signal.
1674
+ subtask.onCancel = () => { };
1675
+ const args = onStart();
1676
+ const raw = hostFn(...args);
1677
+ const toResults = (v) => ft.results.length === 0 ? [] : [v];
1678
+ if (isPromiseLike(raw)) {
1679
+ if (!opts.async) {
1680
+ if (mode !== "jspi" || !suspendable) {
1681
+ // definitions.py line 2286: `thread.wait_until(subtask.resolved)` —
1682
+ // blocking the calling *wasm frame*. Parking needs BOTH jspi mode
1683
+ // and the embedder's per-declaration `suspending()` marker: the
1684
+ // Suspending wrap is applied per-declaration (`importValue`), so an
1685
+ // unmarked import physically cannot suspend, whatever the mode.
1686
+ //
1687
+ // A capability signal is expressly NON-poisoning (amendment 2, #91
1688
+ // scope clarification): the caller keeps running, so the borrows
1689
+ // `onStart` lifted into this subtask must be discharged here or
1690
+ // its lenders stay elevated forever and later `resource.drop`s
1691
+ // trap "handle still lent out" on a healthy instance (found
1692
+ // during the #106 closure; same class as the fact_calls.ts #91
1693
+ // sites).
1694
+ subtask.unwindLenders();
1695
+ needsJspi(suspendable
1696
+ ? `synchronous lower of import '${name}', whose host ` +
1697
+ `implementation returned a Promise (the guest's wasm frame ` +
1698
+ `must block)`
1699
+ : `synchronous lower of import '${name}', whose host ` +
1700
+ `implementation returned a Promise; a sync-typed import may ` +
1701
+ `only park the frame when declared with suspending() ` +
1702
+ `(contracts/embedder-api.md §"Functions and async")`);
1703
+ }
1704
+ // The park (A1): the reference's plain, NON-cancellable wait — a
1705
+ // cancel request against the caller stays pending-cancel and is
1706
+ // delivered at its next cancellable wait, exactly as for any other
1707
+ // mid-frame block. The instance-entry gate stays HELD across the park
1708
+ // (the #43 hold rule; see `blockCurrentActivation`'s GATE LIFETIME
1709
+ // note).
1710
+ //
1711
+ // The settle handler only RECORDS the outcome. All CABI work —
1712
+ // `onResolve`'s result lowering (which may re-enter the guest through
1713
+ // realloc) and `deliverResolve` — is deferred to `produce`, which
1714
+ // runs at resume time under the suspension point's ambient claim.
1715
+ // Lowering from the bare promise continuation instead would execute
1716
+ // guest code in an unattributed chunk — the issue-#24 class the
1717
+ // attribution sentinels exist to prevent.
1718
+ let outcome;
1719
+ // The async arm runs `onResolve` — result lowering, including possible
1720
+ // realloc re-entry into the guest — in this bare promise continuation,
1721
+ // where the sync arm above defers all CABI work to `produce` (the
1722
+ // issue-#24 attribution note). The asymmetry is deliberate (#93): here
1723
+ // no wasm frame is suspended mid-call — the guest returned BLOCKED and
1724
+ // is between activations, which is exactly when the reference's
1725
+ // `on_resolve` runs (the callee's turn), so there is no activation for
1726
+ // the sentinels to attribute this chunk to. Lowering failures are host
1727
+ // failures, not guest traps: they land on `store.hostFailure` and the
1728
+ // driving loop raises them site-named (pinned by
1729
+ // tests/async_lower_onresolve_failure_test.ts).
1730
+ const promise = Promise.resolve(raw).then((v) => {
1731
+ store.pendingHostCalls.delete(promise);
1732
+ outcome = { value: v };
1733
+ }, (e) => {
1734
+ store.pendingHostCalls.delete(promise);
1735
+ outcome = { error: e };
1736
+ });
1737
+ // Registered so the driver's deadlock probe counts this park as
1738
+ // externally-wakeable (driveAsync: `pendingHostCalls.size === 0` is a
1739
+ // precondition of the deadlock verdict) and so teardown can observe
1740
+ // the outstanding call, mirroring the async arm below.
1741
+ store.pendingHostCalls.add(promise);
1742
+ // LENDER DISCHARGE ON EVERY SETTLE PATH (#106, the sibling of the
1743
+ // fact_calls.ts sync-start park's #102 enumeration):
1744
+ //
1745
+ // * produce SUCCESS -> `onResolve` + `deliverResolve` release the
1746
+ // lenders; the `onSettled` backstop below observes
1747
+ // `resolveDelivered()` and is a no-op.
1748
+ // * produce THROW -> exempt-by-poisoning under amendment 2
1749
+ // (contracts/intrinsics.md v0.2 §2: release is owed only on exits
1750
+ // that do NOT poison the caller). Every rejection that reaches
1751
+ // this park is a poisoning trap in the CALLER's own frame:
1752
+ // branded `ComponentException`s on fallible imports were already resolved
1753
+ // into err-shaped VALUES by the conventions layer
1754
+ // (embedder/instantiate.ts `#wrapImportFn`'s `fail` — they take
1755
+ // the success arm above), every other conventions-layer throw is
1756
+ // a `Trap`, and a raw-executor rejection is a declared host bug
1757
+ // that traps (empirical fact (e)). No capability signal can
1758
+ // originate inside `produce`: this park only exists once jspi +
1759
+ // `suspending()` were both granted. The backstop's unwind here is
1760
+ // belt-and-braces bookkeeping on a poisoned instance, not an
1761
+ // obligation.
1762
+ // * abandon -> produce never runs, and an abandoned park
1763
+ // does NOT poison the caller (pinned by
1764
+ // resource_lender_park_settle_test.ts) — without the hook the
1765
+ // subtask's lenders stayed elevated forever and later
1766
+ // `resource.drop`s trapped "handle still lent out". The hook is
1767
+ // the fix.
1768
+ return blockCurrentActivation({
1769
+ store,
1770
+ task: currentTask(),
1771
+ readyFunc: () => outcome !== undefined,
1772
+ cancellable: false,
1773
+ produce: () => {
1774
+ const done = outcome;
1775
+ if ("error" in done) {
1776
+ // A rejection of a sync-typed import is a host failure: it
1777
+ // reaches the guest as a rejection of the import's Promise,
1778
+ // which the engine turns back into a wasm trap (empirical
1779
+ // fact (e); `SuspensionPoint` routes a produce-throw through
1780
+ // exactly that path). Branded `ComponentException`s never reach the raw
1781
+ // boundary — the conventions layer resolves them into
1782
+ // err-shaped values one layer up (see the settle-path
1783
+ // enumeration above).
1784
+ throw done.error;
1785
+ }
1786
+ onResolve(toResults(done.value));
1787
+ subtask.deliverResolve();
1788
+ assert_(vi.done(), `${name}: unconsumed flat arguments`);
1789
+ const flatResults = subtask.flatResults;
1790
+ if (flatResults.length === 0)
1791
+ return undefined;
1792
+ if (flatResults.length === 1)
1793
+ return flatResults[0];
1794
+ return flatResults;
1795
+ },
1796
+ onSettled: () => subtask.unwindLenders(),
1797
+ });
1798
+ }
1799
+ const promise = Promise.resolve(raw).then((v) => {
1800
+ store.pendingHostCalls.delete(promise);
1801
+ try {
1802
+ onResolve(toResults(v));
1803
+ }
1804
+ catch (e) {
1805
+ store.hostFailure = e;
1806
+ }
1807
+ }, (e) => {
1808
+ store.pendingHostCalls.delete(promise);
1809
+ store.hostFailure = e;
1810
+ });
1811
+ store.pendingHostCalls.add(promise);
1812
+ }
1813
+ else {
1814
+ onResolve(toResults(raw));
1815
+ }
1816
+ // definitions.py line 2284: a sync-*typed* callee must have resolved.
1817
+ assert_(ft.async || subtask.resolved(), `${name}: a non-async-typed import must resolve before returning`);
1818
+ if (!opts.async) {
1819
+ if (!subtask.resolved()) {
1820
+ needsJspi(`synchronous lower of import '${name}' on an unresolved subtask`);
1821
+ }
1822
+ subtask.deliverResolve();
1823
+ assert_(vi.done(), `${name}: unconsumed flat arguments`);
1824
+ const flatResults = subtask.flatResults;
1825
+ if (flatResults.length === 0)
1826
+ return undefined;
1827
+ if (flatResults.length === 1)
1828
+ return flatResults[0];
1829
+ return flatResults;
1830
+ }
1831
+ // --- async lower (definitions.py lines 2289-2309) ----------------------
1832
+ if (subtask.resolved()) {
1833
+ // Eager-resolve fast path: no handle, no event, no waitable — the guest
1834
+ // learns the call is done from the return value alone.
1835
+ subtask.deliverResolve();
1836
+ assert_(subtask.flatResults.length === 0, `${name}: async lower produced flat results`);
1837
+ return SubtaskState.RETURNED;
1838
+ }
1839
+ const subtaski = inst.handles.add(subtask);
1840
+ onProgress = () => subtask.setSubtaskPendingEvent(subtaski);
1841
+ return packSubtaskResult(subtask.state, subtaski);
1842
+ };
1843
+ }
1844
+ /**
1845
+ * The callback-ABI dispatch loop of `canon_lift` (definitions.py lines
1846
+ * 2183-2214), factored out so both entry points share one implementation:
1847
+ *
1848
+ * * a host-boundary lift (`liftBody` above), and
1849
+ * * a FACT cross-component call, where the host invokes an async-lifted
1850
+ * callee on the caller's behalf (`intrinsics/fact_calls.ts`).
1851
+ *
1852
+ * `packed` is the code the *initial* activation returned; the loop runs until
1853
+ * it sees EXIT, invoking the callback export with each delivered event.
1854
+ */
1855
+ export function* runCallbackLoop(input) {
1856
+ const { name, task, thread, inst, callback, stats } = input;
1857
+ let [code, si] = unpackCallbackResult(input.packed);
1858
+ while (code !== CallbackCode.EXIT) {
1859
+ // definitions.py line 2187, verbatim shape: the implicit thread of a
1860
+ // needs-exclusive callback task holds the slot on every loop iteration.
1861
+ // (The former per-iteration `holding` check tolerated a resolved task
1862
+ // that had released the slot at a mid-frame block — the release-at-BLOCK
1863
+ // divergence removed by issue #43. Under the hold rule, which is both the
1864
+ // reference's and wasmtime's — `do_not_enter` is set for each callback
1865
+ // invocation, concurrent.rs :942/:960 — the invariant is unconditional.)
1866
+ assert_(task.needsExclusive() &&
1867
+ inst.exclusiveThread === task.implicitThread, "callback loop without holding the exclusive thread");
1868
+ // Releasing the exclusive thread across the wait is what lets *another*
1869
+ // task of the same instance enter and run while this one waits — the
1870
+ // whole point of the callback ABI (definitions.py line 2188). Equally,
1871
+ // RETAKING it below is what defers event delivery to a parked-between-
1872
+ // invocations task while any invocation of this instance is mid-frame:
1873
+ // the `() => inst.exclusiveThread === null` guard on the wait is the
1874
+ // reference's `wait_for_event_and(lambda: not inst.exclusive_thread)`
1875
+ // (line 2199) and wasmtime's `GuestCall::is_ready` DeliverEvent arm,
1876
+ // which requires `!do_not_enter` (concurrent.rs :765).
1877
+ inst.exclusiveThread = null;
1878
+ let event;
1879
+ switch (code) {
1880
+ case CallbackCode.YIELD: {
1881
+ const cancelled = yield* thread.waitUntil(() => inst.exclusiveThread === null, true);
1882
+ event = cancelled
1883
+ ? [EventCode.TASK_CANCELLED, 0, 0]
1884
+ : [EventCode.NONE, 0, 0];
1885
+ break;
1886
+ }
1887
+ case CallbackCode.WAIT: {
1888
+ const wset = inst.handles.get(si);
1889
+ trapIf(!(wset instanceof WaitableSet), `callback returned WAIT with index ${si}, which is not a waitable set`);
1890
+ event = yield* wset.waitForEventAnd(thread, () => inst.exclusiveThread === null, true);
1891
+ break;
1892
+ }
1893
+ default:
1894
+ trap(`invalid callback code ${code}`);
1895
+ }
1896
+ assert_(inst.exclusiveThread === null, "exclusive thread taken while this task was waiting");
1897
+ inst.exclusiveThread = task.implicitThread;
1898
+ stats.callbackInvocations++;
1899
+ const [next] = normalizeCoreValues(yield* awaitCore(callback, [event[0], event[1], event[2]], thread), ["i32"], `${name} callback result`);
1900
+ [code, si] = unpackCallbackResult(next);
1901
+ }
1902
+ }