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.
@@ -5,11 +5,17 @@
5
5
  // pthread_create it invokes `wasi.thread-spawn(startArg)`; the host must run a
6
6
  // NEW OS thread that instantiates the SAME module over the SAME shared memory
7
7
  // and calls `wasi_thread_start(tid, startArg)`. This module provides that host
8
- // in BOTH environments:
9
- // - Node: a `node:worker_threads` Worker (src/host/wasiThreadWorker.mjs),
10
- // created lazily per spawn.
11
- // - Browser: a WARM POOL of classic Blob-URL Workers (inlined below), started
12
- // and confirmed ready during host creation, then dispatched to per spawn.
8
+ // in BOTH environments, over one pool protocol (wasiThreadPool.js):
9
+ // - Node: a pool of `node:worker_threads` Workers
10
+ // (src/host/wasiThreadWorker.mjs), grown when a spawn finds every worker
11
+ // busy, by whichever thread spawns.
12
+ // - Browser: a WARM POOL of Workers (src/host/wasiThreadBrowserWorker.mjs),
13
+ // started and confirmed ready during host creation.
14
+ // A pool worker blocks on its slot of a SharedArrayBuffer between threads; any
15
+ // guest thread assigns it a thread through that buffer and it frees the slot
16
+ // when the thread returns. No step needs an event loop, so one invoke can
17
+ // start any number of threads, at most the pool's size at a time, and a guest
18
+ // thread can start threads of its own (since 0.8.25).
13
19
  // Thread join/exit synchronization is done by the guest over shared-memory
14
20
  // atomics (memory.atomic.wait/notify); the host only needs to start the thread.
15
21
  //
@@ -25,14 +31,31 @@
25
31
  // the parent is already blocked in the join — so the lazily-spawned worker
26
32
  // never starts and the join blocks forever.
27
33
  // - Fix: PRE-START the workers here (while this thread's event loop is still
28
- // free), confirm each is ready, and only postMessage work to an
29
- // already-running worker at spawn time. A message posted to a live worker is
30
- // delivered even while this thread is blocked in the synchronous join, so
31
- // the pooled worker runs the guest thread and notifies the join. (Proven.)
34
+ // free), confirm each is ready, and only hand work to an already-running
35
+ // worker at spawn time. (Proven.) Since 0.8.25 that hand-off is a store and
36
+ // a notify on the worker's pool slot, not a message.
32
37
  //
33
38
  // See docs/isomorphic-pthreads.md and docs/browser-wasmedge-isomorphic.md.
34
39
 
35
40
  import { NODE_BUILTIN_PREFIX } from "./nodeBuiltinSpecifier.js";
41
+ import {
42
+ DEFAULT_WASI_THREAD_SPAWN_WAIT_MS,
43
+ WASI_THREAD_POOL_PROTOCOL,
44
+ WASI_THREAD_POOL_SLOT,
45
+ armWasiThreadPoolSlot,
46
+ createWasiThreadPool,
47
+ createWasiThreadPoolSpawn,
48
+ openWasiThreadPoolSlots,
49
+ readWasiThreadPoolReport,
50
+ releaseWasiThreadPoolSlotIfRunning,
51
+ resolveSpawnWaitMs,
52
+ retireWasiThreadPoolSlot,
53
+ wasiThreadPoolLastTid,
54
+ wasiThreadPoolRunningTid,
55
+ wasiThreadPoolSlotState,
56
+ } from "./wasiThreadPool.js";
57
+
58
+ export { DEFAULT_WASI_THREAD_SPAWN_WAIT_MS };
36
59
 
37
60
  const IS_NODE =
38
61
  typeof process !== "undefined" &&
@@ -61,8 +84,9 @@ const IS_NODE =
61
84
  // byte-for-byte unchanged.
62
85
  //
63
86
  // IMPORTANT: the anchor names a DIRECTORY that must contain the WHOLE chain —
64
- // `wasiThreadBrowserWorker.mjs` imports `./wasiThreadWorkerRuntime.js`. Staging
65
- // only the single `.mjs` next to a bundle does NOT work.
87
+ // `wasiThreadBrowserWorker.mjs` imports `./wasiThreadWorkerRuntime.js` and
88
+ // `./wasiThreadPool.js`, and those import their own siblings. Staging only the
89
+ // single `.mjs` next to a bundle does NOT work.
66
90
  //
67
91
  // There is deliberately NO consumer-side `location` sniffing and NO
68
92
  // fetch-and-retry probe: resolution must be deterministic (Node never fetches;
@@ -95,8 +119,9 @@ let browserWorkerBaseOverride = null;
95
119
  * directory. Pass `null` to restore the packaged default.
96
120
  *
97
121
  * @param {string|URL|null} baseUrl directory URL containing
98
- * `wasiThreadBrowserWorker.mjs` AND `wasiThreadWorkerRuntime.js`. A trailing
99
- * slash is added when missing.
122
+ * `wasiThreadBrowserWorker.mjs` AND the modules it imports
123
+ * (`wasiThreadWorkerRuntime.js`, `wasiThreadPool.js`, ...). A trailing slash
124
+ * is added when missing.
100
125
  */
101
126
  export function setBrowserWasiThreadWorkerBase(baseUrl) {
102
127
  browserWorkerBaseOverride = baseUrl == null ? null : normalizeBase(baseUrl);
@@ -161,8 +186,9 @@ export class WasiThreadWorkerUnreachableError extends Error {
161
186
  constructor(workerUrl, cause) {
162
187
  super(
163
188
  `[wasi-thread] pooled browser worker asset is unreachable at ${workerUrl}. ` +
164
- `The anchor must name a directory that serves BOTH ${BROWSER_WORKER_FILENAME} ` +
165
- `and wasiThreadWorkerRuntime.js. When this host source is bundled, pass ` +
189
+ `The anchor must name a directory that serves ${BROWSER_WORKER_FILENAME} ` +
190
+ `and the modules it imports (wasiThreadWorkerRuntime.js, wasiThreadPool.js). ` +
191
+ `When this host source is bundled, pass ` +
166
192
  `browserWorkerBaseUrl / browserWorkerUrl to createWasiThreadSpawn (or call ` +
167
193
  `setBrowserWasiThreadWorkerBase) — import.meta.url anchors to the bundle, not ` +
168
194
  `to the package layout.`,
@@ -215,8 +241,10 @@ const BROWSER_POOL_PROBE_TIMEOUT_MS = 1500;
215
241
  // explicit poolSize, T9) waits for every worker to settle and keeps the ones
216
242
  // that armed: the engine tolerates partial spawns and runs fewer threads.
217
243
  // The per-worker onmessage handler installed here is PERSISTENT: after arming it
218
- // keeps dispatching {t:"exit"} (idle return) and {t:"error"} (guest fault
219
- // surfacing) for the life of the pool.
244
+ // keeps dispatching {t:"exit"} (the idle return of a worker script from before
245
+ // 0.8.25, which the pool dispatches to by message) and {t:"error"} (guest fault
246
+ // surfacing) for the life of the pool. `protocols` records the pool protocol
247
+ // each ready worker speaks (wasiThreadPool.js; 1 = message dispatch only).
220
248
  function armBrowserPool(
221
249
  created,
222
250
  {
@@ -225,6 +253,7 @@ function armBrowserPool(
225
253
  hostcallChannel,
226
254
  processState,
227
255
  extraImports,
256
+ pool,
228
257
  timeoutMs,
229
258
  partial,
230
259
  onExit,
@@ -239,6 +268,7 @@ function armBrowserPool(
239
268
  const spoke = new WeakSet();
240
269
  const readyWorkers = new Set();
241
270
  const failed = new Set();
271
+ const protocols = new Map();
242
272
  const finish = (ok, extra = {}) => {
243
273
  if (settled) {
244
274
  return;
@@ -253,6 +283,7 @@ function armBrowserPool(
253
283
  error: null,
254
284
  readyWorkers: created.filter((worker) => readyWorkers.has(worker)),
255
285
  failed: created.filter((worker) => failed.has(worker)),
286
+ protocols,
256
287
  ...extra,
257
288
  });
258
289
  };
@@ -285,6 +316,7 @@ function armBrowserPool(
285
316
  spoke.add(worker);
286
317
  if (message.t === "ready") {
287
318
  clearTimeout(timer);
319
+ protocols.set(worker, Number.isInteger(message.protocol) ? message.protocol : 1);
288
320
  if (message.ok !== true && message.error) {
289
321
  // eslint-disable-next-line no-console
290
322
  console.error("[wasi-thread] pooled worker not ready:", message.error);
@@ -329,6 +361,8 @@ function armBrowserPool(
329
361
  processState,
330
362
  extraImports: extraImports ?? [],
331
363
  workerIndex,
364
+ pool: pool ?? null,
365
+ slot: workerIndex,
332
366
  });
333
367
  });
334
368
  });
@@ -371,25 +405,49 @@ function assertCloneableExtraImports(extraImports) {
371
405
  }
372
406
  }
373
407
 
374
- function createSpawnLedger({ poolSize, onSpawnDeclined }) {
408
+
409
+ // How many workers a Node pool with no explicit poolSize may grow to. Workers
410
+ // are reused, so this bounds guest threads alive at once, not threads started.
411
+ const NODE_POOL_CAPACITY = 1024;
412
+
413
+ // Reasons the pool itself counts (any thread); the ledger counts the rest.
414
+ const POOL_DECLINE_REASON = "pool-exhausted";
415
+
416
+ function createSpawnLedger({ poolSize, onSpawnDeclined, getPool = () => null }) {
375
417
  const ledger = {
376
418
  poolSize,
377
419
  armed: 0,
378
420
  failedToArm: 0,
379
- spawned: 0,
380
- declined: 0,
381
421
  declinedByReason: {},
382
422
  lastDeclineReason: null,
383
423
  };
424
+ const report = () => {
425
+ const pool = getPool();
426
+ const counts = pool ? readWasiThreadPoolReport(pool) : null;
427
+ const declinedByReason = { ...ledger.declinedByReason };
428
+ if (counts?.declined) declinedByReason[POOL_DECLINE_REASON] = counts.declined;
429
+ return {
430
+ ...ledger,
431
+ spawned: counts?.spawned ?? 0,
432
+ waited: counts?.waited ?? 0,
433
+ declined: Object.values(declinedByReason).reduce((sum, count) => sum + count, 0),
434
+ declinedByReason,
435
+ counts,
436
+ };
437
+ };
384
438
  return {
385
439
  ledger,
440
+ report,
386
441
  decline(reason) {
387
- ledger.declined += 1;
388
- ledger.declinedByReason[reason] = (ledger.declinedByReason[reason] ?? 0) + 1;
442
+ // A pool counts its own exhaustion, including spawns from guest threads
443
+ // this ledger never sees.
444
+ if (reason !== POOL_DECLINE_REASON || !getPool()) {
445
+ ledger.declinedByReason[reason] = (ledger.declinedByReason[reason] ?? 0) + 1;
446
+ }
389
447
  ledger.lastDeclineReason = reason;
390
448
  if (typeof onSpawnDeclined === "function") {
391
449
  try {
392
- onSpawnDeclined({ reason, poolSize, declined: ledger.declined });
450
+ onSpawnDeclined({ reason, poolSize, declined: report().declined });
393
451
  } catch {
394
452
  // a reporting hook never changes the spawn outcome
395
453
  }
@@ -412,6 +470,12 @@ function reportGuestError(onGuestError, instanceId, tid, error) {
412
470
  * Create the `wasi.thread-spawn` host for a wasi-threads module. Returns the
413
471
  * import function plus liveness/cleanup helpers.
414
472
  *
473
+ * Guest threads run on pooled workers (wasiThreadPool.js). A worker that
474
+ * finishes a thread takes the next one at once, through shared memory, so one
475
+ * invoke can start any number of threads, at most the pool's size at a time;
476
+ * every guest thread's own `wasi.thread-spawn` is the pool's, so a guest
477
+ * thread can start threads too.
478
+ *
415
479
  * @param {Object} options
416
480
  * @param {WebAssembly.Module} options.wasmModule compiled module the workers re-instantiate.
417
481
  * @param {WebAssembly.Memory} options.memory shared imported memory.
@@ -421,9 +485,10 @@ function reportGuestError(onGuestError, instanceId, tid, error) {
421
485
  * @param {number} [options.poolSize] EXPLICIT pool size (T9, design §5.5): the
422
486
  * browser pre-starts exactly this many workers, independent of
423
487
  * `hardwareConcurrency - 1` (writers + lanes: pools are sized for isolation,
424
- * not only for cores). In Node it caps the live guest threads. Arming is
425
- * partial: workers that fail to start are dropped and reported, the rest
426
- * serve. Spawns beyond the pool return -1 and are reported.
488
+ * not only for cores). In Node it caps the pool (live guest threads); workers
489
+ * start when a spawn needs one. Arming is partial: workers that fail to start
490
+ * are dropped and reported, the rest serve. Spawns beyond the pool return -1
491
+ * and are reported. Without it, Node grows its pool to at most 1024 workers.
427
492
  * @param {Array<object>} [options.extraImports] per-worker import objects, as
428
493
  * structured-cloneable descriptors: `{ provider: "flatsql-io", instanceId,
429
494
  * channels, mirror?, trace? }` (SAB I/O channel), `{ provider:
@@ -433,9 +498,18 @@ function reportGuestError(onGuestError, instanceId, tid, error) {
433
498
  * @param {number} [options.instanceId] the owning instance, echoed to
434
499
  * `onGuestError` so a supervisor knows which instance to poison (A36).
435
500
  * @param {(instanceId: number|null, tid: number|null, error: any) => void} [options.onGuestError]
436
- * called when a guest thread traps or its worker dies (A36).
501
+ * called when a guest thread traps or its worker dies (A36). Node reports
502
+ * workers this thread started; a worker a guest thread started reports on
503
+ * stderr only.
437
504
  * @param {(event: { reason: string, poolSize: number, declined: number }) => void} [options.onSpawnDeclined]
438
- * called for every spawn that returns -1.
505
+ * called for every spawn from this thread that returns -1 (spawns from guest
506
+ * threads are counted in spawnReport()).
507
+ * @param {number} [options.spawnWaitMs=250] how long a spawn that finds every
508
+ * pool thread busy waits for one to finish before it is declined. The wait
509
+ * covers a thread that the guest has joined but whose worker has not yet
510
+ * returned; after one wait runs out, that thread's spawns are declined at
511
+ * once until a thread finishes. 0 never waits. A Node pool with room to grow
512
+ * waits at most 2 ms before it starts another worker.
439
513
  * @param {number} [options.probeTimeoutMs] browser warm-pool probe deadline.
440
514
  * @param {object} [options.hostcallChannel] request-isolated channel owned by
441
515
  * the controlling host. Required when pthread workers import the generic
@@ -446,11 +520,11 @@ function reportGuestError(onGuestError, instanceId, tid, error) {
446
520
  * capability negotiation for an owning cross-origin-isolated worker harness.
447
521
  * @param {string|URL} [options.browserWorkerBaseUrl] BROWSER ANCHOR — the
448
522
  * directory URL under which this build serves the SDK host worker chain
449
- * (`wasiThreadBrowserWorker.mjs` + `wasiThreadWorkerRuntime.js`). REQUIRED
450
- * whenever this host source is BUNDLED: `import.meta.url` then resolves to the
451
- * bundle, not to the package layout, and the sibling asset 404s. Defaults to
452
- * the process-wide `setBrowserWasiThreadWorkerBase()` value, then to the
453
- * packaged sibling.
523
+ * (`wasiThreadBrowserWorker.mjs`, `wasiThreadWorkerRuntime.js`,
524
+ * `wasiThreadPool.js` and their imports). REQUIRED whenever this host source is BUNDLED: `import.meta.url`
525
+ * then resolves to the bundle, not to the package layout, and the sibling
526
+ * asset 404s. Defaults to the process-wide `setBrowserWasiThreadWorkerBase()`
527
+ * value, then to the packaged sibling.
454
528
  * @param {string|URL} [options.browserWorkerUrl] the worker file itself; wins
455
529
  * over `browserWorkerBaseUrl`. Use only when the file is not named
456
530
  * `wasiThreadBrowserWorker.mjs` in its served directory, or to pass the
@@ -479,16 +553,16 @@ export async function createWasiThreadSpawn({
479
553
  browserWorkerBaseUrl,
480
554
  browserWorkerUrl,
481
555
  browserWorkerType = "module",
556
+ spawnWaitMs: requestedSpawnWaitMs,
482
557
  } = {}) {
483
- let nextTid = 0;
484
- let spawnCount = 0;
485
558
  const hasExplicitPool = Number.isFinite(explicitPoolSize);
486
559
  if (hasExplicitPool && (explicitPoolSize < 0 || Math.floor(explicitPoolSize) !== explicitPoolSize)) {
487
560
  throw new RangeError("poolSize must be a non-negative integer.");
488
561
  }
562
+ const spawnWaitMs = resolveSpawnWaitMs(requestedSpawnWaitMs);
489
563
  assertCloneableExtraImports(extraImports);
490
564
  if (requiresHostcalls && !hostcallChannel) {
491
- const { ledger, decline } = createSpawnLedger({
565
+ const { report, decline } = createSpawnLedger({
492
566
  poolSize: hasExplicitPool ? explicitPoolSize : 0,
493
567
  onSpawnDeclined,
494
568
  });
@@ -497,20 +571,26 @@ export async function createWasiThreadSpawn({
497
571
  activeThreadCount: () => 0,
498
572
  spawnCount: () => 0,
499
573
  distinctOsThreadCount: () => 0,
500
- spawnReport: () => ({ ...ledger, active: 0 }),
574
+ spawnReport: () => {
575
+ const { counts: _none, ...rest } = report();
576
+ return { ...rest, active: 0 };
577
+ },
501
578
  async terminateAll() {},
502
579
  };
503
580
  }
504
581
 
505
582
  if (IS_NODE) {
506
- // NODE: lazy per-spawn worker_threads. Node workers start on their own OS
507
- // thread independently of the parent's event loop, so lazy creation at
508
- // pthread_create time is fine here — there is no startup-vs-join deadlock.
509
- const workers = new Set();
510
- const osThreadIds = new Set();
511
- const { ledger, decline } = createSpawnLedger({
583
+ // NODE: a pool that grows on demand. A Node worker starts on its own OS
584
+ // thread without its parent's event loop, so a spawn may start one while
585
+ // every event loop of the process is blocked in the guest.
586
+ const pool = createWasiThreadPool({
587
+ capacity: hasExplicitPool ? explicitPoolSize : NODE_POOL_CAPACITY,
588
+ spawnWaitMs,
589
+ });
590
+ const { report, decline } = createSpawnLedger({
512
591
  poolSize: hasExplicitPool ? explicitPoolSize : null,
513
592
  onSpawnDeclined,
593
+ getPool: () => pool,
514
594
  });
515
595
  // The specifier is assembled at runtime on purpose. This branch is dead in
516
596
  // a browser, but a LITERAL `import("node:worker_threads")` is still
@@ -534,67 +614,102 @@ export async function createWasiThreadSpawn({
534
614
  !(index > 0 && sourceFlags.includes(args[index - 1])))
535
615
  : undefined;
536
616
 
537
- const threadSpawn = (startArg) => {
538
- if (hasExplicitPool && workers.size >= explicitPoolSize) {
539
- // The explicit pool is fully busy: decline, so the guest runs the work
540
- // inline (pthread_create -> EAGAIN) and the engine runs fewer threads.
541
- return decline("pool-exhausted");
617
+ // Workers this thread started, by slot. Idle pool workers must not keep
618
+ // the process alive: they are unref'd, ref'd while this thread's spawn
619
+ // runs on them, and unref'd again once idle.
620
+ const workers = new Map();
621
+ let settleTimer = null;
622
+ const settleRefs = () => {
623
+ let busy = 0;
624
+ for (const [slot, worker] of workers) {
625
+ if (wasiThreadPoolRunningTid(pool, slot) !== null) {
626
+ busy += 1;
627
+ worker.ref();
628
+ } else {
629
+ worker.unref();
630
+ }
631
+ }
632
+ if (busy === 0 && settleTimer) {
633
+ clearInterval(settleTimer);
634
+ settleTimer = null;
542
635
  }
543
- const tid = (nextTid += 1);
544
- try {
545
- const worker = new NodeWorker(nodeWorkerUrl, {
636
+ };
637
+
638
+ const startWorker = (slot) => {
639
+ const worker = new NodeWorker(nodeWorkerUrl, {
640
+ execArgv,
641
+ workerData: {
642
+ wasmModule,
643
+ memory,
644
+ hostcallChannel: hostcallChannel ?? null,
645
+ processState,
646
+ extraImports: extraImports ?? [],
647
+ pool,
648
+ slot,
546
649
  execArgv,
547
- workerData: {
548
- wasmModule,
549
- memory,
550
- tid,
551
- startArg,
552
- hostcallChannel: hostcallChannel ?? null,
553
- processState,
554
- extraImports: extraImports ?? [],
555
- workerIndex: tid - 1,
556
- },
557
- });
558
- // Node exposes the OS-thread id per Worker — distinct ids are direct
559
- // evidence that pthread_create ran real concurrent threads.
560
- if (typeof worker.threadId === "number") {
561
- osThreadIds.add(worker.threadId);
650
+ },
651
+ });
652
+ worker.unref();
653
+ worker.on("error", (error) => {
654
+ // A worker crash cannot be surfaced to the guest synchronously. The
655
+ // worker itself writes the fault to stderr first, because this
656
+ // handler never runs while this thread is blocked in pthread_join.
657
+ // eslint-disable-next-line no-console
658
+ console.error("[wasi-thread] worker error:", error);
659
+ reportGuestError(onGuestError, instanceId, wasiThreadPoolLastTid(pool, slot) || null, error);
660
+ });
661
+ worker.once("exit", () => {
662
+ workers.delete(slot);
663
+ if (wasiThreadPoolSlotState(pool, slot) !== WASI_THREAD_POOL_SLOT.RETIRED) {
664
+ retireWasiThreadPoolSlot(pool, slot);
562
665
  }
563
- worker.on("error", (error) => {
564
- // A worker crash cannot be surfaced to the guest synchronously. The
565
- // worker itself writes the fault to stderr first, because this
566
- // handler never runs while this thread is blocked in pthread_join.
567
- // eslint-disable-next-line no-console
568
- console.error("[wasi-thread] worker error:", error);
569
- reportGuestError(onGuestError, instanceId, tid, error);
570
- });
571
- worker.once("exit", () => workers.delete(worker));
572
- workers.add(worker);
573
- spawnCount += 1;
574
- ledger.spawned += 1;
575
- return tid;
576
- } catch {
577
- // Signal spawn failure to the guest: wasi.thread-spawn returns a
578
- // negative value, pthread_create returns EAGAIN, and the module's
579
- // sequential fallback runs the stripe inline. Never abort.
580
- return decline("worker-create-failed");
666
+ });
667
+ workers.set(slot, worker);
668
+ };
669
+
670
+ const spawn = createWasiThreadPoolSpawn(pool, { owner: true, grow: startWorker });
671
+ const threadSpawn = (startArg) => {
672
+ const result = spawn(startArg);
673
+ if (result.tid < 0) {
674
+ // No worker came free: pthread_create returns EAGAIN, and the guest
675
+ // runs the work inline or fails the way it handles EAGAIN.
676
+ return decline(result.reason ?? POOL_DECLINE_REASON);
677
+ }
678
+ const worker = workers.get(result.slot);
679
+ if (worker) {
680
+ worker.ref();
681
+ settleTimer ??= setInterval(settleRefs, 20);
682
+ settleTimer.unref?.();
581
683
  }
684
+ return result.tid;
582
685
  };
583
686
 
687
+ const counts = () => readWasiThreadPoolReport(pool);
584
688
  return {
585
689
  threadSpawn,
586
- activeThreadCount: () => workers.size,
587
- spawnCount: () => spawnCount,
588
- distinctOsThreadCount: () => osThreadIds.size,
589
- spawnReport: () => ({ ...ledger, active: workers.size }),
690
+ activeThreadCount: () => counts().active,
691
+ spawnCount: () => counts().spawned,
692
+ // Each pool worker is its own OS thread; distinct workers are direct
693
+ // evidence that pthread_create ran real concurrent threads.
694
+ distinctOsThreadCount: () => counts().workers,
695
+ spawnReport: () => {
696
+ const { counts: pooled, ...rest } = report();
697
+ return { ...rest, active: pooled.active, idle: pooled.idle, workers: pooled.workers };
698
+ },
590
699
  async terminateAll() {
591
- for (const worker of workers) {
592
- try {
593
- await worker.terminate?.();
594
- } catch {
595
- // best effort
596
- }
597
- }
700
+ if (settleTimer) clearInterval(settleTimer);
701
+ settleTimer = null;
702
+ const slots = counts().slots;
703
+ for (let slot = 0; slot < slots; slot += 1) retireWasiThreadPoolSlot(pool, slot);
704
+ await Promise.all(
705
+ [...workers.values()].map(async (worker) => {
706
+ try {
707
+ await worker.terminate?.();
708
+ } catch {
709
+ // best effort
710
+ }
711
+ }),
712
+ );
598
713
  workers.clear();
599
714
  },
600
715
  };
@@ -604,10 +719,11 @@ export async function createWasiThreadSpawn({
604
719
  // SharedArrayBuffer-backed memory; nested workers DO share it by reference.
605
720
  // The only hazard is lazy startup during the guest's synchronous, no-yield
606
721
  // pthread_create->pthread_join window, so we pre-start the pool here and only
607
- // ever dispatch to an already-running worker.
608
- const idleWorkers = [];
609
- const busyByTid = new Map();
722
+ // ever hand work to an already-running worker. Each created worker owns the
723
+ // pool slot at its creation index.
610
724
  const poolWorkers = [];
725
+ const slotWorkers = [];
726
+ let pool = null;
611
727
  // Threading stays disabled (threadSpawn returns -1 -> guest runs inline) unless
612
728
  // the pool comes up green (all of it, or with an explicit poolSize, any of it).
613
729
  let poolDisabled = true;
@@ -633,39 +749,33 @@ export async function createWasiThreadSpawn({
633
749
  ? explicitPoolSize
634
750
  : Math.max(0, Math.min(Math.floor(hardwareConcurrency) - 1, requested))
635
751
  : 0;
636
- const { ledger, decline } = createSpawnLedger({
752
+ const { ledger, report, decline } = createSpawnLedger({
637
753
  poolSize: hasExplicitPool ? explicitPoolSize : poolSize,
638
754
  onSpawnDeclined,
755
+ getPool: () => pool,
639
756
  });
640
757
  let disabledReason = armed ? null : "threads-unavailable";
641
758
 
759
+ // {t:"exit"} from a worker script before 0.8.25 (message dispatch). No-op
760
+ // once the slot runs another thread.
642
761
  const returnWorkerToIdle = (worker, tid) => {
643
- if (tid !== undefined && tid !== null) {
644
- busyByTid.delete(tid);
645
- }
646
- if (
647
- !poolDisabled &&
648
- poolWorkers.includes(worker) &&
649
- !idleWorkers.includes(worker)
650
- ) {
651
- idleWorkers.push(worker);
762
+ const slot = slotWorkers.indexOf(worker);
763
+ if (pool && slot >= 0 && tid !== undefined && tid !== null) {
764
+ releaseWasiThreadPoolSlotIfRunning(pool, slot, tid);
652
765
  }
653
766
  };
654
767
 
655
768
  const retireWorker = (worker, error) => {
656
769
  // A pooled worker died after arming (A36): report it against the thread it
657
770
  // was running, and never dispatch to it again.
771
+ const slot = slotWorkers.indexOf(worker);
658
772
  let tid = null;
659
- for (const [candidateTid, candidate] of busyByTid) {
660
- if (candidate === worker) {
661
- tid = candidateTid;
662
- busyByTid.delete(candidateTid);
663
- }
773
+ if (pool && slot >= 0) {
774
+ tid = wasiThreadPoolRunningTid(pool, slot);
775
+ retireWasiThreadPoolSlot(pool, slot);
664
776
  }
665
777
  const poolIndex = poolWorkers.indexOf(worker);
666
778
  if (poolIndex >= 0) poolWorkers.splice(poolIndex, 1);
667
- const idleIndex = idleWorkers.indexOf(worker);
668
- if (idleIndex >= 0) idleWorkers.splice(idleIndex, 1);
669
779
  reportGuestError(onGuestError, instanceId, tid, error);
670
780
  };
671
781
 
@@ -695,12 +805,16 @@ export async function createWasiThreadSpawn({
695
805
  }
696
806
  throw new WasiThreadWorkerUnreachableError(workerUrl, error);
697
807
  }
808
+ pool = createWasiThreadPool({ capacity: created.length, spawnWaitMs });
809
+ openWasiThreadPoolSlots(pool, created.length);
810
+ slotWorkers.push(...created);
698
811
  const armResult = await armBrowserPool(created, {
699
812
  wasmModule,
700
813
  memory,
701
814
  hostcallChannel,
702
815
  processState,
703
816
  extraImports,
817
+ pool,
704
818
  timeoutMs: Number.isFinite(probeTimeoutMs) && probeTimeoutMs > 0
705
819
  ? probeTimeoutMs
706
820
  : BROWSER_POOL_PROBE_TIMEOUT_MS,
@@ -722,19 +836,21 @@ export async function createWasiThreadSpawn({
722
836
  if (armResult.ok) {
723
837
  poolDisabled = false;
724
838
  const keep = hasExplicitPool ? armResult.readyWorkers : created;
725
- for (const worker of keep) {
726
- poolWorkers.push(worker);
727
- idleWorkers.push(worker);
728
- }
729
- for (const worker of created) {
730
- if (!keep.includes(worker)) {
839
+ created.forEach((worker, slot) => {
840
+ if (keep.includes(worker)) {
841
+ poolWorkers.push(worker);
842
+ armWasiThreadPoolSlot(pool, slot, {
843
+ messageDispatch: armResult.protocols.get(worker) !== WASI_THREAD_POOL_PROTOCOL,
844
+ });
845
+ } else {
846
+ retireWasiThreadPoolSlot(pool, slot);
731
847
  try {
732
848
  worker.terminate();
733
849
  } catch {
734
850
  // best effort
735
851
  }
736
852
  }
737
- }
853
+ });
738
854
  ledger.armed = keep.length;
739
855
  ledger.failedToArm = created.length - keep.length;
740
856
  } else {
@@ -743,6 +859,9 @@ export async function createWasiThreadSpawn({
743
859
  // grid inline (correct, deterministic, non-hanging). Every created worker
744
860
  // is torn down — including ones that DID confirm ready — so no orphaned
745
861
  // nested worker lingers to contend with the sequential retry's pool.
862
+ for (let slot = 0; slot < created.length; slot += 1) {
863
+ retireWasiThreadPoolSlot(pool, slot);
864
+ }
746
865
  for (const worker of created) {
747
866
  try {
748
867
  worker.terminate();
@@ -757,45 +876,57 @@ export async function createWasiThreadSpawn({
757
876
  disabledReason = "pool-empty";
758
877
  }
759
878
 
879
+ const spawn = pool
880
+ ? createWasiThreadPoolSpawn(pool, {
881
+ owner: true,
882
+ // A worker script from before 0.8.25 only runs a thread it is sent.
883
+ dispatchMessage: (slot, tid, startArg) => {
884
+ slotWorkers[slot].postMessage({ t: "run", tid, startArg });
885
+ },
886
+ })
887
+ : null;
888
+
760
889
  const threadSpawn = (startArg) => {
761
890
  if (poolDisabled) {
762
891
  return decline(disabledReason ?? "pool-disabled");
763
892
  }
764
- const worker = idleWorkers.pop();
765
- if (!worker) {
766
- // Pool exhausted (guest asked for more concurrent threads than the pool
767
- // holds): decline this one so the guest runs the stripe inline. Correct
768
- // and non-hanging; the already-dispatched threads still run in parallel.
769
- return decline("pool-exhausted");
893
+ // Workers take their threads from shared memory and free their slots
894
+ // there, so this sees every thread that finished during this invoke even
895
+ // though this thread has not yielded its event loop since the invoke began.
896
+ const result = spawn(startArg);
897
+ if (result.tid < 0) {
898
+ // Every worker stayed busy (the guest asked for more CONCURRENT threads
899
+ // than the pool holds): decline this one so the guest runs the stripe
900
+ // inline. Correct and non-hanging; the dispatched threads still run.
901
+ return decline(result.reason ?? POOL_DECLINE_REASON);
770
902
  }
771
- const tid = (nextTid += 1);
772
- busyByTid.set(tid, worker);
773
- try {
774
- // Dispatch to a PRE-STARTED worker: its event loop is already live on its
775
- // own thread, so this postMessage is delivered even while THIS thread then
776
- // blocks in the guest's synchronous pthread_join (memory.atomic.wait).
777
- worker.postMessage({ t: "run", tid, startArg });
778
- } catch {
779
- busyByTid.delete(tid);
780
- idleWorkers.push(worker);
781
- return decline("dispatch-failed");
782
- }
783
- spawnCount += 1;
784
- ledger.spawned += 1;
785
- return tid;
903
+ return result.tid;
786
904
  };
787
905
 
906
+ const counts = () => (pool ? readWasiThreadPoolReport(pool) : null);
907
+ const inService = (worker) =>
908
+ wasiThreadPoolSlotState(pool, slotWorkers.indexOf(worker)) !== WASI_THREAD_POOL_SLOT.RETIRED;
788
909
  return {
789
910
  threadSpawn,
790
- activeThreadCount: () => busyByTid.size,
791
- spawnCount: () => spawnCount,
911
+ activeThreadCount: () => counts()?.active ?? 0,
912
+ spawnCount: () => counts()?.spawned ?? 0,
792
913
  // No OS-thread ids in the browser; the count of distinct pooled Worker
793
- // threads is the honest analogue.
794
- distinctOsThreadCount: () => poolWorkers.length,
795
- spawnReport: () => ({ ...ledger, active: busyByTid.size, idle: idleWorkers.length }),
914
+ // threads still in service is the honest analogue.
915
+ distinctOsThreadCount: () => poolWorkers.filter(inService).length,
916
+ spawnReport: () => {
917
+ const { counts: pooled, ...rest } = report();
918
+ return {
919
+ ...rest,
920
+ active: pooled?.active ?? 0,
921
+ idle: pooled && !poolDisabled ? pooled.idle : 0,
922
+ };
923
+ },
796
924
  async terminateAll() {
797
925
  poolDisabled = true;
798
926
  disabledReason = "terminated";
927
+ if (pool) {
928
+ for (let slot = 0; slot < slotWorkers.length; slot += 1) retireWasiThreadPoolSlot(pool, slot);
929
+ }
799
930
  for (const worker of poolWorkers) {
800
931
  try {
801
932
  worker.terminate();
@@ -804,8 +935,6 @@ export async function createWasiThreadSpawn({
804
935
  }
805
936
  }
806
937
  poolWorkers.length = 0;
807
- idleWorkers.length = 0;
808
- busyByTid.clear();
809
938
  },
810
939
  };
811
940
  }