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.
- package/docs/isomorphic-pthreads.html +88 -0
- package/docs/isomorphic-pthreads.md +290 -0
- package/package.json +7 -2
- package/src/browser.js +6 -0
- package/src/flow/flatsqlLinkShim.js +295 -0
- package/src/flow/index.d.ts +31 -0
- package/src/flow/index.js +8 -0
- package/src/host/browserCapabilityProbe.js +251 -0
- package/src/host/flatsqlIo.js +14 -0
- package/src/host/flatsqlIoConformance.js +349 -0
- package/src/host/flatsqlIoContract.js +240 -0
- package/src/host/flatsqlIoImports.js +335 -0
- package/src/host/flatsqlIoMemoryBackend.js +178 -0
- package/src/host/flatsqlIoServer.js +960 -0
- package/src/host/flatsqlIoWorkers.js +323 -0
- package/src/host/hostWorkerBundleSources.js +11 -0
- package/src/host/hostWorkerBundles.js +79 -0
- package/src/host/index.js +9 -0
- package/src/host/nodeSyncFsIo.js +633 -0
- package/src/host/opfsIoBackend.js +280 -0
- package/src/host/opfsIoWorker.mjs +223 -0
- package/src/host/sabIoChannel.js +848 -0
- package/src/host/sabIoMirror.js +306 -0
- package/src/host/wasiThreadBrowserWorker.mjs +18 -11
- package/src/host/wasiThreadHost.js +258 -41
- package/src/host/wasiThreadWorker.mjs +24 -8
- package/src/host/wasiThreadWorkerRuntime.js +54 -0
- package/src/index.d.ts +347 -0
|
@@ -72,11 +72,20 @@ const IS_NODE =
|
|
|
72
72
|
|
|
73
73
|
const BROWSER_WORKER_FILENAME = "wasiThreadBrowserWorker.mjs";
|
|
74
74
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
{
|
|
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({
|
|
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
|
-
|
|
223
|
-
const timer = setTimeout(() =>
|
|
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
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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: () => -
|
|
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 -
|
|
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
|
|
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
|
-
//
|
|
464
|
-
//
|
|
465
|
-
// pool and runs the
|
|
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
|
-
?
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
722
|
+
if (armResult.ok) {
|
|
525
723
|
poolDisabled = false;
|
|
526
|
-
|
|
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 -
|
|
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 -
|
|
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 -
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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;
|