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