@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,579 @@
1
+ // The JSPI ↔ scheduler bridge: turning a blocking canonical built-in into a
2
+ // genuinely suspended wasm activation.
3
+ //
4
+ // This is the phase-1 seam swap. Nothing in the task model changes: `Thread`,
5
+ // `BlockRequest` and `waitUntil` are exactly as they were. What changes is
6
+ // *who* the parked thing is. For the stackless (callback-ABI) path a parked
7
+ // thread is a JS generator; for a stackful one it is a suspended wasm
8
+ // activation, and this module is the adapter that makes the two look the same
9
+ // to `Store.tick`.
10
+ //
11
+ // ===========================================================================
12
+ // THE INVARIANT
13
+ // ===========================================================================
14
+ //
15
+ // A component instantiation is EITHER "plain" — no JS→wasm entry is
16
+ // `promising`-wrapped and no import is `Suspending`-wrapped — OR
17
+ // "suspension-capable": every entry that can reach a blocking built-in is
18
+ // `promising`-wrapped, and every blocking built-in is `Suspending`-wrapped.
19
+ // Never a mixture.
20
+ //
21
+ // This is forced by empirical fact (c), pinned in
22
+ // `runtime/tests/jspi/suspending_import_test.ts`: **a `Suspending`-wrapped
23
+ // import called outside a promising activation traps unconditionally — even
24
+ // on the plain-value fast path.** So `Suspending`-wrapping an intrinsic is not
25
+ // a free upgrade; it is only safe if every activation that can reach it is
26
+ // promising. Hence one decision per instantiation, not per call site.
27
+ //
28
+ // It is enforced structurally rather than by convention: the mode is a single
29
+ // value computed once by the `Executor` and threaded to both wrapping sites
30
+ // (`enterWasm` here, and the trampoline factory in intrinsics/mod.ts). Both
31
+ // assert it, and `assertModeConsistent` is called from the instantiation path
32
+ // so a mismatch fails loudly at instantiate time rather than as a mystery
33
+ // trap much later.
34
+ //
35
+ // Which entries "can reach a blocking built-in"? Exactly three:
36
+ // * a lifted export's core function,
37
+ // * a callback export (the callback-ABI loop re-enters wasm),
38
+ // * a FACT adapter callee invoked by `{sync,async}-start-call`.
39
+ // `realloc`, `post-return` and resource destructors are deliberately NOT
40
+ // promising-wrapped: they are guest-internal or spec-forbidden from blocking,
41
+ // they never call a canonical built-in, and wrapping them would force their
42
+ // results to become Promises where cabi needs a number synchronously.
43
+ import { assert_ } from "../cabi/trap.js";
44
+ import { isSupported, makePromising, makeSuspending } from "./mechanics.js";
45
+ import { withActivation, claimActivationAmbient, dbgId, consumeClaimIfRunning, maybeCurrentThread, releaseActivationAmbient, setResumingThread, } from "../task/mod.js";
46
+ /**
47
+ * Decide the mode for one instantiation.
48
+ *
49
+ * `requested` is the embedder's opt-in. We additionally require the engine to
50
+ * actually implement JSPI: on an engine without it every blocking site falls
51
+ * back to the precise `NeedsJspi` it raised before this module existed, which
52
+ * is the M3 browser-matrix degradation path (docs/milestones.md M3).
53
+ */
54
+ export function chooseMode(requested, needed) {
55
+ if (requested === false)
56
+ return "plain";
57
+ const want = requested === true || needed === true;
58
+ return want && isSupported() ? "jspi" : "plain";
59
+ }
60
+ /**
61
+ * Does a call through this trampoline declaration genuinely block — i.e. is
62
+ * it a reason a component NEEDS suspension support at all?
63
+ *
64
+ * Sharper than a kind list, and it must be: the async *form* of every copy /
65
+ * cancel built-in NEVER blocks — it returns `BLOCKED` and delivers the result
66
+ * through an event (definitions.py `stream_copy` line 2530 / `cancel_copy`
67
+ * 2636 / `canon_subtask_cancel` 2469, each `if not async_: ... else return
68
+ * BLOCKED`). Classifying by kind alone marked every instance that imports an
69
+ * async-form built-in as suspension-capable, which promising-wrapped its
70
+ * eagerly-completing FACT callees — and a wrapped eager callee reports
71
+ * STARTED where the reference reports RETURNED, and parks on a
72
+ * non-cancellable `awaitValue` where the reference delivers a synchronous
73
+ * cancellation (both asserted by big-interleaving-test.wast's expect-codes).
74
+ *
75
+ * Where the async-ness lives varies by kind, following wasmtime's trampoline
76
+ * layout (component/info.rs): copy built-ins carry an options index (the
77
+ * flag is `canonicalOptions[i].async`); the cancel forms and `subtask.cancel`
78
+ * carry `async` on the declaration itself; `waitable-set.wait`,
79
+ * `thread.yield` and `sync-start-call` are unconditionally block-capable
80
+ * (their non-blocking counterparts are separate kinds: poll, the YIELD
81
+ * callback code, async-start-call).
82
+ */
83
+ export function trampolineNeedsSuspension(t, optionsAsync) {
84
+ switch (t.kind) {
85
+ case "sync-start-call":
86
+ case "waitable-set-wait":
87
+ case "thread-yield":
88
+ return true;
89
+ case "subtask-cancel":
90
+ case "stream-cancel-read":
91
+ case "stream-cancel-write":
92
+ case "future-cancel-read":
93
+ case "future-cancel-write":
94
+ return t.async !== true;
95
+ case "stream-read":
96
+ case "stream-write":
97
+ case "future-read":
98
+ case "future-write":
99
+ return typeof t.options === "number" ? !optionsAsync(t.options) : true;
100
+ default:
101
+ return false;
102
+ }
103
+ }
104
+ /**
105
+ * Must the executor hand this trampoline to wasm as a `Suspending` import in
106
+ * jspi mode?
107
+ *
108
+ * A superset of `trampolineNeedsSuspension` by exactly two kinds, both for
109
+ * the same reason: they never block in the reference or in plain mode, but
110
+ * under jspi they may park the CALLER until a callee's state is determinate,
111
+ * because the engine defers a resumed activation's continuation to a
112
+ * microtask (jspi pin (j), `fastpath_hop_test.ts`):
113
+ *
114
+ * * `async-start-call` — parks until the freshly-started callee reaches
115
+ * resolution / completion / a genuine block (fact_calls.ts);
116
+ * * the async form of `subtask.cancel` — parks until a cancellation
117
+ * delivered by settling the callee's suspension has actually landed
118
+ * (async_builtins.ts; cancellable.wast asserts the reference's
119
+ * synchronous-delivery answers).
120
+ *
121
+ * Neither is a *reason* to choose jspi mode, and neither marks its importer
122
+ * suspendable (`Executor.suspendableFuncs`): their parks only ever trigger
123
+ * when the nested callee is itself promising-wrapped, i.e. when a genuine
124
+ * blocker has already contaminated the adapter through the transitive import
125
+ * rule. Marking on these kinds is not only unnecessary — it is wrong: the
126
+ * FACT adapter's `[adapter-callee]*` pass-through exports are what get
127
+ * passed to `*-start-call` as lift callees, and marking the whole adapter
128
+ * instance promoted every eagerly-completing callee to promising, recreating
129
+ * the STARTED-vs-RETURNED divergence one level up.
130
+ */
131
+ export function trampolineCanBlock(t, optionsAsync) {
132
+ return t.kind === "async-start-call" || t.kind === "subtask-cancel" ||
133
+ trampolineNeedsSuspension(t, optionsAsync);
134
+ }
135
+ /**
136
+ * Does this component contain anything that can block a wasm frame?
137
+ *
138
+ * Computed from the plan, on the runtime side, so embedders (and the
139
+ * conformance harness) stay dumb — no plan v3 field, no flag to thread
140
+ * through. Deliberately a slight over-approximation: it asks "could this
141
+ * component ever reach a blocking built-in", not "will this call". A false
142
+ * positive costs a promising-wrapped entry (the export returns a Promise); a
143
+ * false negative would be a hard trap at the blocking site, so the bias is the
144
+ * safe one.
145
+ *
146
+ * The two sources of blocking, both straight from the reference:
147
+ *
148
+ * * a **stackful async lift** — async canonical options with no callback
149
+ * (`canon_lift` line 2179 runs the callee to completion on its own stack);
150
+ * * a **blocking built-in** reached synchronously — see
151
+ * `trampolineNeedsSuspension`.
152
+ */
153
+ export function planNeedsSuspension(plan) {
154
+ for (const o of plan.canonicalOptions) {
155
+ if (o.async && o.callback === null)
156
+ return true;
157
+ }
158
+ const optionsAsync = (i) => plan.canonicalOptions[i]?.async === true;
159
+ for (const t of plan.trampolines) {
160
+ if (trampolineNeedsSuspension(t, optionsAsync))
161
+ return true;
162
+ }
163
+ return false;
164
+ }
165
+ /**
166
+ * Wrap a JS→wasm entry according to the mode.
167
+ *
168
+ * In `plain` mode this is the identity. In `jspi` mode the returned function
169
+ * always yields a Promise (empirical fact (e)), which is why the mode is an
170
+ * embedder opt-in: it changes the shape of every lifted export.
171
+ */
172
+ export function enterWasm(fn, mode) {
173
+ if (mode === "plain")
174
+ return fn;
175
+ assert_(isSupported(), "jspi mode selected on an engine without JSPI");
176
+ return makePromising(fn);
177
+ }
178
+ /**
179
+ * Wrap a blocking-capable trampoline so that returning a Promise suspends the
180
+ * calling wasm activation. Only legal in `jspi` mode — see the invariant.
181
+ *
182
+ * The wrapper around `fn` is ambient claim site (ii) (scheduler.ts), and the
183
+ * one that is easy to miss: **every** call through a `Suspending` import returns to
184
+ * wasm through a microtask hop, even when the import produced its value
185
+ * synchronously and nothing suspended (jspi pin (j),
186
+ * `tests/jspi/fastpath_hop_test.ts`). The rest of the guest's frame therefore
187
+ * runs after our JS frames — `awaitCore`'s `withActivation` bracket included —
188
+ * have unwound, with no `SuspensionPoint` anywhere in sight to have claimed
189
+ * it. Measured signature when this is missing: a FACT adapter's
190
+ * `enter-sync-call` runs bracketed and its `exit-sync-call` runs with no
191
+ * ambient at all ("exit-sync-call with an empty sync-call stack",
192
+ * `trap-if-done.wast:448`, `big-interleaving-test.wast`). This is the site the
193
+ * async-context store used to cover for free, because the engine
194
+ * captured the context when it registered the hop.
195
+ *
196
+ * The claim is only taken on the NON-suspending outcomes. A returned Promise
197
+ * is a genuine suspension whose resumption `SuspensionPoint.resume` claims
198
+ * exactly, and `blockCurrentActivation` has just released this activation's
199
+ * claim on the way in — re-adding it here would strand it.
200
+ */
201
+ // ---------------------------------------------------------------------------
202
+ // Continuation-chunk attribution sentinels (issue #24)
203
+ // ---------------------------------------------------------------------------
204
+ //
205
+ // PROBLEM. Engine continuation chunks — the segments of a promising wasm
206
+ // activation between suspension/hop points — begin as promise REACTIONS,
207
+ // with no synchronous signal to this runtime. When several activations have
208
+ // pending continuations (a settled real suspension racing a fast-path hop,
209
+ // or two fast-path hops from nested entries), the chunks interleave at an
210
+ // empty bracket stack, and every ambient read in a later chunk — a hop's
211
+ // `owner` capture at `claimingFn` entry, or an unsafe intrinsic like
212
+ // `context.set`, which has no hop at all — inherits whatever claim the
213
+ // previous chunk left on top. Claim-stack ordering alone cannot repair
214
+ // this: the release edges are themselves promise reactions. Measured
215
+ // consequence (issue #24): wit-bindgen's callback epilogue restored one
216
+ // task's state pointer into another thread's context slots, and the next
217
+ // invocation of the starved thread's callback hit
218
+ // `assert!(!state.is_null())` (async_support.rs:578) -> unreachable.
219
+ // Reachable only with enough concurrently-suspended sibling activations
220
+ // (first corpus: polymorph-tls' webcrypto-composed suite, three async
221
+ // wit-bindgen components deep).
222
+ //
223
+ // FIX. Exploit the one ordering guarantee the platform does give us:
224
+ // microtasks run FIFO, and between our code queueing a microtask and the
225
+ // engine queueing the continuation reaction there is only synchronous
226
+ // engine-internal promise machinery. So at EVERY point where an engine
227
+ // continuation is about to be queued, queue a SENTINEL first that claims
228
+ // the chunk's owner (move-to-top):
229
+ //
230
+ // * fast-path hop: sentinel queued synchronously in `claimingFn` before
231
+ // returning the plain value — the engine queues the hop reaction while
232
+ // processing that return, so the queue reads [sentinel, chunk].
233
+ // * genuine suspension: the wrapper attached to the import's thenable
234
+ // queues the sentinel inside the settle reaction, before returning the
235
+ // value — the engine (attached to the WRAPPED promise) queues the
236
+ // resumption when that wrapper returns, so again [sentinel, chunk].
237
+ // This holds even when several promises settle in one drain: each
238
+ // pair is queued contiguously from within its own settle reaction.
239
+ //
240
+ // Nothing is delayed or reordered — unlike a serializing gate, which
241
+ // measurably shifted the deterministic-profile backpressure-admission
242
+ // order (async-calls-sync.wast caught it). This is the JSPI substitute for
243
+ // what fibers give wasmtime for free: identity travels with the
244
+ // resumption, here as a claim planted one microtask ahead of it.
245
+ function sentinelFor(owner) {
246
+ if (owner === null || owner === undefined)
247
+ return;
248
+ // `Promise.resolve().then`, not `queueMicrotask`: identical FIFO
249
+ // placement, but the latter does not exist in bare engine shells
250
+ // (SpiderMonkey jsshell; sm-pinned lane caught it).
251
+ SENTINEL_TICK.then(() => claimActivationAmbient(owner));
252
+ }
253
+ const SENTINEL_TICK = Promise.resolve();
254
+ /** Wrap a suspending import's thenable so the eventual resumption chunk is
255
+ * preceded contiguously by its attribution sentinel. */
256
+ function attributeContinuation(owner, r) {
257
+ return Promise.resolve(r).then((v) => {
258
+ sentinelFor(owner);
259
+ return v;
260
+ }, (e) => {
261
+ sentinelFor(owner);
262
+ throw e;
263
+ });
264
+ }
265
+ export function suspendingImport(fn, mode) {
266
+ if (mode === "plain")
267
+ return fn;
268
+ assert_(isSupported(), "jspi mode selected on an engine without JSPI");
269
+ const claimingFn = (...args) => {
270
+ // The activation calling us — read while its bracket (or its hop claim)
271
+ // is still the ambient.
272
+ const owner = maybeCurrentThread() ?? null;
273
+ const invoke = () => fn(...args);
274
+ let r;
275
+ try {
276
+ // Bracket our own JS frame with the caller. Without this, a built-in
277
+ // that synchronously enters ANOTHER activation's wasm (`async-start-call`
278
+ // running its callee through `awaitCore`) leaves that callee's hop claim
279
+ // on top when the callee suspends, and the rest of OUR frame — still the
280
+ // caller's — then reads the callee as the ambient (measured on
281
+ // `fact_calls.ts:820`'s determinacy wait). The nesting is a stack, and
282
+ // this is the frame that owns it.
283
+ r = owner === null ? invoke() : withActivation(owner, invoke);
284
+ }
285
+ catch (e) {
286
+ // A synchronous trap out of a built-in also unwinds the guest through
287
+ // the hop, and the guest's trap-path built-ins run there.
288
+ claimActivationAmbient(owner);
289
+ throw e;
290
+ }
291
+ if (r === null || typeof r?.then !== "function") {
292
+ // Fast path (jspi pin (j)): the value still returns to wasm through an
293
+ // engine microtask hop, so the rest of the caller's frame is an engine
294
+ // continuation chunk like any other. The synchronous claim covers any
295
+ // reads before the hop; the sentinel re-claims contiguously ahead of
296
+ // the hop reaction (see the header above — issue #24's second shape
297
+ // was exactly a fast-path hop chunk misattributed after a sibling's
298
+ // claim intervened).
299
+ claimActivationAmbient(owner);
300
+ sentinelFor(owner);
301
+ return r;
302
+ }
303
+ if (SP_TRACE) {
304
+ console.error(`[sp] hop-suspend owner=${dbgId(owner)} promise=${dbgId(r)}`);
305
+ }
306
+ return attributeContinuation(owner, r);
307
+ };
308
+ return makeSuspending(claimingFn);
309
+ }
310
+ /**
311
+ * Fail loudly at instantiate time if the two wrapping sites disagree.
312
+ *
313
+ * The dangerous direction, per jspi pin (c), is a `Suspending` import
314
+ * reachable from a non-`promising` activation — that traps unconditionally,
315
+ * even on the plain-value path. Entry wrapping in jspi mode is unconditional
316
+ * (every lifted export, callback, and block-capable FACT callee), so the
317
+ * structural invariant is `importsWrapped ⇒ entriesWrapped`, per mode.
318
+ *
319
+ * Entries-without-imports is legitimate: per-declaration classification
320
+ * (`trampolineNeedsSuspension`) wraps no imports in a component whose
321
+ * built-ins are all non-blocking async forms, while the mode can still be
322
+ * jspi via the lift-shape over-approximation in `planNeedsSuspension`
323
+ * (canonical options carrying `async` with no callback are counted whether
324
+ * they belong to a lift or to a copy built-in — the plan does not say which,
325
+ * and the false positive only costs Promise-shaped exports).
326
+ */
327
+ export function assertModeConsistent(mode, entriesWrapped, importsWrapped) {
328
+ if (mode === "plain") {
329
+ assert_(!entriesWrapped && !importsWrapped, `plain mode with wrapped entries=${entriesWrapped} / ` +
330
+ `imports=${importsWrapped} — wrapping ran under the wrong mode`);
331
+ return;
332
+ }
333
+ assert_(entriesWrapped || !importsWrapped, `suspension mode jspi wrapped imports without wrapping any entry ` +
334
+ `(entries=${entriesWrapped}, imports=${importsWrapped}) — a ` +
335
+ `Suspending import reached from a non-promising activation traps ` +
336
+ `unconditionally (jspi pin (c))`);
337
+ }
338
+ /**
339
+ * A suspended wasm activation, presented to the scheduler as an ordinary
340
+ * parked thread.
341
+ *
342
+ * `Store.tick` only ever asks a thread three things — is it waiting, is it
343
+ * ready, please resume — so a suspension point that answers those is
344
+ * indistinguishable from a generator-backed `Thread`. Resuming means settling
345
+ * the Promise the `Suspending` import returned, which is what the engine is
346
+ * awaiting; the value handed to `resume` is whatever the reference would have
347
+ * produced at that block point (an event triple's code, a subtask state, a
348
+ * packed copy result).
349
+ */
350
+ const SP_TRACE = (() => {
351
+ try {
352
+ return Deno.env.get("CE_SP_TRACE") === "1";
353
+ }
354
+ catch {
355
+ return false;
356
+ }
357
+ })();
358
+ export class SuspensionPoint {
359
+ task;
360
+ readyFunc;
361
+ cancellable;
362
+ produce;
363
+ onSettled;
364
+ promise;
365
+ #settle;
366
+ #fail;
367
+ #done = false;
368
+ #finished = false;
369
+ #store;
370
+ /**
371
+ * WHO the engine will resume when this point's promise settles.
372
+ *
373
+ * Captured HERE, at construction, and not derived at resume time: the
374
+ * blocking built-in that mints this point is running under the suspending
375
+ * activation's own ambient, so the ambient names that activation exactly.
376
+ * This is the replacement for the async-context store the scheduler
377
+ * used to rely on (M3A-1): same value, obtained by construction instead of
378
+ * by asking the platform to carry a context across the engine's resumption.
379
+ * `task.implicitThread` is the fallback for the one shape that has no
380
+ * ambient at all — a built-in reached during instantiation.
381
+ */
382
+ // deno-lint-ignore no-explicit-any
383
+ owner;
384
+ constructor(store,
385
+ // deno-lint-ignore no-explicit-any
386
+ task,
387
+ /** Resumable once this holds; `null` = only an explicit resume. */
388
+ readyFunc, cancellable,
389
+ /** Produces the value to hand back to wasm at resume time. */
390
+ produce,
391
+ // deno-lint-ignore no-explicit-any
392
+ owner,
393
+ /**
394
+ * `finally`-style hook: runs EXACTLY ONCE, on whichever terminal
395
+ * transition this point takes — produce-success, produce-throw, or
396
+ * `abandon` (issue #102). It is the seam a blocking built-in uses to
397
+ * discharge state it owns for the duration of the park (the FACT
398
+ * start-calls' borrow-lender scopes, contracts/intrinsics.md v0.2
399
+ * amendment 2) without having to trust that `produce` runs.
400
+ *
401
+ * INVARIANTS this hook must respect, so bridge.ts's own contracts are
402
+ * not disturbed:
403
+ * * it must not throw (a throw here would escape `resume` *after* the
404
+ * import's Promise was settled, i.e. into whatever drained the
405
+ * scheduler); it is called inside a `try`/`catch` that reports such
406
+ * a throw rather than propagating it;
407
+ * * it must be idempotent-safe by construction anyway, because it runs
408
+ * AFTER `produce` on the success path — a built-in that already did
409
+ * its cleanup inside `produce` (to pin cleanup ordering relative to
410
+ * the produced value) sees this as a no-op backstop;
411
+ * * it must not resume/abandon this or any other suspension point.
412
+ */
413
+ onSettled) {
414
+ this.task = task;
415
+ this.readyFunc = readyFunc;
416
+ this.cancellable = cancellable;
417
+ this.produce = produce;
418
+ this.onSettled = onSettled;
419
+ this.#store = store;
420
+ this.owner = owner ?? maybeCurrentThread() ?? task?.implicitThread ?? null;
421
+ if (SP_TRACE) {
422
+ console.error(`[sp] mint ${dbgId(this)} owner=${dbgId(this.owner)} task=${dbgId(this.task)}\n${(new Error().stack ?? "").split("\n").slice(2, 5).join("\n")}`);
423
+ }
424
+ this.promise = new Promise((res, rej) => {
425
+ this.#settle = res;
426
+ this.#fail = rej;
427
+ });
428
+ store.startWaiting(this);
429
+ }
430
+ waiting() {
431
+ return !this.#done;
432
+ }
433
+ ready() {
434
+ return !this.#done && this.readyFunc !== null && this.readyFunc();
435
+ }
436
+ /** Settle the import's Promise; the engine resumes the wasm activation. */
437
+ resume(cancelled = false) {
438
+ assert_(!this.#done, "resume of an already-resumed suspension point");
439
+ // Mirrors task/thread.ts:187-190 (definitions.py:367 `Thread.resume`):
440
+ // a cancelled resume is only legal at a cancellable block point (#93).
441
+ assert_(this.cancellable || !cancelled, "cancelled resume of a non-cancellable suspension point");
442
+ if (SP_TRACE) {
443
+ console.error(`[sp] resume ${dbgId(this)} owner=${dbgId(this.owner)}\n${(new Error().stack ?? "").split("\n").slice(2, 5).join("\n")}`);
444
+ }
445
+ this.#done = true;
446
+ this.#store.stopWaiting(this);
447
+ try {
448
+ this.#resumeInner(cancelled);
449
+ }
450
+ finally {
451
+ // Terminal state reached, by whichever of the two paths below. See
452
+ // `onSettled`: this is the backstop, not the primary cleanup site, so
453
+ // it runs after `produce` and after the settle — on the success path it
454
+ // observes cleanup `produce` already did, and changes nothing.
455
+ this.#finish();
456
+ }
457
+ }
458
+ #resumeInner(cancelled) {
459
+ let value;
460
+ try {
461
+ value = this.produce(cancelled);
462
+ }
463
+ catch (e) {
464
+ // A trap computed at resume time (e.g. the event turned out to be a
465
+ // trapping one) must reach the guest as a rejection of the import's
466
+ // Promise, which the engine turns back into a wasm trap — empirical
467
+ // fact (e): post-resume traps arrive as ordinary rejections.
468
+ //
469
+ // This is a RESUMPTION too: the engine hands control back to the wasm
470
+ // activation (to unwind it), and the guest's FACT adapter runs its
471
+ // trap-path built-ins — `exit-sync-call` among them — before our own
472
+ // continuation regains control. So it takes the ambient claim exactly
473
+ // like the value path. Missing it here is what `trap-if-done.wast:448`
474
+ // and the `assert_trap` rows of `big-interleaving-test.wast` detect
475
+ // ("exit-sync-call with an empty sync-call stack").
476
+ if (maybeCurrentThread() === undefined)
477
+ claimActivationAmbient(this.owner);
478
+ setResumingThread(this.task?.implicitThread ?? null);
479
+ this.#fail(e);
480
+ return;
481
+ }
482
+ // Claim the ambient for this activation across the engine's resumption:
483
+ // settling the import's Promise hands control to wasm, which will call
484
+ // built-ins with an empty bracket stack. The claim names `owner` — the
485
+ // activation captured when this point was minted — not a guess derived
486
+ // now; see `owner` and `setResumingThread`.
487
+ //
488
+ // If the DRIVER's slot is live for the activation currently executing (it
489
+ // is the code that called us — a running guest's `subtask.cancel`
490
+ // delivering a cancellation settles the callee's suspension from inside
491
+ // its own frame), that claim has served its purpose; consume it rather
492
+ // than false-positive the one-claimant assert.
493
+ consumeClaimIfRunning();
494
+ // The activation-ambient claim (site (i) in scheduler.ts). Taken only when
495
+ // NOBODY is running right now: if a guest activation is executing, `owner`
496
+ // does not run until that activation yields, and pushing onto a
497
+ // LAST-IN-FIRST-OUT stack now would make `owner` the ambient for the
498
+ // caller's remaining frame. In that shape `owner` is picked up either by
499
+ // its own first `Suspending` call (site (ii)) or, before that, by the
500
+ // driver's `resumingThread` slot at the bottom tier — exactly as it always
501
+ // was.
502
+ if (maybeCurrentThread() === undefined)
503
+ claimActivationAmbient(this.owner);
504
+ setResumingThread(this.task?.implicitThread ?? null);
505
+ this.#settle(value);
506
+ }
507
+ /** Abandon this suspension without resuming the guest (teardown paths). */
508
+ abandon(reason) {
509
+ if (this.#done)
510
+ return;
511
+ this.#done = true;
512
+ this.#store.stopWaiting(this);
513
+ try {
514
+ this.#fail(reason);
515
+ }
516
+ finally {
517
+ // The settle path that never runs `produce` at all — the one issue #102
518
+ // is about.
519
+ this.#finish();
520
+ }
521
+ }
522
+ /** Run `onSettled` at most once. Never throws (see the field's doc). */
523
+ #finish() {
524
+ if (this.#finished)
525
+ return;
526
+ this.#finished = true;
527
+ if (this.onSettled === undefined)
528
+ return;
529
+ try {
530
+ this.onSettled();
531
+ }
532
+ catch (e) {
533
+ // Swallowing is the conservative reading: we are past the point where
534
+ // the guest's Promise was settled, so there is no frame left that could
535
+ // meaningfully receive this. Report loudly instead of corrupting an
536
+ // unrelated drain.
537
+ console.error(`[sp] onSettled threw for ${dbgId(this)}:`, e);
538
+ }
539
+ }
540
+ }
541
+ /**
542
+ * The single shape every blocking built-in needs: "park the calling wasm frame
543
+ * until `readyFunc` holds, then hand it `produce()`".
544
+ *
545
+ * In `plain` mode the caller must not reach here — it raises `NeedsJspi` at
546
+ * its own site, with a message naming the operation, exactly as before. In
547
+ * `jspi` mode this returns a Promise, and because the built-in was
548
+ * `Suspending`-wrapped the engine suspends the activation on it.
549
+ */
550
+ export function blockCurrentActivation(input) {
551
+ // GATE LIFETIME: pristine reference semantics (definitions.py
552
+ // `block_internal` line 378 does NOT touch `inst.exclusive_thread`). A
553
+ // RESOLVED task that parks mid-frame in a synchronous built-in KEEPS
554
+ // gating its instance — identical to wasmtime, whose
555
+ // `ConcurrentInstanceState.do_not_enter` is set/cleared only by
556
+ // `enter_instance`/`exit_instance`, i.e. bracketed on the whole core
557
+ // invocation (source refs distilled on issue #43; exam kit archived at
558
+ // 4f3351f:exams/wasmtime-exclusivity/). polyengine's former release-at-BLOCK
559
+ // divergence was removed by issue #43; `test/async/sync-streams.wast` is
560
+ // now green via the DEFERRED ENTRY DECISION in intrinsics/fact_calls.ts
561
+ // (`createAsyncStartCall`), not via gate release.
562
+ //
563
+ // WHO is parking — read BEFORE anything below disturbs the ambient. This
564
+ // one value serves both purposes: it is the activation the engine will
565
+ // resume when this point settles (`SuspensionPoint.owner`, the replacement
566
+ // for the retired async-context store), and it is the activation whose
567
+ // ambient claim ends here. Reading it after the release yields `undefined`
568
+ // and strands the point with no owner (measured: `cancellable.wast:322`
569
+ // then reported `pending-capability: instantiation-time task context`).
570
+ const owner = maybeCurrentThread() ?? input.task?.implicitThread ?? null;
571
+ // The activation is parking: if it still carried the resumed-ambient claim
572
+ // from the settle that resumed it, that claim's window closes here (the
573
+ // other closing edge — the activation FINISHING — is handled by
574
+ // `Store.noteAwaiting`'s settle continuation).
575
+ consumeClaimIfRunning();
576
+ releaseActivationAmbient(owner);
577
+ const point = new SuspensionPoint(input.store, input.task, input.readyFunc, input.cancellable, input.produce, owner, input.onSettled);
578
+ return point.promise;
579
+ }
@@ -0,0 +1,89 @@
1
+ // JSPI mechanics — a small typed wrapper over `WebAssembly.promising` and
2
+ // `WebAssembly.Suspending`. This module is intentionally standalone: it has
3
+ // no knowledge of the task/scheduler model (runtime/src/task,exec,intrinsics,
4
+ // plan) and must not import from those directories. It is the mechanics
5
+ // layer that the upcoming JSPI scheduler phase (docs/architecture.md §6) will consume —
6
+ // not the scheduler itself.
7
+ //
8
+ // # The frame rule (docs/architecture.md §5)
9
+ //
10
+ // From the js-promise-integration proposal Overview: only WebAssembly
11
+ // computations may be suspended — only wasm frames may be active between the
12
+ // call to a `promising`-wrapped export and any call to a `Suspending`-wrapped
13
+ // import. A JS frame anywhere in between traps.
14
+ //
15
+ // This has been empirically pinned in `runtime/tests/jspi/frame_rule_test.ts`
16
+ // against Deno 2.9.5 / V8 15.0.245.2-rusty: see that file for the exact
17
+ // error constructor, message, and timing observed. Consequences (also
18
+ // findings, not just theory, per that test):
19
+ //
20
+ // - Host-boundary JS glue is safe: a `Suspending`-wrapped import's JS body
21
+ // runs to completion and returns a Promise; the actual suspension
22
+ // happens only after control returns to wasm, so glue code itself never
23
+ // sits on the suspended stack (pinned in
24
+ // `suspending_import_test.ts::pure_wasm_stack_suspends_and_resumes`).
25
+ // - Cross-component JS glue between two wasm activations traps the moment
26
+ // anything below it suspends — cross-component adapters must be wasm
27
+ // (FACT), not JS (docs/architecture.md §4.1, §5).
28
+ //
29
+ // # Reentrancy and concurrency (empirical, not mechanics-layer policy)
30
+ //
31
+ // The engine permits things the Component Model forbids (e.g. reentering an
32
+ // instance while one of its exports is suspended) — docs/architecture.md §6 flags this as
33
+ // the scheduler's job to gate, not the engine's. See
34
+ // `reentry_test.ts`/`concurrent_activations_test.ts` for what the engine
35
+ // actually allows; this module does not enforce CM invariants.
36
+ import { jspiApi } from "./types.js";
37
+ /** True if the current engine implements `WebAssembly.promising` and
38
+ * `WebAssembly.Suspending`. Both are phase-4 API surface (docs/architecture.md §3): no
39
+ * fallback path exists or is planned for engines without them. */
40
+ export function isSupported() {
41
+ return (typeof globalThis.WebAssembly ===
42
+ "object" && jspiApi() !== null);
43
+ }
44
+ /** Throws if JSPI is not available in the current engine. Call this before
45
+ * using anything else in this module if you want a clear error instead of a
46
+ * `TypeError: WebAssembly.promising is not a function`. */
47
+ export function assertSupported() {
48
+ if (!isSupported()) {
49
+ throw new Error("JSPI (WebAssembly.promising / WebAssembly.Suspending) is not " +
50
+ "available in this engine; see docs/architecture.md §3 for the compatibility " +
51
+ "floor (no fallback path exists).");
52
+ }
53
+ }
54
+ /**
55
+ * Wrap a wasm-exported function (as retrieved from
56
+ * `instance.exports.someExport`) so that calling it:
57
+ * - always returns a Promise,
58
+ * - suspends the underlying wasm activation (rather than trapping) the
59
+ * first time it calls a `Suspending`-wrapped import that itself returns
60
+ * a genuine Promise,
61
+ * - resolves that Promise with the export's return value once the wasm
62
+ * activation runs to completion (after zero or more suspend/resume
63
+ * cycles).
64
+ *
65
+ * This is a direct type-safe pass-through of `WebAssembly.promising`; it
66
+ * does no extra bookkeeping. `TArgs`/`TReturn` are the caller's own
67
+ * annotation of the underlying export's signature (not reflected — see
68
+ * docs/architecture.md §3's note on js-types being flagged/phase-3).
69
+ */
70
+ export function makePromising(wasmExport) {
71
+ assertSupported();
72
+ return jspiApi().promising(wasmExport);
73
+ }
74
+ /**
75
+ * Wrap a JS function as a `Suspending` import: when a `promising`-suspendable
76
+ * wasm activation calls it (per the frame rule above) and it returns a
77
+ * genuine Promise, the wasm activation suspends until that Promise settles;
78
+ * if it returns a non-Promise value (or the call site is not suspension-
79
+ * eligible), the value passes straight through — the "fast path", pinned in
80
+ * `suspending_import_test.ts::non_promise_return_is_fast_path`.
81
+ *
82
+ * The returned value is an opaque `WebAssembly.Suspending` instance; hand it
83
+ * directly to the instantiation `imports` object in the slot the wasm module
84
+ * expects a function import.
85
+ */
86
+ export function makeSuspending(fn) {
87
+ assertSupported();
88
+ return new (jspiApi().Suspending)(fn);
89
+ }
@@ -0,0 +1,5 @@
1
+ // JSPI mechanics module (docs/architecture.md §5/§6). Standalone: no imports from
2
+ // runtime/src/{task,exec,intrinsics,plan}.
3
+ export * from "./mechanics.js";
4
+ export * from "./bridge.js";
5
+ export * from "./suspending.js";