space-data-module-sdk 0.8.21 → 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.
@@ -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
@@ -405,16 +566,18 @@ export async function createWasiThreadSpawn({
405
566
  // handler never runs while this thread is blocked in pthread_join.
406
567
  // eslint-disable-next-line no-console
407
568
  console.error("[wasi-thread] worker error:", error);
569
+ reportGuestError(onGuestError, instanceId, tid, error);
408
570
  });
409
571
  worker.once("exit", () => workers.delete(worker));
410
572
  workers.add(worker);
411
573
  spawnCount += 1;
574
+ ledger.spawned += 1;
412
575
  return tid;
413
576
  } catch {
414
577
  // Signal spawn failure to the guest: wasi.thread-spawn returns a
415
578
  // negative value, pthread_create returns EAGAIN, and the module's
416
579
  // sequential fallback runs the stripe inline. Never abort.
417
- return -1;
580
+ return decline("worker-create-failed");
418
581
  }
419
582
  };
420
583
 
@@ -423,6 +586,7 @@ export async function createWasiThreadSpawn({
423
586
  activeThreadCount: () => workers.size,
424
587
  spawnCount: () => spawnCount,
425
588
  distinctOsThreadCount: () => osThreadIds.size,
589
+ spawnReport: () => ({ ...ledger, active: workers.size }),
426
590
  async terminateAll() {
427
591
  for (const worker of workers) {
428
592
  try {
@@ -445,7 +609,7 @@ export async function createWasiThreadSpawn({
445
609
  const busyByTid = new Map();
446
610
  const poolWorkers = [];
447
611
  // Threading stays disabled (threadSpawn returns -1 -> guest runs inline) unless
448
- // 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).
449
613
  let poolDisabled = true;
450
614
 
451
615
  const browserThreadsEnabled =
@@ -460,12 +624,20 @@ export async function createWasiThreadSpawn({
460
624
  const requested = Number.isFinite(requestedThreads)
461
625
  ? Math.floor(requestedThreads)
462
626
  : hardwareConcurrency;
463
- // N = min(hardwareConcurrency - 1, requested). The main compute thread is one
464
- // core; the pool provides the rest. Clamped at >= 0 (a 1-core host gets no
465
- // 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).
466
631
  const poolSize = armed
467
- ? 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))
468
635
  : 0;
636
+ const { ledger, decline } = createSpawnLedger({
637
+ poolSize: hasExplicitPool ? explicitPoolSize : poolSize,
638
+ onSpawnDeclined,
639
+ });
640
+ let disabledReason = armed ? null : "threads-unavailable";
469
641
 
470
642
  const returnWorkerToIdle = (worker, tid) => {
471
643
  if (tid !== undefined && tid !== null) {
@@ -480,6 +652,23 @@ export async function createWasiThreadSpawn({
480
652
  }
481
653
  };
482
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
+
483
672
  if (poolSize > 0) {
484
673
  const workerUrl = resolveBrowserWorkerUrl({
485
674
  browserWorkerUrl,
@@ -488,7 +677,11 @@ export async function createWasiThreadSpawn({
488
677
  const created = [];
489
678
  try {
490
679
  for (let i = 0; i < poolSize; i += 1) {
491
- created.push(new Worker(workerUrl, { type: "module" }));
680
+ created.push(
681
+ browserWorkerType === "classic"
682
+ ? new Worker(workerUrl)
683
+ : new Worker(workerUrl, { type: "module" }),
684
+ );
492
685
  }
493
686
  } catch (error) {
494
687
  // Constructing a module Worker throws synchronously for a malformed or
@@ -507,10 +700,15 @@ export async function createWasiThreadSpawn({
507
700
  memory,
508
701
  hostcallChannel,
509
702
  processState,
510
- 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,
511
708
  onExit: returnWorkerToIdle,
709
+ onGuestError: (tid, error) => reportGuestError(onGuestError, instanceId, tid, error),
710
+ onWorkerError: retireWorker,
512
711
  });
513
- const armed = armResult.ok;
514
712
  if (armResult.unreachable) {
515
713
  for (const worker of created) {
516
714
  try {
@@ -521,12 +719,24 @@ export async function createWasiThreadSpawn({
521
719
  }
522
720
  throw new WasiThreadWorkerUnreachableError(workerUrl, armResult.error);
523
721
  }
524
- if (armed) {
722
+ if (armResult.ok) {
525
723
  poolDisabled = false;
526
- for (const worker of created) {
724
+ const keep = hasExplicitPool ? armResult.readyWorkers : created;
725
+ for (const worker of keep) {
527
726
  poolWorkers.push(worker);
528
727
  idleWorkers.push(worker);
529
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;
530
740
  } else {
531
741
  // Any failure disables browser threading entirely: threadSpawn returns -1,
532
742
  // the guest's pthread_create returns EAGAIN, and the module runs its whole
@@ -540,19 +750,23 @@ export async function createWasiThreadSpawn({
540
750
  // best effort
541
751
  }
542
752
  }
753
+ ledger.failedToArm = created.length;
754
+ disabledReason = "pool-not-armed";
543
755
  }
756
+ } else if (armed) {
757
+ disabledReason = "pool-empty";
544
758
  }
545
759
 
546
760
  const threadSpawn = (startArg) => {
547
761
  if (poolDisabled) {
548
- return -1;
762
+ return decline(disabledReason ?? "pool-disabled");
549
763
  }
550
764
  const worker = idleWorkers.pop();
551
765
  if (!worker) {
552
766
  // Pool exhausted (guest asked for more concurrent threads than the pool
553
767
  // holds): decline this one so the guest runs the stripe inline. Correct
554
768
  // and non-hanging; the already-dispatched threads still run in parallel.
555
- return -1;
769
+ return decline("pool-exhausted");
556
770
  }
557
771
  const tid = (nextTid += 1);
558
772
  busyByTid.set(tid, worker);
@@ -564,9 +778,10 @@ export async function createWasiThreadSpawn({
564
778
  } catch {
565
779
  busyByTid.delete(tid);
566
780
  idleWorkers.push(worker);
567
- return -1;
781
+ return decline("dispatch-failed");
568
782
  }
569
783
  spawnCount += 1;
784
+ ledger.spawned += 1;
570
785
  return tid;
571
786
  };
572
787
 
@@ -577,8 +792,10 @@ export async function createWasiThreadSpawn({
577
792
  // No OS-thread ids in the browser; the count of distinct pooled Worker
578
793
  // threads is the honest analogue.
579
794
  distinctOsThreadCount: () => poolWorkers.length,
795
+ spawnReport: () => ({ ...ledger, active: busyByTid.size, idle: idleWorkers.length }),
580
796
  async terminateAll() {
581
797
  poolDisabled = true;
798
+ disabledReason = "terminated";
582
799
  for (const worker of poolWorkers) {
583
800
  try {
584
801
  worker.terminate();
@@ -7,11 +7,20 @@
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
 
11
16
  import { writeSync } from "node:fs";
12
17
  import { workerData } from "node:worker_threads";
13
18
 
14
- import { createWasiThreadWorkerRuntime } from "./wasiThreadWorkerRuntime.js";
19
+ import {
20
+ createWasiThreadWorkerRuntime,
21
+ resolveModuleExtraImports,
22
+ } from "./wasiThreadWorkerRuntime.js";
23
+ import { resolveNodeFlatsqlIoDescriptors } from "./nodeSyncFsIo.js";
15
24
 
16
25
  const { wasmModule, memory, tid, startArg, hostcallChannel, processState } = workerData;
17
26
 
@@ -27,18 +36,25 @@ function reportGuestThreadFault(what, error) {
27
36
  }
28
37
  }
29
38
 
30
- const runtime = createWasiThreadWorkerRuntime({
31
- wasmModule,
32
- memory,
33
- hostcallChannel,
34
- processState,
35
- });
39
+ let runtime;
36
40
  let instance;
37
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
+ });
38
54
  instance = runtime.instantiate();
39
55
  } catch (error) {
40
56
  reportGuestThreadFault("failed to instantiate", error);
41
- runtime.close();
57
+ runtime?.close();
42
58
  throw error;
43
59
  }
44
60
 
@@ -1,4 +1,5 @@
1
1
  import { createHostcallBridge, DEFAULT_HOSTCALL_IMPORT_MODULE } from "./abi.js";
2
+ import { mergeImportFragments, resolveExtraImports } from "./flatsqlIoImports.js";
2
3
  import {
3
4
  createSabHostcallBuffer,
4
5
  createSabHostcallClientDispatch,
@@ -54,11 +55,27 @@ function createThreadHostcallDispatch(options) {
54
55
  };
55
56
  }
56
57
 
58
+ /**
59
+ * The per-worker half of the wasi-threads host: the import object one guest
60
+ * thread instantiates the shared module with.
61
+ *
62
+ * Every pool thread gets WASI, the shared `env.memory`, a `wasi.thread-spawn`
63
+ * stub, the hostcall bridge when the module imports it, and the `extraImports`
64
+ * entries (T9): per-worker import objects such as FlatSQL's `env.flatsql_io_*`.
65
+ * Each entry is a factory `(ctx) => importObject` (or `{ imports, close }`), or
66
+ * a built-in descriptor such as `{ provider: "flatsql-io", instanceId, channels }`
67
+ * (flatsqlIoImports.js). Factories run once per worker, so each worker owns its
68
+ * own resources (for flatsql-io: its own request-ring slot). `ctx` is
69
+ * `{ memory, getMemory, tid, workerIndex }`.
70
+ */
57
71
  export function createWasiThreadWorkerRuntime({
58
72
  wasmModule,
59
73
  memory,
60
74
  hostcallChannel,
61
75
  processState,
76
+ extraImports,
77
+ workerIndex,
78
+ tid,
62
79
  } = {}) {
63
80
  const wasi = createBrowserWasiShim({ processState });
64
81
  wasi.setMemory(memory);
@@ -79,7 +96,18 @@ export function createWasiThreadWorkerRuntime({
79
96
  Object.assign(imports, bridge.imports);
80
97
  }
81
98
 
99
+ const extras = resolveExtraImports(extraImports, {
100
+ memory,
101
+ getMemory: () => instance?.exports?.memory ?? memory,
102
+ workerIndex: workerIndex ?? null,
103
+ tid: tid ?? null,
104
+ });
105
+ mergeImportFragments(imports, extras.fragments);
106
+ // The shared memory is the one contract every import object must agree on.
107
+ imports.env.memory = memory;
108
+
82
109
  return {
110
+ imports,
83
111
  instantiate() {
84
112
  instance = new WebAssembly.Instance(wasmModule, imports);
85
113
  wasi.setMemory(instance.exports.memory ?? memory);
@@ -87,8 +115,34 @@ export function createWasiThreadWorkerRuntime({
87
115
  },
88
116
  close() {
89
117
  hostcalls?.close();
118
+ extras.close();
90
119
  },
91
120
  };
92
121
  }
93
122
 
123
+ /**
124
+ * Resolve `{ moduleUrl, exportName?, config? }` entries by importing their
125
+ * factory module (module workers and Node; a classic blob worker cannot). The
126
+ * resolved entries are factories that receive `{ ...ctx, config }`. Other
127
+ * entries pass through unchanged.
128
+ */
129
+ export async function resolveModuleExtraImports(extraImports) {
130
+ const out = [];
131
+ for (const entry of extraImports ?? []) {
132
+ if (entry && typeof entry.moduleUrl === "string") {
133
+ const mod = await import(/* @vite-ignore */ /* webpackIgnore: true */ entry.moduleUrl);
134
+ const factory = mod[entry.exportName ?? "default"];
135
+ if (typeof factory !== "function") {
136
+ throw new TypeError(
137
+ `extraImports module ${entry.moduleUrl} exports no factory "${entry.exportName ?? "default"}".`,
138
+ );
139
+ }
140
+ out.push({ factory, config: entry.config ?? null });
141
+ } else {
142
+ out.push(entry);
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+
94
148
  export const WASI_THREAD_HOSTCALL_MESSAGE = THREAD_HOSTCALL_MESSAGE;