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