space-data-module-sdk 0.8.20 → 0.8.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/docs/isomorphic-pthreads.html +100 -0
  2. package/docs/isomorphic-pthreads.md +335 -0
  3. package/package.json +7 -2
  4. package/src/browser.js +6 -0
  5. package/src/compiler/invokeGlue.js +38 -0
  6. package/src/flow/flatsqlLinkShim.js +295 -0
  7. package/src/flow/index.d.ts +31 -0
  8. package/src/flow/index.js +8 -0
  9. package/src/host/browserCapabilityProbe.js +251 -0
  10. package/src/host/browserModuleHarness.js +24 -5
  11. package/src/host/flatsqlIo.js +14 -0
  12. package/src/host/flatsqlIoConformance.js +349 -0
  13. package/src/host/flatsqlIoContract.js +240 -0
  14. package/src/host/flatsqlIoImports.js +335 -0
  15. package/src/host/flatsqlIoMemoryBackend.js +178 -0
  16. package/src/host/flatsqlIoServer.js +960 -0
  17. package/src/host/flatsqlIoWorkers.js +323 -0
  18. package/src/host/hostWorkerBundleSources.js +11 -0
  19. package/src/host/hostWorkerBundles.js +79 -0
  20. package/src/host/index.js +9 -0
  21. package/src/host/isomorphicLoader.js +10 -2
  22. package/src/host/nodeSyncFsIo.js +633 -0
  23. package/src/host/opfsIoBackend.js +280 -0
  24. package/src/host/opfsIoWorker.mjs +223 -0
  25. package/src/host/sabIoChannel.js +848 -0
  26. package/src/host/sabIoMirror.js +306 -0
  27. package/src/host/wasiThreadBrowserWorker.mjs +21 -11
  28. package/src/host/wasiThreadHost.js +261 -43
  29. package/src/host/wasiThreadWorker.mjs +46 -8
  30. package/src/host/wasiThreadWorkerRuntime.js +54 -0
  31. package/src/index.d.ts +349 -0
  32. package/src/runtime/constants.js +6 -0
  33. package/src/testing/native/wasmedge_wasi_threads_runner.c +13 -5
  34. package/src/testing/parityHarness.js +7 -1
  35. package/src/testing/parityLanes.js +19 -0
@@ -72,11 +72,20 @@ const IS_NODE =
72
72
 
73
73
  const BROWSER_WORKER_FILENAME = "wasiThreadBrowserWorker.mjs";
74
74
 
75
- /** Default anchor: the sibling asset in this file's own package layout. */
76
- export const DEFAULT_BROWSER_WORKER_URL = new URL(
77
- `./${BROWSER_WORKER_FILENAME}`,
78
- import.meta.url,
79
- );
75
+ /**
76
+ * Default anchor: the sibling asset in this file's own package layout, or null
77
+ * when this source was bundled into a context without a hierarchical module
78
+ * URL (an IIFE bundle has no `import.meta.url`; a blob: module worker's URL
79
+ * cannot anchor a relative path). Evaluating the module must never throw
80
+ * there: such hosts pass `browserWorkerUrl` (e.g. the blob bundle of A39).
81
+ */
82
+ export const DEFAULT_BROWSER_WORKER_URL = (() => {
83
+ try {
84
+ return new URL(`./${BROWSER_WORKER_FILENAME}`, import.meta.url);
85
+ } catch {
86
+ return null;
87
+ }
88
+ })();
80
89
 
81
90
  let browserWorkerBaseOverride = null;
82
91
 
@@ -133,6 +142,11 @@ export function resolveBrowserWorkerUrl({
133
142
  ? new URL(`${base}${BROWSER_WORKER_FILENAME}`, documentBase).href
134
143
  : `${base}${BROWSER_WORKER_FILENAME}`;
135
144
  }
145
+ if (!DEFAULT_BROWSER_WORKER_URL) {
146
+ throw new WasiThreadWorkerUnreachableError(
147
+ "(no packaged anchor: this host source was bundled without import.meta.url)",
148
+ );
149
+ }
136
150
  return String(DEFAULT_BROWSER_WORKER_URL);
137
151
  }
138
152
 
@@ -185,8 +199,9 @@ export function isWasiThreadsModule(wasmModule) {
185
199
  const BROWSER_POOL_PROBE_TIMEOUT_MS = 1500;
186
200
 
187
201
  // Arm the browser warm pool: probe every created worker and resolve a single
188
- // all-or-nothing decision, as `{ ok, unreachable, error }`.
202
+ // decision, as `{ ok, unreachable, error, readyWorkers, failed }`.
189
203
  // ok:true -> every worker confirmed {t:"ready", ok:true}
204
+ // (partial mode: at least one did)
190
205
  // ok:false -> a worker reported not-ready or missed the
191
206
  // probe deadline: a committed, fast SEQUENTIAL
192
207
  // fallback (capability negotiation).
@@ -196,19 +211,34 @@ const BROWSER_POOL_PROBE_TIMEOUT_MS = 1500;
196
211
  // wrong anchor / broken import chain). That is
197
212
  // a deployment defect, not a capability, and
198
213
  // the caller must fail LOUD.
199
- // It resolves the instant any worker loses — never a wait for the slowest loser.
214
+ // All-or-nothing mode resolves the instant any worker loses. Partial mode (an
215
+ // explicit poolSize, T9) waits for every worker to settle and keeps the ones
216
+ // that armed: the engine tolerates partial spawns and runs fewer threads.
200
217
  // The per-worker onmessage handler installed here is PERSISTENT: after arming it
201
218
  // keeps dispatching {t:"exit"} (idle return) and {t:"error"} (guest fault
202
219
  // surfacing) for the life of the pool.
203
220
  function armBrowserPool(
204
221
  created,
205
- { wasmModule, memory, hostcallChannel, processState, timeoutMs, onExit },
222
+ {
223
+ wasmModule,
224
+ memory,
225
+ hostcallChannel,
226
+ processState,
227
+ extraImports,
228
+ timeoutMs,
229
+ partial,
230
+ onExit,
231
+ onGuestError,
232
+ onWorkerError,
233
+ },
206
234
  ) {
207
235
  return new Promise((resolve) => {
208
236
  let remaining = created.length;
209
237
  let settled = false;
210
238
  const timers = [];
211
239
  const spoke = new WeakSet();
240
+ const readyWorkers = new Set();
241
+ const failed = new Set();
212
242
  const finish = (ok, extra = {}) => {
213
243
  if (settled) {
214
244
  return;
@@ -217,10 +247,36 @@ function armBrowserPool(
217
247
  for (const timer of timers) {
218
248
  clearTimeout(timer);
219
249
  }
220
- resolve({ ok, unreachable: false, error: null, ...extra });
250
+ resolve({
251
+ ok,
252
+ unreachable: false,
253
+ error: null,
254
+ readyWorkers: created.filter((worker) => readyWorkers.has(worker)),
255
+ failed: created.filter((worker) => failed.has(worker)),
256
+ ...extra,
257
+ });
258
+ };
259
+ const settleOne = (worker, ready) => {
260
+ if (readyWorkers.has(worker) || failed.has(worker)) {
261
+ return;
262
+ }
263
+ (ready ? readyWorkers : failed).add(worker);
264
+ remaining -= 1;
265
+ if (!partial) {
266
+ if (!ready) {
267
+ // One worker that cannot instantiate the module over the shared
268
+ // memory disables the whole pool — decide NOW, do not wait out the
269
+ // rest of the probes.
270
+ finish(false);
271
+ } else if (remaining === 0) {
272
+ finish(true);
273
+ }
274
+ } else if (remaining === 0) {
275
+ finish(readyWorkers.size > 0);
276
+ }
221
277
  };
222
- for (const worker of created) {
223
- const timer = setTimeout(() => finish(false), timeoutMs);
278
+ created.forEach((worker, workerIndex) => {
279
+ const timer = setTimeout(() => settleOne(worker, false), timeoutMs);
224
280
  timers.push(timer);
225
281
  worker.onmessage = (event) => {
226
282
  const message = event.data || {};
@@ -229,17 +285,11 @@ function armBrowserPool(
229
285
  spoke.add(worker);
230
286
  if (message.t === "ready") {
231
287
  clearTimeout(timer);
232
- if (message.ok === true) {
233
- remaining -= 1;
234
- if (remaining === 0) {
235
- finish(true);
236
- }
237
- } else {
238
- // One worker that cannot instantiate the module over the shared
239
- // memory disables the whole pool — decide NOW, do not wait out the
240
- // rest of the probes.
241
- finish(false);
288
+ if (message.ok !== true && message.error) {
289
+ // eslint-disable-next-line no-console
290
+ console.error("[wasi-thread] pooled worker not ready:", message.error);
242
291
  }
292
+ settleOne(worker, message.ok === true);
243
293
  } else if (message.t === "exit") {
244
294
  onExit(worker, message.tid);
245
295
  } else if (message.t === "error") {
@@ -248,6 +298,7 @@ function armBrowserPool(
248
298
  "[wasi-thread] pooled worker guest error:",
249
299
  message.error,
250
300
  );
301
+ onGuestError?.(message.tid ?? null, message.error);
251
302
  }
252
303
  };
253
304
  worker.onerror = (error) => {
@@ -257,9 +308,18 @@ function armBrowserPool(
257
308
  "[wasi-thread] pooled worker error:",
258
309
  error?.message ?? error,
259
310
  );
311
+ if (settled) {
312
+ // A worker-level fault after arming: the pool lost a thread.
313
+ onWorkerError?.(worker, error);
314
+ return;
315
+ }
260
316
  // A worker that errored without ever answering the pool protocol never
261
317
  // ran our script: the asset at the resolved anchor is unreachable.
262
- finish(false, { unreachable: !spoke.has(worker), error });
318
+ if (!spoke.has(worker)) {
319
+ finish(false, { unreachable: true, error });
320
+ return;
321
+ }
322
+ settleOne(worker, false);
263
323
  };
264
324
  worker.postMessage({
265
325
  t: "probe",
@@ -267,8 +327,10 @@ function armBrowserPool(
267
327
  memory,
268
328
  hostcallChannel: hostcallChannel ?? null,
269
329
  processState,
330
+ extraImports: extraImports ?? [],
331
+ workerIndex,
270
332
  });
271
- }
333
+ });
272
334
  });
273
335
  }
274
336
 
@@ -297,6 +359,55 @@ function detectHardwareConcurrency() {
297
359
  return 1;
298
360
  }
299
361
 
362
+ function assertCloneableExtraImports(extraImports) {
363
+ for (const entry of extraImports ?? []) {
364
+ if (typeof entry === "function") {
365
+ throw new TypeError(
366
+ "createWasiThreadSpawn extraImports must be structured-cloneable descriptors " +
367
+ "(e.g. { provider: \"flatsql-io\", instanceId, channels }) or { moduleUrl } " +
368
+ "entries: a function cannot cross into a worker thread.",
369
+ );
370
+ }
371
+ }
372
+ }
373
+
374
+ function createSpawnLedger({ poolSize, onSpawnDeclined }) {
375
+ const ledger = {
376
+ poolSize,
377
+ armed: 0,
378
+ failedToArm: 0,
379
+ spawned: 0,
380
+ declined: 0,
381
+ declinedByReason: {},
382
+ lastDeclineReason: null,
383
+ };
384
+ return {
385
+ ledger,
386
+ decline(reason) {
387
+ ledger.declined += 1;
388
+ ledger.declinedByReason[reason] = (ledger.declinedByReason[reason] ?? 0) + 1;
389
+ ledger.lastDeclineReason = reason;
390
+ if (typeof onSpawnDeclined === "function") {
391
+ try {
392
+ onSpawnDeclined({ reason, poolSize, declined: ledger.declined });
393
+ } catch {
394
+ // a reporting hook never changes the spawn outcome
395
+ }
396
+ }
397
+ return -1;
398
+ },
399
+ };
400
+ }
401
+
402
+ function reportGuestError(onGuestError, instanceId, tid, error) {
403
+ if (typeof onGuestError !== "function") return;
404
+ try {
405
+ onGuestError(instanceId ?? null, tid ?? null, error);
406
+ } catch {
407
+ // supervision hooks never throw into the pool
408
+ }
409
+ }
410
+
300
411
  /**
301
412
  * Create the `wasi.thread-spawn` host for a wasi-threads module. Returns the
302
413
  * import function plus liveness/cleanup helpers.
@@ -306,7 +417,26 @@ function detectHardwareConcurrency() {
306
417
  * @param {WebAssembly.Memory} options.memory shared imported memory.
307
418
  * @param {number} [options.requestedThreads] upper bound on how many guest
308
419
  * threads the module will ask for (browser warm-pool sizing). Defaults to the
309
- * host's hardware concurrency.
420
+ * host's hardware concurrency. Ignored when `poolSize` is given.
421
+ * @param {number} [options.poolSize] EXPLICIT pool size (T9, design §5.5): the
422
+ * browser pre-starts exactly this many workers, independent of
423
+ * `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.
427
+ * @param {Array<object>} [options.extraImports] per-worker import objects, as
428
+ * structured-cloneable descriptors: `{ provider: "flatsql-io", instanceId,
429
+ * channels, mirror?, trace? }` (SAB I/O channel), `{ provider:
430
+ * "flatsql-io-node", root, table, instanceId }` (Node sync fs), or
431
+ * `{ moduleUrl, exportName?, config? }` (a factory module; module workers
432
+ * and Node only). See wasiThreadWorkerRuntime.js.
433
+ * @param {number} [options.instanceId] the owning instance, echoed to
434
+ * `onGuestError` so a supervisor knows which instance to poison (A36).
435
+ * @param {(instanceId: number|null, tid: number|null, error: any) => void} [options.onGuestError]
436
+ * called when a guest thread traps or its worker dies (A36).
437
+ * @param {(event: { reason: string, poolSize: number, declined: number }) => void} [options.onSpawnDeclined]
438
+ * called for every spawn that returns -1.
439
+ * @param {number} [options.probeTimeoutMs] browser warm-pool probe deadline.
310
440
  * @param {object} [options.hostcallChannel] request-isolated channel owned by
311
441
  * the controlling host. Required when pthread workers import the generic
312
442
  * module-host ABI.
@@ -323,8 +453,11 @@ function detectHardwareConcurrency() {
323
453
  * packaged sibling.
324
454
  * @param {string|URL} [options.browserWorkerUrl] the worker file itself; wins
325
455
  * over `browserWorkerBaseUrl`. Use only when the file is not named
326
- * `wasiThreadBrowserWorker.mjs` in its served directory.
327
- * @returns {Promise<{ threadSpawn: Function, activeThreadCount: () => number, spawnCount: () => number, distinctOsThreadCount: () => number, terminateAll: () => Promise<void> }>}
456
+ * `wasiThreadBrowserWorker.mjs` in its served directory, or to pass the
457
+ * self-contained blob bundle (hostWorkerBundles.js, A39) together with
458
+ * `browserWorkerType: "classic"`.
459
+ * @param {"module"|"classic"} [options.browserWorkerType="module"]
460
+ * @returns {Promise<{ threadSpawn: Function, activeThreadCount: () => number, spawnCount: () => number, distinctOsThreadCount: () => number, spawnReport: () => object, terminateAll: () => Promise<void> }>}
328
461
  * @throws {WasiThreadWorkerUnreachableError} in the browser, when the pooled
329
462
  * path was requested but the worker asset at the resolved anchor never loaded.
330
463
  * Deliberately fatal: a silent drop to one thread is the defect this replaces.
@@ -333,21 +466,38 @@ export async function createWasiThreadSpawn({
333
466
  wasmModule,
334
467
  memory,
335
468
  requestedThreads,
469
+ poolSize: explicitPoolSize,
470
+ extraImports,
471
+ instanceId,
472
+ onGuestError,
473
+ onSpawnDeclined,
474
+ probeTimeoutMs,
336
475
  hostcallChannel,
337
476
  processState,
338
477
  requiresHostcalls = false,
339
478
  enableBrowserThreads,
340
479
  browserWorkerBaseUrl,
341
480
  browserWorkerUrl,
481
+ browserWorkerType = "module",
342
482
  } = {}) {
343
483
  let nextTid = 0;
344
484
  let spawnCount = 0;
485
+ const hasExplicitPool = Number.isFinite(explicitPoolSize);
486
+ if (hasExplicitPool && (explicitPoolSize < 0 || Math.floor(explicitPoolSize) !== explicitPoolSize)) {
487
+ throw new RangeError("poolSize must be a non-negative integer.");
488
+ }
489
+ assertCloneableExtraImports(extraImports);
345
490
  if (requiresHostcalls && !hostcallChannel) {
491
+ const { ledger, decline } = createSpawnLedger({
492
+ poolSize: hasExplicitPool ? explicitPoolSize : 0,
493
+ onSpawnDeclined,
494
+ });
346
495
  return {
347
- threadSpawn: () => -1,
496
+ threadSpawn: () => decline("hostcall-channel-missing"),
348
497
  activeThreadCount: () => 0,
349
498
  spawnCount: () => 0,
350
499
  distinctOsThreadCount: () => 0,
500
+ spawnReport: () => ({ ...ledger, active: 0 }),
351
501
  async terminateAll() {},
352
502
  };
353
503
  }
@@ -358,6 +508,10 @@ export async function createWasiThreadSpawn({
358
508
  // pthread_create time is fine here — there is no startup-vs-join deadlock.
359
509
  const workers = new Set();
360
510
  const osThreadIds = new Set();
511
+ const { ledger, decline } = createSpawnLedger({
512
+ poolSize: hasExplicitPool ? explicitPoolSize : null,
513
+ onSpawnDeclined,
514
+ });
361
515
  // The specifier is assembled at runtime on purpose. This branch is dead in
362
516
  // a browser, but a LITERAL `import("node:worker_threads")` is still
363
517
  // statically resolved by esbuild/vite/rollup under a browser target, and
@@ -381,6 +535,11 @@ export async function createWasiThreadSpawn({
381
535
  : undefined;
382
536
 
383
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");
542
+ }
384
543
  const tid = (nextTid += 1);
385
544
  try {
386
545
  const worker = new NodeWorker(nodeWorkerUrl, {
@@ -392,6 +551,8 @@ export async function createWasiThreadSpawn({
392
551
  startArg,
393
552
  hostcallChannel: hostcallChannel ?? null,
394
553
  processState,
554
+ extraImports: extraImports ?? [],
555
+ workerIndex: tid - 1,
395
556
  },
396
557
  });
397
558
  // Node exposes the OS-thread id per Worker — distinct ids are direct
@@ -400,20 +561,23 @@ export async function createWasiThreadSpawn({
400
561
  osThreadIds.add(worker.threadId);
401
562
  }
402
563
  worker.on("error", (error) => {
403
- // A worker crash cannot be surfaced to the guest synchronously; log it
404
- // so a hung pthread_join is diagnosable rather than silent.
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.
405
567
  // eslint-disable-next-line no-console
406
568
  console.error("[wasi-thread] worker error:", error);
569
+ reportGuestError(onGuestError, instanceId, tid, error);
407
570
  });
408
571
  worker.once("exit", () => workers.delete(worker));
409
572
  workers.add(worker);
410
573
  spawnCount += 1;
574
+ ledger.spawned += 1;
411
575
  return tid;
412
576
  } catch {
413
577
  // Signal spawn failure to the guest: wasi.thread-spawn returns a
414
578
  // negative value, pthread_create returns EAGAIN, and the module's
415
579
  // sequential fallback runs the stripe inline. Never abort.
416
- return -1;
580
+ return decline("worker-create-failed");
417
581
  }
418
582
  };
419
583
 
@@ -422,6 +586,7 @@ export async function createWasiThreadSpawn({
422
586
  activeThreadCount: () => workers.size,
423
587
  spawnCount: () => spawnCount,
424
588
  distinctOsThreadCount: () => osThreadIds.size,
589
+ spawnReport: () => ({ ...ledger, active: workers.size }),
425
590
  async terminateAll() {
426
591
  for (const worker of workers) {
427
592
  try {
@@ -444,7 +609,7 @@ export async function createWasiThreadSpawn({
444
609
  const busyByTid = new Map();
445
610
  const poolWorkers = [];
446
611
  // Threading stays disabled (threadSpawn returns -1 -> guest runs inline) unless
447
- // the entire pool comes up green. Any probe failure/timeout disables it.
612
+ // the pool comes up green (all of it, or with an explicit poolSize, any of it).
448
613
  let poolDisabled = true;
449
614
 
450
615
  const browserThreadsEnabled =
@@ -459,12 +624,20 @@ export async function createWasiThreadSpawn({
459
624
  const requested = Number.isFinite(requestedThreads)
460
625
  ? Math.floor(requestedThreads)
461
626
  : hardwareConcurrency;
462
- // N = min(hardwareConcurrency - 1, requested). The main compute thread is one
463
- // core; the pool provides the rest. Clamped at >= 0 (a 1-core host gets no
464
- // pool and runs the proven sequential path).
627
+ // Explicit: exactly poolSize workers. Implicit: N = min(hardwareConcurrency
628
+ // - 1, requested) — the main compute thread is one core and the pool
629
+ // provides the rest, clamped at >= 0 (a 1-core host gets no pool and runs the
630
+ // proven sequential path).
465
631
  const poolSize = armed
466
- ? Math.max(0, Math.min(Math.floor(hardwareConcurrency) - 1, requested))
632
+ ? hasExplicitPool
633
+ ? explicitPoolSize
634
+ : Math.max(0, Math.min(Math.floor(hardwareConcurrency) - 1, requested))
467
635
  : 0;
636
+ const { ledger, decline } = createSpawnLedger({
637
+ poolSize: hasExplicitPool ? explicitPoolSize : poolSize,
638
+ onSpawnDeclined,
639
+ });
640
+ let disabledReason = armed ? null : "threads-unavailable";
468
641
 
469
642
  const returnWorkerToIdle = (worker, tid) => {
470
643
  if (tid !== undefined && tid !== null) {
@@ -479,6 +652,23 @@ export async function createWasiThreadSpawn({
479
652
  }
480
653
  };
481
654
 
655
+ const retireWorker = (worker, error) => {
656
+ // A pooled worker died after arming (A36): report it against the thread it
657
+ // was running, and never dispatch to it again.
658
+ let tid = null;
659
+ for (const [candidateTid, candidate] of busyByTid) {
660
+ if (candidate === worker) {
661
+ tid = candidateTid;
662
+ busyByTid.delete(candidateTid);
663
+ }
664
+ }
665
+ const poolIndex = poolWorkers.indexOf(worker);
666
+ if (poolIndex >= 0) poolWorkers.splice(poolIndex, 1);
667
+ const idleIndex = idleWorkers.indexOf(worker);
668
+ if (idleIndex >= 0) idleWorkers.splice(idleIndex, 1);
669
+ reportGuestError(onGuestError, instanceId, tid, error);
670
+ };
671
+
482
672
  if (poolSize > 0) {
483
673
  const workerUrl = resolveBrowserWorkerUrl({
484
674
  browserWorkerUrl,
@@ -487,7 +677,11 @@ export async function createWasiThreadSpawn({
487
677
  const created = [];
488
678
  try {
489
679
  for (let i = 0; i < poolSize; i += 1) {
490
- created.push(new Worker(workerUrl, { type: "module" }));
680
+ created.push(
681
+ browserWorkerType === "classic"
682
+ ? new Worker(workerUrl)
683
+ : new Worker(workerUrl, { type: "module" }),
684
+ );
491
685
  }
492
686
  } catch (error) {
493
687
  // Constructing a module Worker throws synchronously for a malformed or
@@ -506,10 +700,15 @@ export async function createWasiThreadSpawn({
506
700
  memory,
507
701
  hostcallChannel,
508
702
  processState,
509
- timeoutMs: BROWSER_POOL_PROBE_TIMEOUT_MS,
703
+ extraImports,
704
+ timeoutMs: Number.isFinite(probeTimeoutMs) && probeTimeoutMs > 0
705
+ ? probeTimeoutMs
706
+ : BROWSER_POOL_PROBE_TIMEOUT_MS,
707
+ partial: hasExplicitPool,
510
708
  onExit: returnWorkerToIdle,
709
+ onGuestError: (tid, error) => reportGuestError(onGuestError, instanceId, tid, error),
710
+ onWorkerError: retireWorker,
511
711
  });
512
- const armed = armResult.ok;
513
712
  if (armResult.unreachable) {
514
713
  for (const worker of created) {
515
714
  try {
@@ -520,12 +719,24 @@ export async function createWasiThreadSpawn({
520
719
  }
521
720
  throw new WasiThreadWorkerUnreachableError(workerUrl, armResult.error);
522
721
  }
523
- if (armed) {
722
+ if (armResult.ok) {
524
723
  poolDisabled = false;
525
- for (const worker of created) {
724
+ const keep = hasExplicitPool ? armResult.readyWorkers : created;
725
+ for (const worker of keep) {
526
726
  poolWorkers.push(worker);
527
727
  idleWorkers.push(worker);
528
728
  }
729
+ for (const worker of created) {
730
+ if (!keep.includes(worker)) {
731
+ try {
732
+ worker.terminate();
733
+ } catch {
734
+ // best effort
735
+ }
736
+ }
737
+ }
738
+ ledger.armed = keep.length;
739
+ ledger.failedToArm = created.length - keep.length;
529
740
  } else {
530
741
  // Any failure disables browser threading entirely: threadSpawn returns -1,
531
742
  // the guest's pthread_create returns EAGAIN, and the module runs its whole
@@ -539,19 +750,23 @@ export async function createWasiThreadSpawn({
539
750
  // best effort
540
751
  }
541
752
  }
753
+ ledger.failedToArm = created.length;
754
+ disabledReason = "pool-not-armed";
542
755
  }
756
+ } else if (armed) {
757
+ disabledReason = "pool-empty";
543
758
  }
544
759
 
545
760
  const threadSpawn = (startArg) => {
546
761
  if (poolDisabled) {
547
- return -1;
762
+ return decline(disabledReason ?? "pool-disabled");
548
763
  }
549
764
  const worker = idleWorkers.pop();
550
765
  if (!worker) {
551
766
  // Pool exhausted (guest asked for more concurrent threads than the pool
552
767
  // holds): decline this one so the guest runs the stripe inline. Correct
553
768
  // and non-hanging; the already-dispatched threads still run in parallel.
554
- return -1;
769
+ return decline("pool-exhausted");
555
770
  }
556
771
  const tid = (nextTid += 1);
557
772
  busyByTid.set(tid, worker);
@@ -563,9 +778,10 @@ export async function createWasiThreadSpawn({
563
778
  } catch {
564
779
  busyByTid.delete(tid);
565
780
  idleWorkers.push(worker);
566
- return -1;
781
+ return decline("dispatch-failed");
567
782
  }
568
783
  spawnCount += 1;
784
+ ledger.spawned += 1;
569
785
  return tid;
570
786
  };
571
787
 
@@ -576,8 +792,10 @@ export async function createWasiThreadSpawn({
576
792
  // No OS-thread ids in the browser; the count of distinct pooled Worker
577
793
  // threads is the honest analogue.
578
794
  distinctOsThreadCount: () => poolWorkers.length,
795
+ spawnReport: () => ({ ...ledger, active: busyByTid.size, idle: idleWorkers.length }),
579
796
  async terminateAll() {
580
797
  poolDisabled = true;
798
+ disabledReason = "terminated";
581
799
  for (const worker of poolWorkers) {
582
800
  try {
583
801
  worker.terminate();
@@ -7,25 +7,63 @@
7
7
  // worker). Thread lifecycle/join synchronization happens entirely over shared
8
8
  // memory atomics (memory.atomic.wait/notify emitted by the guest) — no
9
9
  // messages are needed for correctness.
10
+ //
11
+ // `extraImports` entries arrive through workerData: built-in descriptors such
12
+ // as `{ provider: "flatsql-io" }` (SAB channel) or `{ provider:
13
+ // "flatsql-io-node", root, table }` (synchronous fs over a shared virtual-handle
14
+ // table, nodeSyncFsIo.js, design §5.6), and `{ moduleUrl }` factory modules.
10
15
 
16
+ import { writeSync } from "node:fs";
11
17
  import { workerData } from "node:worker_threads";
12
18
 
13
- import { createWasiThreadWorkerRuntime } from "./wasiThreadWorkerRuntime.js";
19
+ import {
20
+ createWasiThreadWorkerRuntime,
21
+ resolveModuleExtraImports,
22
+ } from "./wasiThreadWorkerRuntime.js";
23
+ import { resolveNodeFlatsqlIoDescriptors } from "./nodeSyncFsIo.js";
14
24
 
15
25
  const { wasmModule, memory, tid, startArg, hostcallChannel, processState } = workerData;
16
- const runtime = createWasiThreadWorkerRuntime({
17
- wasmModule,
18
- memory,
19
- hostcallChannel,
20
- processState,
21
- });
22
- const instance = runtime.instantiate();
26
+
27
+ // A thread that faults never completes the pthread exit protocol, so the
28
+ // thread joining it blocks for good inside the guest. When that joiner is the
29
+ // thread that owns this worker, it can never run this worker's "error" event.
30
+ // Write the fault to stderr from here, synchronously, so it is always seen.
31
+ function reportGuestThreadFault(what, error) {
32
+ try {
33
+ writeSync(2, `[wasi-thread] guest thread ${tid} ${what}: ${error?.stack ?? error}\n`);
34
+ } catch {
35
+ // stderr unavailable; the worker error event still carries the fault
36
+ }
37
+ }
38
+
39
+ let runtime;
40
+ let instance;
41
+ try {
42
+ const extraImports = resolveNodeFlatsqlIoDescriptors(
43
+ await resolveModuleExtraImports(workerData.extraImports ?? []),
44
+ );
45
+ runtime = createWasiThreadWorkerRuntime({
46
+ wasmModule,
47
+ memory,
48
+ hostcallChannel,
49
+ processState,
50
+ extraImports,
51
+ workerIndex: workerData.workerIndex,
52
+ tid,
53
+ });
54
+ instance = runtime.instantiate();
55
+ } catch (error) {
56
+ reportGuestThreadFault("failed to instantiate", error);
57
+ runtime?.close();
58
+ throw error;
59
+ }
23
60
 
24
61
  try {
25
62
  instance.exports.wasi_thread_start(tid, startArg);
26
63
  } catch (error) {
27
64
  // WASI proc_exit surfaces as WasiExitError; a clean thread return is normal.
28
65
  if (!(error && error.name === "WasiExitError")) {
66
+ reportGuestThreadFault("trapped", error);
29
67
  throw error;
30
68
  }
31
69
  } finally {