space-data-module-sdk 0.8.23 → 0.8.25
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/docs/isomorphic-pthreads.html +29 -5
- package/docs/isomorphic-pthreads.md +98 -8
- package/package.json +1 -1
- package/src/compiler/compileModule.js +378 -385
- package/src/compiler/emceptionNode.js +339 -70
- package/src/host/browserModuleHarness.js +13 -0
- package/src/host/hostWorkerBundleSources.js +3 -3
- package/src/host/nodeSyncFsIo.js +3 -1
- package/src/host/wasiThreadBrowserWorker.mjs +41 -21
- package/src/host/wasiThreadHost.js +279 -150
- package/src/host/wasiThreadPool.js +389 -0
- package/src/host/wasiThreadWorker.mjs +62 -20
- package/src/host/wasiThreadWorkerRuntime.js +6 -1
- package/src/index.d.ts +31 -0
- package/src/testing/index.d.ts +2 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// The wasi-threads pool: one SharedArrayBuffer that every thread of a guest
|
|
2
|
+
// process shares, so spawning, dispatching and recycling a thread never needs
|
|
3
|
+
// an event loop.
|
|
4
|
+
//
|
|
5
|
+
// A guest runs pthread_create ... pthread_join without yielding its thread's
|
|
6
|
+
// event loop, often for a whole invoke that spawns wave after wave of threads,
|
|
7
|
+
// and any guest thread may call pthread_create. So a pool thread is never told
|
|
8
|
+
// to run by a message, and never reports back by one:
|
|
9
|
+
//
|
|
10
|
+
// - every pool worker owns a slot and blocks on it (Atomics.wait) while idle;
|
|
11
|
+
// - a spawner, on any thread, claims an IDLE slot with a compare-exchange,
|
|
12
|
+
// writes the tid and start argument, marks it ASSIGNED and notifies it;
|
|
13
|
+
// - the worker runs wasi_thread_start(tid, arg), marks its slot IDLE again,
|
|
14
|
+
// bumps the release generation and notifies it, so a spawner waiting for a
|
|
15
|
+
// free worker wakes.
|
|
16
|
+
//
|
|
17
|
+
// Layout (Int32 words):
|
|
18
|
+
//
|
|
19
|
+
// header 0 generation bumped whenever a slot may have become claimable
|
|
20
|
+
// 1 next tid wasi-threads tids, 1 .. 2^29 - 1, from every thread
|
|
21
|
+
// 2 spawned 3 waited 4 declined (no worker came free)
|
|
22
|
+
// 5 slots slots in service (Node grows it; the browser fixes it)
|
|
23
|
+
// 6 capacity
|
|
24
|
+
// slot i at HEADER + STRIDE * i: state, tid, start argument, flags, OS
|
|
25
|
+
// thread id of its worker (Node), 3 spare words
|
|
26
|
+
//
|
|
27
|
+
// Slot states: PENDING (no worker yet), IDLE, CLAIMED (a spawner is writing),
|
|
28
|
+
// ASSIGNED (tid and argument written), RUNNING, RETIRED (never serves again).
|
|
29
|
+
//
|
|
30
|
+
// A spawn that finds no idle worker waits on the generation for up to
|
|
31
|
+
// spawnWaitMs: a thread the guest has just joined is a few instructions from
|
|
32
|
+
// returning when its joiner wakes, so a wave spawned right after a join can
|
|
33
|
+
// find every worker busy for a moment. When the wait runs out the spawn is
|
|
34
|
+
// declined (pthread_create -> EAGAIN), and later spawns on that thread are
|
|
35
|
+
// declined at once until some worker comes free. Where the pool can grow
|
|
36
|
+
// (Node), a spawn that finds no idle worker waits only GROW_AFTER_MS for one
|
|
37
|
+
// before it starts a new worker.
|
|
38
|
+
//
|
|
39
|
+
// A slot flagged MESSAGE_DISPATCH belongs to a browser worker script from an
|
|
40
|
+
// SDK before 0.8.25: only the owning thread dispatches to it, by {t:"run"},
|
|
41
|
+
// and it comes back through {t:"exit"} on the owner's event loop.
|
|
42
|
+
|
|
43
|
+
export const WASI_THREAD_POOL_PROTOCOL = 2;
|
|
44
|
+
|
|
45
|
+
export const WASI_THREAD_POOL_SLOT = Object.freeze({
|
|
46
|
+
PENDING: 0,
|
|
47
|
+
IDLE: 1,
|
|
48
|
+
CLAIMED: 2,
|
|
49
|
+
ASSIGNED: 3,
|
|
50
|
+
RUNNING: 4,
|
|
51
|
+
RETIRED: 5,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const { PENDING, IDLE, CLAIMED, ASSIGNED, RUNNING, RETIRED } = WASI_THREAD_POOL_SLOT;
|
|
55
|
+
|
|
56
|
+
const H_GENERATION = 0;
|
|
57
|
+
const H_NEXT_TID = 1;
|
|
58
|
+
const H_SPAWNED = 2;
|
|
59
|
+
const H_WAITED = 3;
|
|
60
|
+
const H_DECLINED = 4;
|
|
61
|
+
const H_SLOTS = 5;
|
|
62
|
+
const H_CAPACITY = 6;
|
|
63
|
+
const HEADER = 8;
|
|
64
|
+
const STRIDE = 8;
|
|
65
|
+
const S_STATE = 0;
|
|
66
|
+
const S_TID = 1;
|
|
67
|
+
const S_ARG = 2;
|
|
68
|
+
const S_FLAGS = 3;
|
|
69
|
+
const S_THREAD = 4;
|
|
70
|
+
const FLAG_MESSAGE_DISPATCH = 1;
|
|
71
|
+
// wasi-libc keeps a thread id in the low 29 bits of a mutex word.
|
|
72
|
+
const MAX_TID = 0x1fffffff;
|
|
73
|
+
// With room to grow, how long a spawn waits for a busy worker to come free
|
|
74
|
+
// before it starts another one.
|
|
75
|
+
const GROW_AFTER_MS = 2;
|
|
76
|
+
|
|
77
|
+
/** How long a spawn waits for a busy pool thread to finish, by default. */
|
|
78
|
+
export const DEFAULT_WASI_THREAD_SPAWN_WAIT_MS = 250;
|
|
79
|
+
|
|
80
|
+
const at = (slot) => HEADER + STRIDE * slot;
|
|
81
|
+
|
|
82
|
+
function monotonicNow() {
|
|
83
|
+
return typeof performance !== "undefined" && typeof performance.now === "function"
|
|
84
|
+
? performance.now()
|
|
85
|
+
: Date.now();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function resolveSpawnWaitMs(spawnWaitMs) {
|
|
89
|
+
if (spawnWaitMs === undefined || spawnWaitMs === null) {
|
|
90
|
+
return DEFAULT_WASI_THREAD_SPAWN_WAIT_MS;
|
|
91
|
+
}
|
|
92
|
+
if (typeof spawnWaitMs !== "number" || !(spawnWaitMs >= 0) || spawnWaitMs === Infinity) {
|
|
93
|
+
throw new RangeError("spawnWaitMs must be a finite, non-negative number of milliseconds.");
|
|
94
|
+
}
|
|
95
|
+
return spawnWaitMs;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A new pool with room for `capacity` workers, none in service. The returned
|
|
100
|
+
* descriptor is structured-cloneable: post it to every thread of the process.
|
|
101
|
+
*/
|
|
102
|
+
export function createWasiThreadPool({ capacity, spawnWaitMs }) {
|
|
103
|
+
if (!Number.isInteger(capacity) || capacity < 0) {
|
|
104
|
+
throw new RangeError("pool capacity must be a non-negative integer.");
|
|
105
|
+
}
|
|
106
|
+
const control = new Int32Array(
|
|
107
|
+
new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * (HEADER + STRIDE * capacity)),
|
|
108
|
+
);
|
|
109
|
+
control[H_NEXT_TID] = 1;
|
|
110
|
+
control[H_CAPACITY] = capacity;
|
|
111
|
+
return { control, spawnWaitMs: resolveSpawnWaitMs(spawnWaitMs) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function bumpGeneration(control) {
|
|
115
|
+
Atomics.add(control, H_GENERATION, 1);
|
|
116
|
+
Atomics.notify(control, H_GENERATION);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function setState(control, slot, state) {
|
|
120
|
+
Atomics.store(control, at(slot) + S_STATE, state);
|
|
121
|
+
Atomics.notify(control, at(slot) + S_STATE);
|
|
122
|
+
bumpGeneration(control);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Put `count` slots in service, all PENDING until their workers are armed. */
|
|
126
|
+
export function openWasiThreadPoolSlots(pool, count) {
|
|
127
|
+
Atomics.store(pool.control, H_SLOTS, count);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A worker is ready: its slot takes threads. */
|
|
131
|
+
export function armWasiThreadPoolSlot(pool, slot, { messageDispatch = false } = {}) {
|
|
132
|
+
if (messageDispatch) {
|
|
133
|
+
Atomics.store(pool.control, at(slot) + S_FLAGS, FLAG_MESSAGE_DISPATCH);
|
|
134
|
+
}
|
|
135
|
+
setState(pool.control, slot, IDLE);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The slot never serves again; a worker blocked on it returns. */
|
|
139
|
+
export function retireWasiThreadPoolSlot(pool, slot) {
|
|
140
|
+
setState(pool.control, slot, RETIRED);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function wasiThreadPoolSlotState(pool, slot) {
|
|
144
|
+
return Atomics.load(pool.control, at(slot) + S_STATE);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The tid last assigned to a slot (0 before its first), in any state. */
|
|
148
|
+
export function wasiThreadPoolLastTid(pool, slot) {
|
|
149
|
+
return Atomics.load(pool.control, at(slot) + S_TID);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The tid a slot is running (assigned or running), else null. */
|
|
153
|
+
export function wasiThreadPoolRunningTid(pool, slot) {
|
|
154
|
+
const state = Atomics.load(pool.control, at(slot) + S_STATE);
|
|
155
|
+
return state === ASSIGNED || state === RUNNING
|
|
156
|
+
? Atomics.load(pool.control, at(slot) + S_TID)
|
|
157
|
+
: null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Free a slot from the owner's side, only while it still runs `tid`: the
|
|
162
|
+
* {t:"exit"} of a MESSAGE_DISPATCH worker, or a worker that died mid-thread.
|
|
163
|
+
*/
|
|
164
|
+
export function releaseWasiThreadPoolSlotIfRunning(pool, slot, tid) {
|
|
165
|
+
const { control } = pool;
|
|
166
|
+
const index = at(slot);
|
|
167
|
+
for (;;) {
|
|
168
|
+
const state = Atomics.load(control, index + S_STATE);
|
|
169
|
+
if ((state !== ASSIGNED && state !== RUNNING) || Atomics.load(control, index + S_TID) !== tid) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
if (Atomics.compareExchange(control, index + S_STATE, state, IDLE) === state) {
|
|
173
|
+
bumpGeneration(control);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Worker side: take the slot's assignment, if there is one.
|
|
181
|
+
* @returns {{ tid: number, startArg: number } | null}
|
|
182
|
+
*/
|
|
183
|
+
export function takeWasiThreadPoolAssignment(pool, slot) {
|
|
184
|
+
const { control } = pool;
|
|
185
|
+
const index = at(slot);
|
|
186
|
+
if (Atomics.compareExchange(control, index + S_STATE, ASSIGNED, RUNNING) !== ASSIGNED) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
tid: Atomics.load(control, index + S_TID),
|
|
191
|
+
startArg: Atomics.load(control, index + S_ARG),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Worker side: the thread returned; the worker takes threads again. */
|
|
196
|
+
export function finishWasiThreadPoolRun(pool, slot) {
|
|
197
|
+
const { control } = pool;
|
|
198
|
+
// A slot retired while its thread ran stays retired.
|
|
199
|
+
if (Atomics.compareExchange(control, at(slot) + S_STATE, RUNNING, IDLE) === RUNNING) {
|
|
200
|
+
bumpGeneration(control);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Worker side: serve the slot until it is retired. Blocks between threads.
|
|
206
|
+
* `runThread(tid, startArg)` returns false when the worker must stop (a guest
|
|
207
|
+
* fault); the slot is then retired.
|
|
208
|
+
*/
|
|
209
|
+
export function serveWasiThreadPoolSlot(pool, slot, { runThread, osThreadId = 0 }) {
|
|
210
|
+
const { control } = pool;
|
|
211
|
+
const index = at(slot) + S_STATE;
|
|
212
|
+
if (osThreadId) Atomics.store(control, at(slot) + S_THREAD, osThreadId);
|
|
213
|
+
for (;;) {
|
|
214
|
+
const state = Atomics.load(control, index);
|
|
215
|
+
if (state === RETIRED) return;
|
|
216
|
+
if (state !== ASSIGNED) {
|
|
217
|
+
Atomics.wait(control, index, state);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const assignment = takeWasiThreadPoolAssignment(pool, slot);
|
|
221
|
+
if (!assignment) continue;
|
|
222
|
+
if (runThread(assignment.tid, assignment.startArg) === false) {
|
|
223
|
+
retireWasiThreadPoolSlot(pool, slot);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
finishWasiThreadPoolRun(pool, slot);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Counts across every thread of the process. */
|
|
231
|
+
export function readWasiThreadPoolReport(pool) {
|
|
232
|
+
const { control } = pool;
|
|
233
|
+
const slots = Math.min(Atomics.load(control, H_SLOTS), Atomics.load(control, H_CAPACITY));
|
|
234
|
+
let active = 0;
|
|
235
|
+
let idle = 0;
|
|
236
|
+
let workers = 0;
|
|
237
|
+
for (let slot = 0; slot < slots; slot += 1) {
|
|
238
|
+
const state = Atomics.load(control, at(slot) + S_STATE);
|
|
239
|
+
if (state === CLAIMED || state === ASSIGNED || state === RUNNING) active += 1;
|
|
240
|
+
else if (state === IDLE) idle += 1;
|
|
241
|
+
if (Atomics.load(control, at(slot) + S_THREAD) !== 0) workers += 1;
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
spawned: Atomics.load(control, H_SPAWNED),
|
|
245
|
+
waited: Atomics.load(control, H_WAITED),
|
|
246
|
+
declined: Atomics.load(control, H_DECLINED),
|
|
247
|
+
slots,
|
|
248
|
+
active,
|
|
249
|
+
idle,
|
|
250
|
+
workers,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The wasi.thread-spawn of one thread over the pool. Any thread may hold one.
|
|
256
|
+
*
|
|
257
|
+
* @param {object} pool the descriptor from createWasiThreadPool
|
|
258
|
+
* @param {object} [options]
|
|
259
|
+
* @param {boolean} [options.owner] the owning thread: the only one that may
|
|
260
|
+
* dispatch to MESSAGE_DISPATCH slots
|
|
261
|
+
* @param {(slot: number) => void} [options.grow] start a worker for a newly
|
|
262
|
+
* reserved slot (Node); throws when it cannot
|
|
263
|
+
* @param {(slot: number, tid: number, startArg: number) => void} [options.dispatchMessage]
|
|
264
|
+
* post {t:"run"} to a MESSAGE_DISPATCH slot's worker
|
|
265
|
+
* @returns {(startArg: number) => { tid: number, slot: number, reason: string | null }}
|
|
266
|
+
*/
|
|
267
|
+
export function createWasiThreadPoolSpawn(pool, { owner = false, grow = null, dispatchMessage = null } = {}) {
|
|
268
|
+
const { control, spawnWaitMs } = pool;
|
|
269
|
+
const capacity = Atomics.load(control, H_CAPACITY);
|
|
270
|
+
// The generation at which this thread's last wait ran out: while it is
|
|
271
|
+
// current no worker has come free since, so waiting again cannot help.
|
|
272
|
+
let starvedAt = null;
|
|
273
|
+
// Atomics.wait throws where the agent may not block (a window's main thread).
|
|
274
|
+
let canBlock = true;
|
|
275
|
+
|
|
276
|
+
const claimable = (slot) =>
|
|
277
|
+
owner || (Atomics.load(control, at(slot) + S_FLAGS) & FLAG_MESSAGE_DISPATCH) === 0;
|
|
278
|
+
|
|
279
|
+
// Claim an IDLE slot. `live` counts the claimable slots that are not
|
|
280
|
+
// RETIRED: RETIRED is final, so with none live no slot will ever come free,
|
|
281
|
+
// whatever other spawners are doing meanwhile.
|
|
282
|
+
const scan = () => {
|
|
283
|
+
const slots = Math.min(Atomics.load(control, H_SLOTS), capacity);
|
|
284
|
+
let live = 0;
|
|
285
|
+
for (let slot = 0; slot < slots; slot += 1) {
|
|
286
|
+
if (!claimable(slot)) continue;
|
|
287
|
+
if (Atomics.compareExchange(control, at(slot) + S_STATE, IDLE, CLAIMED) === IDLE) {
|
|
288
|
+
return { slot, live: live + 1 };
|
|
289
|
+
}
|
|
290
|
+
if (Atomics.load(control, at(slot) + S_STATE) !== RETIRED) live += 1;
|
|
291
|
+
}
|
|
292
|
+
return { slot: -1, live };
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const reserve = () => {
|
|
296
|
+
for (;;) {
|
|
297
|
+
const slots = Atomics.load(control, H_SLOTS);
|
|
298
|
+
if (slots >= capacity) return -1;
|
|
299
|
+
if (Atomics.compareExchange(control, H_SLOTS, slots, slots + 1) === slots) {
|
|
300
|
+
Atomics.store(control, at(slots) + S_STATE, CLAIMED);
|
|
301
|
+
return slots;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const growOne = () => {
|
|
307
|
+
const slot = reserve();
|
|
308
|
+
if (slot < 0) return { slot: -1, reason: null };
|
|
309
|
+
try {
|
|
310
|
+
grow(slot);
|
|
311
|
+
return { slot, reason: null };
|
|
312
|
+
} catch {
|
|
313
|
+
retireWasiThreadPoolSlot(pool, slot);
|
|
314
|
+
return { slot: -1, reason: "worker-create-failed" };
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const claim = () => {
|
|
319
|
+
let found = scan();
|
|
320
|
+
if (found.slot >= 0) return { slot: found.slot, waited: false, reason: null };
|
|
321
|
+
const growable = typeof grow === "function";
|
|
322
|
+
const now = monotonicNow();
|
|
323
|
+
const deadline = now + (canBlock ? spawnWaitMs : 0);
|
|
324
|
+
let growAt = now + (canBlock ? Math.min(GROW_AFTER_MS, spawnWaitMs) : 0);
|
|
325
|
+
let waited = false;
|
|
326
|
+
for (;;) {
|
|
327
|
+
const generation = Atomics.load(control, H_GENERATION);
|
|
328
|
+
found = scan();
|
|
329
|
+
if (found.slot >= 0) return { slot: found.slot, waited, reason: null };
|
|
330
|
+
const room = growable && Math.min(Atomics.load(control, H_SLOTS), capacity) < capacity;
|
|
331
|
+
if (room && (found.live === 0 || monotonicNow() >= growAt)) {
|
|
332
|
+
const grown = growOne();
|
|
333
|
+
if (grown.slot >= 0 || grown.reason) return { ...grown, waited };
|
|
334
|
+
continue; // another thread took the last reservation
|
|
335
|
+
}
|
|
336
|
+
if (found.live === 0) return { slot: -1, waited, reason: "pool-exhausted" };
|
|
337
|
+
if (generation === starvedAt && !room) return { slot: -1, waited, reason: "pool-exhausted" };
|
|
338
|
+
const remaining = (room ? growAt : deadline) - monotonicNow();
|
|
339
|
+
if (!room && remaining <= 0) {
|
|
340
|
+
starvedAt = generation;
|
|
341
|
+
return { slot: -1, waited, reason: "pool-exhausted" };
|
|
342
|
+
}
|
|
343
|
+
if (remaining > 0) {
|
|
344
|
+
try {
|
|
345
|
+
Atomics.wait(control, H_GENERATION, generation, remaining);
|
|
346
|
+
} catch {
|
|
347
|
+
canBlock = false;
|
|
348
|
+
if (!room) return { slot: -1, waited, reason: "pool-exhausted" };
|
|
349
|
+
growAt = 0;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
waited = true;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
return (startArg) => {
|
|
358
|
+
const claimed = claim();
|
|
359
|
+
if (claimed.slot < 0) {
|
|
360
|
+
if (claimed.reason === "pool-exhausted") Atomics.add(control, H_DECLINED, 1);
|
|
361
|
+
return { tid: -1, slot: -1, reason: claimed.reason };
|
|
362
|
+
}
|
|
363
|
+
const { slot } = claimed;
|
|
364
|
+
const index = at(slot);
|
|
365
|
+
const tid = Atomics.add(control, H_NEXT_TID, 1);
|
|
366
|
+
if (tid > MAX_TID) {
|
|
367
|
+
Atomics.store(control, index + S_STATE, IDLE);
|
|
368
|
+
bumpGeneration(control);
|
|
369
|
+
return { tid: -1, slot: -1, reason: "tid-exhausted" };
|
|
370
|
+
}
|
|
371
|
+
Atomics.store(control, index + S_TID, tid);
|
|
372
|
+
Atomics.store(control, index + S_ARG, startArg | 0);
|
|
373
|
+
Atomics.store(control, index + S_STATE, ASSIGNED);
|
|
374
|
+
const messageDispatch = (Atomics.load(control, index + S_FLAGS) & FLAG_MESSAGE_DISPATCH) !== 0;
|
|
375
|
+
if (messageDispatch) {
|
|
376
|
+
try {
|
|
377
|
+
dispatchMessage(slot, tid, startArg);
|
|
378
|
+
} catch {
|
|
379
|
+
releaseWasiThreadPoolSlotIfRunning(pool, slot, tid);
|
|
380
|
+
return { tid: -1, slot: -1, reason: "dispatch-failed" };
|
|
381
|
+
}
|
|
382
|
+
} else {
|
|
383
|
+
Atomics.notify(control, index + S_STATE);
|
|
384
|
+
}
|
|
385
|
+
Atomics.add(control, H_SPAWNED, 1);
|
|
386
|
+
if (claimed.waited) Atomics.add(control, H_WAITED, 1);
|
|
387
|
+
return { tid, slot, reason: null };
|
|
388
|
+
};
|
|
389
|
+
}
|
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
// Node worker_threads entry for a
|
|
1
|
+
// Node worker_threads entry for a pooled wasi-threads guest thread.
|
|
2
2
|
//
|
|
3
3
|
// The wasi-threads contract: when the guest calls pthread_create it invokes the
|
|
4
4
|
// host `wasi.thread-spawn` import, which must run a NEW OS thread that
|
|
5
5
|
// instantiates the SAME module over the SAME shared linear memory and calls
|
|
6
|
-
// `wasi_thread_start(tid, startArg)`. This file is
|
|
6
|
+
// `wasi_thread_start(tid, startArg)`. This file is such a thread (a Node
|
|
7
7
|
// worker). Thread lifecycle/join synchronization happens entirely over shared
|
|
8
|
-
// memory atomics (memory.atomic.wait/notify emitted by the guest)
|
|
9
|
-
//
|
|
8
|
+
// memory atomics (memory.atomic.wait/notify emitted by the guest).
|
|
9
|
+
//
|
|
10
|
+
// Since SDK 0.8.25 the worker is pooled (wasiThreadPool.js): it serves one slot
|
|
11
|
+
// of the pool, blocking on it between threads, and runs every thread any guest
|
|
12
|
+
// thread assigns to it. Its guest threads get the pool's spawn as their own
|
|
13
|
+
// wasi.thread-spawn, and that spawn may start the next pool worker from this
|
|
14
|
+
// thread: a Node worker starts on its own thread without its parent's event
|
|
15
|
+
// loop, and every event loop here is blocked in the guest.
|
|
10
16
|
//
|
|
11
17
|
// `extraImports` entries arrive through workerData: built-in descriptors such
|
|
12
18
|
// as `{ provider: "flatsql-io" }` (SAB channel) or `{ provider:
|
|
@@ -14,21 +20,27 @@
|
|
|
14
20
|
// table, nodeSyncFsIo.js, design §5.6), and `{ moduleUrl }` factory modules.
|
|
15
21
|
|
|
16
22
|
import { writeSync } from "node:fs";
|
|
17
|
-
import { workerData } from "node:worker_threads";
|
|
23
|
+
import { threadId, Worker, workerData } from "node:worker_threads";
|
|
18
24
|
|
|
19
25
|
import {
|
|
20
26
|
createWasiThreadWorkerRuntime,
|
|
21
27
|
resolveModuleExtraImports,
|
|
22
28
|
} from "./wasiThreadWorkerRuntime.js";
|
|
23
29
|
import { resolveNodeFlatsqlIoDescriptors } from "./nodeSyncFsIo.js";
|
|
30
|
+
import {
|
|
31
|
+
createWasiThreadPoolSpawn,
|
|
32
|
+
retireWasiThreadPoolSlot,
|
|
33
|
+
serveWasiThreadPoolSlot,
|
|
34
|
+
wasiThreadPoolLastTid,
|
|
35
|
+
} from "./wasiThreadPool.js";
|
|
24
36
|
|
|
25
|
-
const { wasmModule, memory,
|
|
37
|
+
const { wasmModule, memory, hostcallChannel, processState, pool, slot, execArgv } = workerData;
|
|
26
38
|
|
|
27
39
|
// A thread that faults never completes the pthread exit protocol, so the
|
|
28
40
|
// thread joining it blocks for good inside the guest. When that joiner is the
|
|
29
41
|
// thread that owns this worker, it can never run this worker's "error" event.
|
|
30
42
|
// Write the fault to stderr from here, synchronously, so it is always seen.
|
|
31
|
-
function reportGuestThreadFault(what, error) {
|
|
43
|
+
function reportGuestThreadFault(tid, what, error) {
|
|
32
44
|
try {
|
|
33
45
|
writeSync(2, `[wasi-thread] guest thread ${tid} ${what}: ${error?.stack ?? error}\n`);
|
|
34
46
|
} catch {
|
|
@@ -36,36 +48,66 @@ function reportGuestThreadFault(what, error) {
|
|
|
36
48
|
}
|
|
37
49
|
}
|
|
38
50
|
|
|
51
|
+
// A spawn from one of this worker's guest threads that finds every pool worker
|
|
52
|
+
// busy starts the next one here.
|
|
53
|
+
function grow(nextSlot) {
|
|
54
|
+
const worker = new Worker(new URL(import.meta.url), {
|
|
55
|
+
execArgv,
|
|
56
|
+
workerData: { ...workerData, slot: nextSlot },
|
|
57
|
+
});
|
|
58
|
+
// The pool, not this worker's event loop, decides when it stops.
|
|
59
|
+
worker.unref();
|
|
60
|
+
// It reports its own faults on stderr; this loop rarely runs to hear them.
|
|
61
|
+
worker.on("error", () => {});
|
|
62
|
+
}
|
|
63
|
+
|
|
39
64
|
let runtime;
|
|
40
|
-
let instance;
|
|
41
65
|
try {
|
|
42
66
|
const extraImports = resolveNodeFlatsqlIoDescriptors(
|
|
43
67
|
await resolveModuleExtraImports(workerData.extraImports ?? []),
|
|
44
68
|
);
|
|
69
|
+
const spawn = createWasiThreadPoolSpawn(pool, { grow });
|
|
45
70
|
runtime = createWasiThreadWorkerRuntime({
|
|
46
71
|
wasmModule,
|
|
47
72
|
memory,
|
|
48
73
|
hostcallChannel,
|
|
49
74
|
processState,
|
|
50
75
|
extraImports,
|
|
51
|
-
workerIndex:
|
|
52
|
-
tid,
|
|
76
|
+
workerIndex: slot,
|
|
77
|
+
threadSpawn: (startArg) => spawn(startArg).tid,
|
|
53
78
|
});
|
|
54
|
-
|
|
79
|
+
runtime.instantiate();
|
|
55
80
|
} catch (error) {
|
|
56
|
-
|
|
57
|
-
|
|
81
|
+
// The slot already holds the thread this worker was started for.
|
|
82
|
+
reportGuestThreadFault(wasiThreadPoolLastTid(pool, slot), "failed to instantiate", error);
|
|
83
|
+
retireWasiThreadPoolSlot(pool, slot);
|
|
84
|
+
try {
|
|
85
|
+
runtime?.close();
|
|
86
|
+
} catch {
|
|
87
|
+
// the instantiate fault is the one to report
|
|
88
|
+
}
|
|
58
89
|
throw error;
|
|
59
90
|
}
|
|
60
91
|
|
|
92
|
+
let fault = null;
|
|
61
93
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
94
|
+
serveWasiThreadPoolSlot(pool, slot, {
|
|
95
|
+
osThreadId: threadId,
|
|
96
|
+
runThread(tid, startArg) {
|
|
97
|
+
try {
|
|
98
|
+
runtime.instantiate().exports.wasi_thread_start(tid, startArg);
|
|
99
|
+
return true;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
// WASI proc_exit surfaces as WasiExitError; a clean thread return is normal.
|
|
102
|
+
if (error && error.name === "WasiExitError") return true;
|
|
103
|
+
reportGuestThreadFault(tid, "trapped", error);
|
|
104
|
+
fault = error;
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
});
|
|
69
109
|
} finally {
|
|
70
110
|
runtime.close();
|
|
71
111
|
}
|
|
112
|
+
// Surface the fault as this worker's error event (onGuestError, A36).
|
|
113
|
+
if (fault) throw fault;
|
|
@@ -67,6 +67,10 @@ function createThreadHostcallDispatch(options) {
|
|
|
67
67
|
* (flatsqlIoImports.js). Factories run once per worker, so each worker owns its
|
|
68
68
|
* own resources (for flatsql-io: its own request-ring slot). `ctx` is
|
|
69
69
|
* `{ memory, getMemory, tid, workerIndex }`.
|
|
70
|
+
*
|
|
71
|
+
* `threadSpawn` is this thread's `wasi.thread-spawn`: the pool's spawn
|
|
72
|
+
* (wasiThreadPool.js), so a guest thread can start threads of its own. Without
|
|
73
|
+
* one, a spawn from this thread returns -1.
|
|
70
74
|
*/
|
|
71
75
|
export function createWasiThreadWorkerRuntime({
|
|
72
76
|
wasmModule,
|
|
@@ -76,6 +80,7 @@ export function createWasiThreadWorkerRuntime({
|
|
|
76
80
|
extraImports,
|
|
77
81
|
workerIndex,
|
|
78
82
|
tid,
|
|
83
|
+
threadSpawn,
|
|
79
84
|
} = {}) {
|
|
80
85
|
const wasi = createBrowserWasiShim({ processState });
|
|
81
86
|
wasi.setMemory(memory);
|
|
@@ -84,7 +89,7 @@ export function createWasiThreadWorkerRuntime({
|
|
|
84
89
|
const imports = {
|
|
85
90
|
...wasi.imports,
|
|
86
91
|
env: { memory },
|
|
87
|
-
wasi: { "thread-spawn": () => -1 },
|
|
92
|
+
wasi: { "thread-spawn": typeof threadSpawn === "function" ? threadSpawn : () => -1 },
|
|
88
93
|
};
|
|
89
94
|
|
|
90
95
|
if (requiresHostcallBridge(wasmModule)) {
|
package/src/index.d.ts
CHANGED
|
@@ -978,7 +978,16 @@ export interface CompilationResult {
|
|
|
978
978
|
compiler: string;
|
|
979
979
|
language: string;
|
|
980
980
|
threadModel: ModuleThreadModelName;
|
|
981
|
+
/**
|
|
982
|
+
* The explicit `outputPath`, or the artifact inside `tempDir` when the
|
|
983
|
+
* caller passed `keepTempDir: true`. `null` otherwise: the compile's temp
|
|
984
|
+
* dir has already been removed, so use `wasmBytes`.
|
|
985
|
+
*/
|
|
981
986
|
outputPath: string | null;
|
|
987
|
+
/**
|
|
988
|
+
* Non-null only with `keepTempDir: true`. The caller owns it and frees it
|
|
989
|
+
* with `cleanupCompilation(result)`.
|
|
990
|
+
*/
|
|
982
991
|
tempDir: string | null;
|
|
983
992
|
wasmBytes: Uint8Array;
|
|
984
993
|
guestLink: GuestLinkArtifact | null;
|
|
@@ -1054,6 +1063,13 @@ export function compileModuleFromSource(options: {
|
|
|
1054
1063
|
language?: string;
|
|
1055
1064
|
threadModel?: ModuleThreadModelName;
|
|
1056
1065
|
outputPath?: string;
|
|
1066
|
+
/**
|
|
1067
|
+
* Keep the compile's temp dir (toolchain scratch, intermediate objects, and
|
|
1068
|
+
* the artifact when no `outputPath` is given) after a successful compile.
|
|
1069
|
+
* Default `false`: it is removed before the promise settles, on success and
|
|
1070
|
+
* on failure. A failed compile always removes it.
|
|
1071
|
+
*/
|
|
1072
|
+
keepTempDir?: boolean;
|
|
1057
1073
|
importedMemory?: boolean;
|
|
1058
1074
|
sharedMemory?: boolean;
|
|
1059
1075
|
initialMemoryBytes?: number;
|
|
@@ -2003,6 +2019,11 @@ export function createBrowserModuleHarness(options?: {
|
|
|
2003
2019
|
// Upper bound on guest wasi-threads spawns; sizes the browser warm worker
|
|
2004
2020
|
// pool (defaults to hardware concurrency). No effect on a non-threaded guest.
|
|
2005
2021
|
maxThreads?: number;
|
|
2022
|
+
// How long a guest spawn that finds every pooled thread busy waits for one to
|
|
2023
|
+
// finish before pthread_create fails (createWasiThreadSpawn spawnWaitMs).
|
|
2024
|
+
wasiThreadSpawnWaitMs?: number;
|
|
2025
|
+
// Explicit wasi-threads pool size (createWasiThreadSpawn poolSize).
|
|
2026
|
+
wasiThreadPoolSize?: number;
|
|
2006
2027
|
// Request-isolated BroadcastChannel descriptor a nested pthread hostcall
|
|
2007
2028
|
// dispatches over; supplied by createWorkerModuleHarness for the in-worker
|
|
2008
2029
|
// harness instance, not something a top-level caller usually sets by hand.
|
|
@@ -2377,11 +2398,15 @@ export interface WasiThreadSpawnReport {
|
|
|
2377
2398
|
armed: number;
|
|
2378
2399
|
failedToArm: number;
|
|
2379
2400
|
spawned: number;
|
|
2401
|
+
/** Spawns that found every pool thread busy and got one by waiting (spawnWaitMs). */
|
|
2402
|
+
waited: number;
|
|
2380
2403
|
declined: number;
|
|
2381
2404
|
declinedByReason: Record<string, number>;
|
|
2382
2405
|
lastDeclineReason: string | null;
|
|
2383
2406
|
active: number;
|
|
2384
2407
|
idle?: number;
|
|
2408
|
+
/** Node: pool workers started (each its own OS thread). */
|
|
2409
|
+
workers?: number;
|
|
2385
2410
|
}
|
|
2386
2411
|
export function isWasiThreadsModule(wasmModule: WebAssembly.Module): boolean;
|
|
2387
2412
|
export function createWasiThreadSpawn(options: {
|
|
@@ -2401,6 +2426,12 @@ export function createWasiThreadSpawn(options: {
|
|
|
2401
2426
|
browserWorkerBaseUrl?: string | URL;
|
|
2402
2427
|
browserWorkerUrl?: string | URL;
|
|
2403
2428
|
browserWorkerType?: "module" | "classic";
|
|
2429
|
+
/**
|
|
2430
|
+
* How long a spawn that finds every pool thread busy waits for one to
|
|
2431
|
+
* finish before it is declined. Default 250; 0 never waits. A Node pool
|
|
2432
|
+
* with room to grow waits at most 2 ms, then starts another worker.
|
|
2433
|
+
*/
|
|
2434
|
+
spawnWaitMs?: number;
|
|
2404
2435
|
}): Promise<{
|
|
2405
2436
|
threadSpawn(startArg: number): number;
|
|
2406
2437
|
activeThreadCount(): number;
|
package/src/testing/index.d.ts
CHANGED
|
@@ -459,6 +459,8 @@ export function createBrowserModuleHarness(options?: {
|
|
|
459
459
|
initialMemoryBytes?: number;
|
|
460
460
|
maximumMemoryBytes?: number;
|
|
461
461
|
maxThreads?: number;
|
|
462
|
+
wasiThreadSpawnWaitMs?: number;
|
|
463
|
+
wasiThreadPoolSize?: number;
|
|
462
464
|
threadHostcallChannel?: {
|
|
463
465
|
channelName: string;
|
|
464
466
|
token: string;
|