space-data-module-sdk 0.8.24 → 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.
@@ -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 spawned wasi-threads guest thread.
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 that OS thread (a Node
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) — no
9
- // messages are needed for correctness.
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, tid, startArg, hostcallChannel, processState } = workerData;
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: workerData.workerIndex,
52
- tid,
76
+ workerIndex: slot,
77
+ threadSpawn: (startArg) => spawn(startArg).tid,
53
78
  });
54
- instance = runtime.instantiate();
79
+ runtime.instantiate();
55
80
  } catch (error) {
56
- reportGuestThreadFault("failed to instantiate", error);
57
- runtime?.close();
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
- instance.exports.wasi_thread_start(tid, startArg);
63
- } catch (error) {
64
- // WASI proc_exit surfaces as WasiExitError; a clean thread return is normal.
65
- if (!(error && error.name === "WasiExitError")) {
66
- reportGuestThreadFault("trapped", error);
67
- throw error;
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
@@ -2019,6 +2019,11 @@ export function createBrowserModuleHarness(options?: {
2019
2019
  // Upper bound on guest wasi-threads spawns; sizes the browser warm worker
2020
2020
  // pool (defaults to hardware concurrency). No effect on a non-threaded guest.
2021
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;
2022
2027
  // Request-isolated BroadcastChannel descriptor a nested pthread hostcall
2023
2028
  // dispatches over; supplied by createWorkerModuleHarness for the in-worker
2024
2029
  // harness instance, not something a top-level caller usually sets by hand.
@@ -2393,11 +2398,15 @@ export interface WasiThreadSpawnReport {
2393
2398
  armed: number;
2394
2399
  failedToArm: number;
2395
2400
  spawned: number;
2401
+ /** Spawns that found every pool thread busy and got one by waiting (spawnWaitMs). */
2402
+ waited: number;
2396
2403
  declined: number;
2397
2404
  declinedByReason: Record<string, number>;
2398
2405
  lastDeclineReason: string | null;
2399
2406
  active: number;
2400
2407
  idle?: number;
2408
+ /** Node: pool workers started (each its own OS thread). */
2409
+ workers?: number;
2401
2410
  }
2402
2411
  export function isWasiThreadsModule(wasmModule: WebAssembly.Module): boolean;
2403
2412
  export function createWasiThreadSpawn(options: {
@@ -2417,6 +2426,12 @@ export function createWasiThreadSpawn(options: {
2417
2426
  browserWorkerBaseUrl?: string | URL;
2418
2427
  browserWorkerUrl?: string | URL;
2419
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;
2420
2435
  }): Promise<{
2421
2436
  threadSpawn(startArg: number): number;
2422
2437
  activeThreadCount(): number;
@@ -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;