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.
- package/docs/isomorphic-pthreads.html +100 -0
- package/docs/isomorphic-pthreads.md +335 -0
- package/package.json +7 -2
- package/src/browser.js +6 -0
- package/src/compiler/invokeGlue.js +38 -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/browserModuleHarness.js +24 -5
- 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/isomorphicLoader.js +10 -2
- 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 +21 -11
- package/src/host/wasiThreadHost.js +261 -43
- package/src/host/wasiThreadWorker.mjs +46 -8
- package/src/host/wasiThreadWorkerRuntime.js +54 -0
- package/src/index.d.ts +349 -0
- package/src/runtime/constants.js +6 -0
- package/src/testing/native/wasmedge_wasi_threads_runner.c +13 -5
- package/src/testing/parityHarness.js +7 -1
- 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
|
-
/**
|
|
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
|
|
@@ -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
|
|
404
|
-
//
|
|
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 -
|
|
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
|
|
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
|
-
//
|
|
463
|
-
//
|
|
464
|
-
// 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).
|
|
465
631
|
const poolSize = armed
|
|
466
|
-
?
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
722
|
+
if (armResult.ok) {
|
|
524
723
|
poolDisabled = false;
|
|
525
|
-
|
|
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 -
|
|
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 -
|
|
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 -
|
|
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 {
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 {
|