@atolljs/core 0.1.6 → 0.1.8
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/README.md +1 -1
- package/dist/contract/memoryDevtools.d.ts +30 -0
- package/dist/contract/sharedMemory.d.ts +6 -2
- package/dist/contract/types.d.ts +13 -0
- package/dist/devtools.d.ts +346 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +19 -16
- package/dist/index10.js +28 -184
- package/dist/index11.js +18 -48
- package/dist/index12.js +29 -63
- package/dist/index13.js +49 -24
- package/dist/index14.js +57 -59
- package/dist/index15.js +335 -45
- package/dist/index16.js +242 -47
- package/dist/index17.js +57 -38
- package/dist/index18.js +9 -86
- package/dist/index19.js +58 -48
- package/dist/index20.js +24 -56
- package/dist/index21.js +61 -0
- package/dist/index22.js +48 -0
- package/dist/index23.js +68 -0
- package/dist/index24.js +41 -0
- package/dist/index25.js +46 -0
- package/dist/index26.js +113 -0
- package/dist/index27.js +60 -0
- package/dist/index28.js +57 -0
- package/dist/index5.js +227 -35
- package/dist/index6.js +143 -471
- package/dist/index7.js +51 -20
- package/dist/index8.js +507 -27
- package/dist/index9.js +19 -18
- package/dist/pool/dedicatedWorker.d.ts +63 -0
- package/dist/pool/devtoolsCommands.d.ts +54 -0
- package/dist/pool/subWorkerClient.d.ts +50 -0
- package/dist/pool/workerChannel.d.ts +44 -0
- package/dist/pool/workerClient.d.ts +38 -8
- package/dist/pool/workerPool.d.ts +13 -0
- package/dist/reactive.d.ts +15 -0
- package/dist/reactiveGraph.d.ts +9 -0
- package/dist/userTiming.d.ts +27 -0
- package/package.json +2 -1
- package/src/contract/memoryDevtools.ts +249 -0
- package/src/contract/sharedMemory.ts +39 -9
- package/src/contract/types.ts +13 -0
- package/src/devtools.ts +735 -0
- package/src/index.ts +27 -1
- package/src/log.ts +22 -0
- package/src/observable.ts +16 -3
- package/src/pool/dedicatedWorker.ts +332 -0
- package/src/pool/devtoolsCommands.ts +165 -0
- package/src/pool/subWorkerClient.ts +70 -0
- package/src/pool/workerChannel.ts +99 -0
- package/src/pool/workerClient.ts +64 -15
- package/src/pool/workerPool.ts +115 -36
- package/src/reactive.ts +87 -12
- package/src/reactiveGraph.ts +91 -0
- package/src/testing/inProcessWorker.ts +4 -3
- package/src/userTiming.ts +71 -0
- package/src/worker/workerBootstrap.ts +29 -3
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { SharedSpec } from '../contract/sharedMemory';
|
|
2
|
+
import type { WorkerDefinition } from '../worker/defineWorker';
|
|
3
|
+
import { connectWorker, type ConnectWorkerConfig, type WorkerClient } from './workerClient';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Worker-shell side of a nested pool: identical options to
|
|
7
|
+
* {@link ConnectWorkerConfig}, since the pool protocol (INIT / INIT_MEMORY /
|
|
8
|
+
* EXECUTE_TASK) doesn't care which side of the thread boundary spawned the
|
|
9
|
+
* workers. The distinguishing knob is `sharedBuffer` — inside a worker
|
|
10
|
+
* shell, a bound contract's `.buffer` (or the shell pool's `sharedBuffer`)
|
|
11
|
+
* lets sub-workers attach to the SAME shared memory instead of a fresh one.
|
|
12
|
+
*/
|
|
13
|
+
export interface ConnectSubWorkerConfig<S extends SharedSpec> extends ConnectWorkerConfig<S> {}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Spawn a worker pool from INSIDE a worker — the worker-shell counterpart
|
|
17
|
+
* of {@link connectWorker}. The sub-worker entry is a normal `defineWorker`
|
|
18
|
+
* module; only the spawn site differs:
|
|
19
|
+
*
|
|
20
|
+
* // shell.worker.ts — runs inside a dedicated worker
|
|
21
|
+
* const sub = connectSubWorker<SubWorker>({
|
|
22
|
+
* worker: () => new Worker(new URL('./sub.worker.ts', import.meta.url), { type: 'module' }),
|
|
23
|
+
* sharedMemory: memory, // fresh buffer per sub-pool
|
|
24
|
+
* // — or attach sub-workers to THIS worker's bound buffer:
|
|
25
|
+
* // sharedBuffer: memory.buffer,
|
|
26
|
+
* workers: 2,
|
|
27
|
+
* });
|
|
28
|
+
*
|
|
29
|
+
* methods: { fanOut: (chunks) => Promise.all(chunks.map((c) => sub.crunch(c))) }
|
|
30
|
+
*
|
|
31
|
+
* Memory modes (both supported, pick per config):
|
|
32
|
+
* - `sharedMemory` without `sharedBuffer`: the sub-pool allocates its own
|
|
33
|
+
* WebAssembly.Memory inside the worker and binds the contract to it —
|
|
34
|
+
* a private buffer for this tier.
|
|
35
|
+
* - `sharedBuffer: someContract.buffer`: no allocation — every sub-worker
|
|
36
|
+
* binds the shell's existing buffer, so all three tiers (main, shell,
|
|
37
|
+
* sub) read and write the same fields. Hub-and-spoke sharing.
|
|
38
|
+
*
|
|
39
|
+
* Nested workers need `new Worker` inside a worker context: Chrome and
|
|
40
|
+
* Firefox support it, Safari does not — feature-detect and fall back to
|
|
41
|
+
* dispatching on the main-thread pool. Node's `worker_threads` nests
|
|
42
|
+
* natively; use `worker: () => createNodeWorker(new Worker('./sub.js'))`
|
|
43
|
+
* (a sub-worker entry still needs `@atolljs/node/shim` first).
|
|
44
|
+
*
|
|
45
|
+
* Bundlers detect `new Worker(new URL(...))` inside a worker bundle too:
|
|
46
|
+
* the @atolljs/vite dev plugin rewrites nested entry URLs back onto its
|
|
47
|
+
* `?worker_file` bundling path, and `vite build` emits nested worker
|
|
48
|
+
* chunks. The same rules as the main thread apply — the URL must stay an
|
|
49
|
+
* inline literal, never hoisted or computed.
|
|
50
|
+
*/
|
|
51
|
+
export function connectSubWorker<
|
|
52
|
+
W extends WorkerDefinition,
|
|
53
|
+
S extends SharedSpec = W extends WorkerDefinition<infer X> ? X : SharedSpec,
|
|
54
|
+
>(config: ConnectSubWorkerConfig<S>): WorkerClient<W, S> {
|
|
55
|
+
const guarded: ConnectSubWorkerConfig<S> = { ...config };
|
|
56
|
+
if (typeof config.worker !== 'function') {
|
|
57
|
+
// A URL config means we call `new Worker` ourselves — surface a clear
|
|
58
|
+
// error on engines without nested workers instead of a bare
|
|
59
|
+
// ReferenceError at spawn time.
|
|
60
|
+
guarded.worker = () => {
|
|
61
|
+
if (typeof Worker === 'undefined') {
|
|
62
|
+
throw new Error(
|
|
63
|
+
'connectSubWorker: no `Worker` global in this context — nested dedicated workers are unsupported here (e.g. Safari). Feature-detect and fall back to a main-thread pool.',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return new Worker(config.worker as URL, { type: 'module' });
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return connectWorker<W, S>(guarded);
|
|
70
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-worker plumbing both runners share: `WorkerPool` (N workers,
|
|
3
|
+
* queue + least-busy scheduling) and `DedicatedWorker` (one worker, no
|
|
4
|
+
* pool). Memory setup, the INIT handshake, and devtools forwarding are the
|
|
5
|
+
* same protocol whichever runner owns the worker.
|
|
6
|
+
*/
|
|
7
|
+
import { MemoryManager } from './memory';
|
|
8
|
+
import type { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory';
|
|
9
|
+
import type { MemoryPersistence, TaskMap, WorkerPoolConfig } from '../contract/types';
|
|
10
|
+
import { devtoolsEnabled, forwardDevtools, type EmittedDevtoolsEvent } from '../devtools';
|
|
11
|
+
|
|
12
|
+
export interface ChannelMemory<S extends SharedSpec> {
|
|
13
|
+
sharedMemory: (SharedMemory<S> & SharedAccess<S>) | undefined;
|
|
14
|
+
memoryManager?: MemoryManager;
|
|
15
|
+
/** A caller-supplied buffer shipped via INIT_MEMORY (sub-worker / second-runner sharing). */
|
|
16
|
+
injectedBuffer?: SharedArrayBuffer;
|
|
17
|
+
persistence: MemoryPersistence | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the runner's memory once, at construction: a caller-supplied
|
|
22
|
+
* buffer binds the contract to those bytes, otherwise a fresh
|
|
23
|
+
* WebAssembly.Memory is allocated. Message-only runners get neither.
|
|
24
|
+
*/
|
|
25
|
+
export function setupChannelMemory<S extends SharedSpec>(
|
|
26
|
+
config: WorkerPoolConfig<S, TaskMap>,
|
|
27
|
+
owner: string,
|
|
28
|
+
): ChannelMemory<S> {
|
|
29
|
+
if (config.sharedMemory || config.sharedBuffer !== undefined) {
|
|
30
|
+
if (typeof SharedArrayBuffer === 'undefined' || (typeof crossOriginIsolated !== 'undefined' && !crossOriginIsolated)) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`${owner} requires SharedArrayBuffer in a cross-origin isolated context — serve the page with COOP/COEP headers.`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
let injectedBuffer: SharedArrayBuffer | undefined;
|
|
37
|
+
if (config.sharedBuffer !== undefined) {
|
|
38
|
+
injectedBuffer = typeof config.sharedBuffer === 'function' ? config.sharedBuffer() : config.sharedBuffer;
|
|
39
|
+
if (!injectedBuffer) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`${owner}: \`sharedBuffer\` resolved to an empty buffer — the producing pool or contract has no bound memory yet.`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!config.sharedMemory) return { sharedMemory: undefined, injectedBuffer, persistence: undefined };
|
|
46
|
+
|
|
47
|
+
const sharedMemory = config.sharedMemory;
|
|
48
|
+
let memoryManager: MemoryManager | undefined;
|
|
49
|
+
if (injectedBuffer) {
|
|
50
|
+
sharedMemory.bind(injectedBuffer);
|
|
51
|
+
} else {
|
|
52
|
+
memoryManager = new MemoryManager(config.memory);
|
|
53
|
+
memoryManager.ensureCapacity(sharedMemory.totalBytes);
|
|
54
|
+
sharedMemory.bind(memoryManager.getBuffer());
|
|
55
|
+
}
|
|
56
|
+
return { sharedMemory, memoryManager, injectedBuffer, persistence: config.persistence?.(sharedMemory) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The INIT handshake: INIT_MEMORY with the runner's memory (or the injected
|
|
61
|
+
* buffer), bare INIT for message-only runners. `devtools` tells the worker
|
|
62
|
+
* to forward its instrumentation back only when a sink is live at spawn.
|
|
63
|
+
*/
|
|
64
|
+
export function sendInit<S extends SharedSpec>(worker: Worker, mem: ChannelMemory<S>): 'INIT' | 'INIT_MEMORY' {
|
|
65
|
+
if (mem.memoryManager) {
|
|
66
|
+
worker.postMessage({ type: 'INIT_MEMORY', memory: mem.memoryManager.memory, devtools: devtoolsEnabled() });
|
|
67
|
+
return 'INIT_MEMORY';
|
|
68
|
+
}
|
|
69
|
+
if (mem.injectedBuffer) {
|
|
70
|
+
worker.postMessage({ type: 'INIT_MEMORY', buffer: mem.injectedBuffer, devtools: devtoolsEnabled() });
|
|
71
|
+
return 'INIT_MEMORY';
|
|
72
|
+
}
|
|
73
|
+
worker.postMessage({ type: 'INIT', devtools: devtoolsEnabled() });
|
|
74
|
+
return 'INIT';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Re-emit a worker-forwarded devtools event into this thread's sink.
|
|
79
|
+
*
|
|
80
|
+
* A runner constructed inside a worker mints ids ('pool-1', 'worker-1') on
|
|
81
|
+
* that context's own counters, which collide textually with ids minted on
|
|
82
|
+
* the parent thread. Every id arriving from a worker refers to the worker's
|
|
83
|
+
* local id space, so it is qualified unconditionally as
|
|
84
|
+
* `${runnerId}#${slot}~${id}`: the id then encodes the spawn path and
|
|
85
|
+
* dashboards can draw parent→child edges. Existing `worker` stamps
|
|
86
|
+
* (multi-hop forwarding) are qualified the same way so attribution stays
|
|
87
|
+
* pinned to the deepest emitting worker.
|
|
88
|
+
*/
|
|
89
|
+
export function forwardWorkerDevtools(runnerId: string, slot: number, ev: EmittedDevtoolsEvent & { poolId?: string }): void {
|
|
90
|
+
const qualify = (id: string): string => `${runnerId}#${slot}~${id}`;
|
|
91
|
+
forwardDevtools({
|
|
92
|
+
...ev,
|
|
93
|
+
...(ev.poolId !== undefined ? { poolId: qualify(ev.poolId) } : {}),
|
|
94
|
+
worker:
|
|
95
|
+
ev.worker === undefined
|
|
96
|
+
? { poolId: runnerId, slot }
|
|
97
|
+
: { poolId: qualify(ev.worker.poolId), slot: ev.worker.slot },
|
|
98
|
+
});
|
|
99
|
+
}
|
package/src/pool/workerClient.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ServiceMethod, TaskRunner } from '../service';
|
|
|
2
2
|
import type { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory';
|
|
3
3
|
import type { MemoryConfig, Prettify } from '../contract/types';
|
|
4
4
|
import { WorkerPool, type RunOptions } from './workerPool';
|
|
5
|
+
import { DedicatedWorker } from './dedicatedWorker';
|
|
5
6
|
import type {
|
|
6
7
|
WorkerDefinition,
|
|
7
8
|
WorkerMethodMap,
|
|
@@ -92,16 +93,33 @@ export function workerClient<W extends WorkerDefinition>(
|
|
|
92
93
|
export interface ConnectWorkerConfig<S extends SharedSpec> {
|
|
93
94
|
/** Omit for a message-only pool — the SharedArrayBuffer check is skipped. */
|
|
94
95
|
sharedMemory?: SharedMemory<S> & Prettify<SharedAccess<S>>;
|
|
96
|
+
/**
|
|
97
|
+
* Ship an existing SharedArrayBuffer to every worker instead of
|
|
98
|
+
* allocating one — e.g. `sharedBuffer: anotherPool.sharedBuffer`, or a
|
|
99
|
+
* bound contract's `contract.buffer` inside a worker shell. A thunk is
|
|
100
|
+
* resolved once at pool construction.
|
|
101
|
+
*/
|
|
102
|
+
sharedBuffer?: SharedArrayBuffer | (() => SharedArrayBuffer | undefined);
|
|
95
103
|
/**
|
|
96
104
|
* Bundler-detectable factory `() => new Worker(new URL('./x.worker.ts', import.meta.url), { type: 'module' })`,
|
|
97
105
|
* or a URL.
|
|
98
106
|
*/
|
|
99
107
|
worker: (() => Worker) | URL;
|
|
108
|
+
/**
|
|
109
|
+
* How many workers this client needs. `1` (the default) is a single
|
|
110
|
+
* dedicated worker with no pool: calls post straight to it, no queue or
|
|
111
|
+
* scheduler. `N > 1` builds a pool that spreads separate calls across N
|
|
112
|
+
* workers; `'auto'` sizes it to `navigator.hardwareConcurrency ?? 4`.
|
|
113
|
+
*/
|
|
114
|
+
workers?: number | 'auto';
|
|
115
|
+
/** @deprecated Use `workers`. Read only when `workers` is absent. */
|
|
100
116
|
poolSize?: number | 'auto';
|
|
117
|
+
/** Display label for devtools/dashboards — falls back to a generated id. */
|
|
118
|
+
name?: string;
|
|
101
119
|
memory?: MemoryConfig;
|
|
102
|
-
/**
|
|
120
|
+
/** Pool only: max in-flight tasks per worker (default 1); excess calls queue FIFO. */
|
|
103
121
|
concurrency?: number;
|
|
104
|
-
/**
|
|
122
|
+
/** Pool only: max queued calls (default Infinity); a full queue rejects with PoolQueueFullError. */
|
|
105
123
|
maxQueue?: number;
|
|
106
124
|
/** Default true — a crashed worker is replaced and its in-flight calls reject. */
|
|
107
125
|
respawn?: boolean;
|
|
@@ -111,13 +129,29 @@ export interface ConnectWorkerConfig<S extends SharedSpec> {
|
|
|
111
129
|
lazy?: boolean;
|
|
112
130
|
}
|
|
113
131
|
|
|
114
|
-
/**
|
|
132
|
+
/** What a client runs on: a pool (`workers > 1`) or one dedicated worker. */
|
|
133
|
+
export type WorkerRunner<S extends SharedSpec = SharedSpec> = WorkerPool<S> | DedicatedWorker<S>;
|
|
134
|
+
|
|
135
|
+
/** Resolve a `workers` count: `'auto'` is one per core (4 when unknown). */
|
|
136
|
+
export const resolveWorkerCount = (n: number | 'auto' | undefined): number =>
|
|
137
|
+
n === 'auto'
|
|
138
|
+
? typeof navigator !== 'undefined'
|
|
139
|
+
? (navigator.hardwareConcurrency ?? 4)
|
|
140
|
+
: 4
|
|
141
|
+
: Math.max(1, Math.floor(n ?? 1));
|
|
142
|
+
|
|
143
|
+
/** A typed worker client plus lifecycle controls. */
|
|
115
144
|
export type WorkerClient<W extends WorkerDefinition, S extends SharedSpec> = WorkerMethods<W> & {
|
|
116
|
-
/** Spawn the
|
|
145
|
+
/** Spawn the worker(s) now (idempotent). */
|
|
117
146
|
start(): void;
|
|
118
|
-
/** Terminate the
|
|
147
|
+
/** Terminate the worker(s); the next method call re-spawns. */
|
|
119
148
|
terminate(): void;
|
|
120
|
-
|
|
149
|
+
/**
|
|
150
|
+
* The runner once spawned: a `DedicatedWorker` for `workers: 1`, a
|
|
151
|
+
* `WorkerPool` otherwise. Both expose `stats()`, `close()`, `terminate()`,
|
|
152
|
+
* `workers`, `sharedBuffer` and `poolId`.
|
|
153
|
+
*/
|
|
154
|
+
readonly pool: WorkerRunner<S> | null;
|
|
121
155
|
readonly sharedMemory: ConnectWorkerConfig<S>['sharedMemory'] | undefined;
|
|
122
156
|
};
|
|
123
157
|
|
|
@@ -130,12 +164,15 @@ export type WorkerClient<W extends WorkerDefinition, S extends SharedSpec> = Wor
|
|
|
130
164
|
* export const incidents = connectWorker<IncidentsWorker>({
|
|
131
165
|
* sharedMemory: incidentsMemory,
|
|
132
166
|
* worker: () => new Worker(new URL('./worker/incidents.worker.ts', import.meta.url), { type: 'module' }),
|
|
133
|
-
*
|
|
167
|
+
* workers: 3, // omit for one dedicated worker
|
|
134
168
|
* });
|
|
135
169
|
*
|
|
136
|
-
* await incidents.queryIncidents(q); // typed dispatch
|
|
170
|
+
* await incidents.queryIncidents(q); // typed dispatch
|
|
137
171
|
* incidents.terminate(); // next call lazily re-spawns
|
|
138
172
|
*
|
|
173
|
+
* `workers` states what the work needs and the client picks the runner:
|
|
174
|
+
* 1 (default) → a `DedicatedWorker`, no pool; more → a `WorkerPool`.
|
|
175
|
+
*
|
|
139
176
|
* Lazy by default: no Worker is touched until the first method call (or
|
|
140
177
|
* `start()`), so importing the client is SSR-safe.
|
|
141
178
|
*/
|
|
@@ -143,21 +180,33 @@ export function connectWorker<
|
|
|
143
180
|
W extends WorkerDefinition,
|
|
144
181
|
S extends SharedSpec = W extends WorkerDefinition<infer X> ? X : SharedSpec,
|
|
145
182
|
>(config: ConnectWorkerConfig<S>): WorkerClient<W, S> {
|
|
146
|
-
let pool:
|
|
147
|
-
const spawn = ():
|
|
148
|
-
(pool
|
|
183
|
+
let pool: WorkerRunner<S> | null = null;
|
|
184
|
+
const spawn = (): WorkerRunner<S> => {
|
|
185
|
+
if (pool) return pool;
|
|
186
|
+
const count = resolveWorkerCount(config.workers ?? config.poolSize);
|
|
187
|
+
const base = {
|
|
149
188
|
sharedMemory: config.sharedMemory,
|
|
189
|
+
sharedBuffer: config.sharedBuffer,
|
|
150
190
|
createWorker:
|
|
151
191
|
typeof config.worker === 'function'
|
|
152
192
|
? config.worker
|
|
153
193
|
: () => new Worker(config.worker as URL, { type: 'module' }),
|
|
154
|
-
|
|
194
|
+
name: config.name,
|
|
155
195
|
memory: config.memory,
|
|
156
|
-
concurrency: config.concurrency,
|
|
157
|
-
maxQueue: config.maxQueue,
|
|
158
196
|
respawn: config.respawn,
|
|
159
197
|
taskTimeout: config.taskTimeout,
|
|
160
|
-
}
|
|
198
|
+
};
|
|
199
|
+
pool =
|
|
200
|
+
count === 1
|
|
201
|
+
? new DedicatedWorker<S>(base)
|
|
202
|
+
: new WorkerPool<S>({
|
|
203
|
+
...base,
|
|
204
|
+
poolSize: count,
|
|
205
|
+
concurrency: config.concurrency,
|
|
206
|
+
maxQueue: config.maxQueue,
|
|
207
|
+
});
|
|
208
|
+
return pool;
|
|
209
|
+
};
|
|
161
210
|
|
|
162
211
|
if (config.lazy === false) spawn();
|
|
163
212
|
|
package/src/pool/workerPool.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { MemoryManager } from './memory';
|
|
1
|
+
import type { MemoryManager } from './memory';
|
|
2
2
|
import { PoolQueueFullError, TaskAbortedError, TaskTimeoutError, WorkerCrashedError } from './errors';
|
|
3
3
|
import { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory';
|
|
4
4
|
import { MemoryPersistence, PoolTasks, TaskContract, TaskMap, TaskResult, WorkerPoolConfig } from '../contract/types';
|
|
5
|
+
import { devtoolsEnabled, emitDevtools, estimateCloneBytes, nextDevtoolsId, type EmittedDevtoolsEvent } from '../devtools';
|
|
6
|
+
import { measureTaskCall } from '../userTiming';
|
|
7
|
+
import { forwardWorkerDevtools, sendInit, setupChannelMemory, type ChannelMemory } from './workerChannel';
|
|
5
8
|
import { fmtBytes, scoped } from '../log';
|
|
9
|
+
import { applyChaos, CHAOS_FAILURE, KILL_MESSAGE, registerDevtoolsRunner, type ChaosConfig } from './devtoolsCommands';
|
|
6
10
|
|
|
7
11
|
const poolLog = scoped('pool');
|
|
8
12
|
const taskLog = scoped('task');
|
|
@@ -59,6 +63,8 @@ export interface PoolStats {
|
|
|
59
63
|
interface TaskEntry {
|
|
60
64
|
contract: TaskContract<any[], any>;
|
|
61
65
|
args: any[];
|
|
66
|
+
/** Per-pool sequence — correlates a call's devtools events across its lifecycle. */
|
|
67
|
+
callId: number;
|
|
62
68
|
enqueuedAt: number;
|
|
63
69
|
resolve: (v: any) => void;
|
|
64
70
|
reject: (e: unknown) => void;
|
|
@@ -83,7 +89,10 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
83
89
|
private pending = new Map<number, TaskEntry>();
|
|
84
90
|
private nextMessageId = 0;
|
|
85
91
|
private queue: TaskEntry[] = [];
|
|
92
|
+
private readonly mem: ChannelMemory<S>;
|
|
86
93
|
private memoryManager?: MemoryManager;
|
|
94
|
+
/** A caller-supplied buffer shipped via INIT_MEMORY (sub-worker / second-pool sharing). */
|
|
95
|
+
private injectedBuffer?: SharedArrayBuffer;
|
|
87
96
|
private workerUrl?: URL;
|
|
88
97
|
private spawn: () => Worker;
|
|
89
98
|
private readonly concurrency: number;
|
|
@@ -91,6 +100,9 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
91
100
|
private readonly respawn: boolean;
|
|
92
101
|
private readonly taskTimeout?: number;
|
|
93
102
|
private closed = false;
|
|
103
|
+
/** Dashboard fault injection (`pool.chaos`); null unless devtools set it. */
|
|
104
|
+
private chaos: ChaosConfig | null = null;
|
|
105
|
+
private devtoolsOff?: () => void;
|
|
94
106
|
private drainWaiters: Array<() => void> = [];
|
|
95
107
|
private statsAgg = {
|
|
96
108
|
completed: 0,
|
|
@@ -100,18 +112,19 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
100
112
|
waitMs: agg(),
|
|
101
113
|
runMs: agg(),
|
|
102
114
|
};
|
|
115
|
+
/**
|
|
116
|
+
* Devtools correlation id, stamped on every event this pool emits:
|
|
117
|
+
* '<name>-p' when the config has a `name`, else 'pool-N'.
|
|
118
|
+
*/
|
|
119
|
+
readonly poolId: string;
|
|
120
|
+
private readonly label?: string;
|
|
121
|
+
private nextCallId = 0;
|
|
103
122
|
public readonly sharedMemory: (SharedMemory<S> & SharedAccess<S>) | undefined;
|
|
104
123
|
/** The attached memory-persistence adapter, if the config supplied one. */
|
|
105
124
|
public readonly persistence: MemoryPersistence | undefined;
|
|
106
125
|
|
|
107
126
|
constructor(config: WorkerPoolConfig<S, TaskMap>) {
|
|
108
|
-
|
|
109
|
-
if (typeof SharedArrayBuffer === 'undefined' || (typeof crossOriginIsolated !== 'undefined' && !crossOriginIsolated)) {
|
|
110
|
-
throw new Error(
|
|
111
|
-
'WorkerPool requires SharedArrayBuffer in a cross-origin isolated context — serve the page with COOP/COEP headers.'
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
127
|
+
const mem = setupChannelMemory(config, 'WorkerPool');
|
|
115
128
|
if (!config.workerUrl && !config.createWorker) {
|
|
116
129
|
throw new Error('WorkerPool requires either `workerUrl` or `createWorker` in its config.');
|
|
117
130
|
}
|
|
@@ -121,30 +134,58 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
121
134
|
this.maxQueue = config.maxQueue ?? Infinity;
|
|
122
135
|
this.respawn = config.respawn ?? true;
|
|
123
136
|
this.taskTimeout = config.taskTimeout;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
137
|
+
this.label = config.name;
|
|
138
|
+
this.poolId = nextDevtoolsId('pool', config.name);
|
|
139
|
+
this.mem = mem;
|
|
140
|
+
this.sharedMemory = mem.sharedMemory;
|
|
141
|
+
this.memoryManager = mem.memoryManager;
|
|
142
|
+
this.injectedBuffer = mem.injectedBuffer;
|
|
143
|
+
this.persistence = mem.persistence;
|
|
132
144
|
|
|
133
145
|
const requestedSize = config.poolSize ?? 'auto';
|
|
134
146
|
const poolSize =
|
|
135
|
-
requestedSize === 'auto'
|
|
147
|
+
requestedSize === 'auto'
|
|
148
|
+
? (typeof navigator !== 'undefined' ? navigator.hardwareConcurrency ?? 4 : 4)
|
|
149
|
+
: requestedSize;
|
|
136
150
|
|
|
137
151
|
poolLog.info(
|
|
138
152
|
this.sharedMemory
|
|
139
153
|
? `spawning ${poolSize} worker(s) with ${fmtBytes(this.sharedMemory.totalBytes)} shared buffer`
|
|
140
|
-
:
|
|
154
|
+
: this.injectedBuffer
|
|
155
|
+
? `spawning ${poolSize} worker(s) with ${fmtBytes(this.injectedBuffer.byteLength)} inherited buffer`
|
|
156
|
+
: `spawning ${poolSize} worker(s), message-only`,
|
|
141
157
|
{ workerUrl: config.workerUrl ? String(config.workerUrl) : 'createWorker()' },
|
|
142
158
|
);
|
|
143
159
|
poolLog.debug(`tip: setLogLevel('trace') for per-task detail`);
|
|
144
160
|
|
|
161
|
+
emitDevtools({
|
|
162
|
+
type: 'pool:init',
|
|
163
|
+
poolId: this.poolId,
|
|
164
|
+
label: this.label,
|
|
165
|
+
poolSize,
|
|
166
|
+
concurrency: this.concurrency,
|
|
167
|
+
memoryBytes: this.sharedMemory?.totalBytes ?? this.injectedBuffer?.byteLength,
|
|
168
|
+
});
|
|
169
|
+
|
|
145
170
|
for (let i = 0; i < poolSize; i++) {
|
|
146
171
|
this.addWorker(this.spawn());
|
|
147
172
|
}
|
|
173
|
+
if (devtoolsEnabled()) {
|
|
174
|
+
this.devtoolsOff = registerDevtoolsRunner({
|
|
175
|
+
poolId: this.poolId,
|
|
176
|
+
label: this.label,
|
|
177
|
+
dedicated: false,
|
|
178
|
+
size: () => this.slots.length,
|
|
179
|
+
stats: () => this.stats(),
|
|
180
|
+
kill: (slot) => {
|
|
181
|
+
const s = this.slots[slot];
|
|
182
|
+
if (!s) throw new Error(`${this.poolId} has no worker slot ${slot}`);
|
|
183
|
+
this.handleWorkerError(s, { message: KILL_MESSAGE });
|
|
184
|
+
},
|
|
185
|
+
getChaos: () => this.chaos,
|
|
186
|
+
setChaos: (chaos) => { this.chaos = chaos; },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
148
189
|
|
|
149
190
|
// Install first-class task methods: pool.queryIncidents(q) →
|
|
150
191
|
// runTask(QueryIncidents, q). Typed through PoolTasks<T> at the
|
|
@@ -167,18 +208,24 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
167
208
|
private addWorker(worker: Worker): WorkerSlot {
|
|
168
209
|
const slot: WorkerSlot = { worker, inFlight: 0 };
|
|
169
210
|
worker.addEventListener('message', (event: MessageEvent) => {
|
|
170
|
-
|
|
211
|
+
const data = event.data as TaskResult & { messageId?: number; type?: string; event?: EmittedDevtoolsEvent };
|
|
212
|
+
// Worker-side instrumentation forwarded on the task channel — re-emit
|
|
213
|
+
// into the local sink so one sink sees the whole app. Stamp which
|
|
214
|
+
// slot produced it so worker-side fetches/heap/memory writes
|
|
215
|
+
// attribute to a specific worker.
|
|
216
|
+
if (data.type === 'ATOLL_DEVTOOLS' && data.event) {
|
|
217
|
+
forwardWorkerDevtools(this.poolId, this.slots.indexOf(slot), data.event);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.handleReply(slot, data);
|
|
171
221
|
});
|
|
172
222
|
worker.addEventListener('error', (event: unknown) => {
|
|
173
223
|
this.handleWorkerError(slot, event);
|
|
174
224
|
});
|
|
175
|
-
|
|
176
|
-
worker.postMessage({ type: 'INIT_MEMORY', memory: this.memoryManager.memory });
|
|
177
|
-
} else {
|
|
178
|
-
worker.postMessage({ type: 'INIT' });
|
|
179
|
-
}
|
|
225
|
+
const init = sendInit(worker, this.mem);
|
|
180
226
|
this.slots.push(slot);
|
|
181
|
-
poolLog.debug(`worker spawned, ${
|
|
227
|
+
poolLog.debug(`worker spawned, ${init} sent`);
|
|
228
|
+
emitDevtools({ type: 'worker:spawn', poolId: this.poolId, slot: this.slots.indexOf(slot) });
|
|
182
229
|
return slot;
|
|
183
230
|
}
|
|
184
231
|
|
|
@@ -192,14 +239,24 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
192
239
|
// stats and settlement — the caller was already rejected.
|
|
193
240
|
if (!entry.done) {
|
|
194
241
|
entry.done = true;
|
|
195
|
-
|
|
242
|
+
const start = entry.sentAt ?? entry.enqueuedAt;
|
|
243
|
+
const runMs = performance.now() - start;
|
|
244
|
+
this.statsAgg.runMs.add(runMs);
|
|
196
245
|
if (data.success) {
|
|
197
246
|
this.statsAgg.completed++;
|
|
198
247
|
taskLog.debug(`← ${entry.contract.taskId} ok`);
|
|
248
|
+
if (devtoolsEnabled()) {
|
|
249
|
+
emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: entry.callId, taskId: entry.contract.taskId, outcome: 'ok', runMs, resultBytes: estimateCloneBytes(data.result) });
|
|
250
|
+
measureTaskCall(this.poolId, entry.contract.taskId, entry.callId, this.slots.indexOf(slot), start, start + runMs, true);
|
|
251
|
+
}
|
|
199
252
|
entry.resolve(data.result);
|
|
200
253
|
} else {
|
|
201
254
|
this.statsAgg.failed++;
|
|
202
255
|
taskLog.warn(`← ${entry.contract.taskId} failed: ${data.error}`);
|
|
256
|
+
if (devtoolsEnabled()) {
|
|
257
|
+
emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: entry.callId, taskId: entry.contract.taskId, outcome: 'error', runMs, error: data.error });
|
|
258
|
+
measureTaskCall(this.poolId, entry.contract.taskId, entry.callId, this.slots.indexOf(slot), start, start + runMs, false);
|
|
259
|
+
}
|
|
203
260
|
entry.reject(new Error(data.error));
|
|
204
261
|
}
|
|
205
262
|
}
|
|
@@ -212,6 +269,8 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
212
269
|
const e = event as { message?: string; error?: Error } | undefined;
|
|
213
270
|
const message = e?.message ?? (e?.error ? String(e.error) : 'worker error');
|
|
214
271
|
poolLog.warn(`worker error: ${message}`);
|
|
272
|
+
const idx = this.slots.indexOf(slot);
|
|
273
|
+
emitDevtools({ type: 'worker:error', poolId: this.poolId, slot: idx, message });
|
|
215
274
|
|
|
216
275
|
// Reject the dead worker's in-flight tasks. Orphaned (already
|
|
217
276
|
// aborted/timed-out) entries are dropped without re-counting.
|
|
@@ -222,23 +281,25 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
222
281
|
if (entry.done) continue;
|
|
223
282
|
entry.done = true;
|
|
224
283
|
this.statsAgg.failed++;
|
|
284
|
+
emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: entry.callId, taskId: entry.contract.taskId, outcome: 'crashed', error: message });
|
|
225
285
|
entry.reject(new WorkerCrashedError(message));
|
|
226
286
|
}
|
|
227
287
|
}
|
|
228
288
|
|
|
229
|
-
const idx = this.slots.indexOf(slot);
|
|
230
289
|
if (idx >= 0) this.slots.splice(idx, 1);
|
|
231
290
|
slot.worker.terminate();
|
|
232
291
|
|
|
233
292
|
if (this.respawn) {
|
|
234
293
|
poolLog.info('respawning crashed worker');
|
|
235
294
|
this.addWorker(this.spawn());
|
|
295
|
+
emitDevtools({ type: 'worker:respawn', poolId: this.poolId, slot: this.slots.length - 1 });
|
|
236
296
|
} else if (this.slots.length === 0) {
|
|
237
297
|
// No workers left to ever serve the queue.
|
|
238
298
|
for (const entry of this.queue.splice(0)) {
|
|
239
299
|
entry.done = true;
|
|
240
300
|
this.detach(entry);
|
|
241
301
|
this.statsAgg.failed++;
|
|
302
|
+
emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: entry.callId, taskId: entry.contract.taskId, outcome: 'crashed', error: message });
|
|
242
303
|
entry.reject(new WorkerCrashedError(message));
|
|
243
304
|
}
|
|
244
305
|
}
|
|
@@ -270,16 +331,26 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
270
331
|
entry.messageId = messageId;
|
|
271
332
|
entry.slot = slot;
|
|
272
333
|
entry.sentAt = performance.now();
|
|
273
|
-
|
|
334
|
+
const waitMs = entry.sentAt - entry.enqueuedAt;
|
|
335
|
+
this.statsAgg.waitMs.add(waitMs);
|
|
274
336
|
slot.inFlight++;
|
|
275
337
|
this.pending.set(messageId, entry);
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
338
|
+
const slotIndex = this.slots.indexOf(slot);
|
|
339
|
+
taskLog.debug(`→ ${entry.contract.taskId}`, { worker: slotIndex, args: entry.args });
|
|
340
|
+
if (devtoolsEnabled()) {
|
|
341
|
+
emitDevtools({ type: 'task:dispatch', poolId: this.poolId, callId: entry.callId, taskId: entry.contract.taskId, slot: slotIndex, waitMs, argBytes: estimateCloneBytes(entry.args) });
|
|
342
|
+
}
|
|
343
|
+
const msg = { type: 'EXECUTE_TASK', messageId, taskId: entry.contract.taskId, args: entry.args };
|
|
344
|
+
if (this.chaos) {
|
|
345
|
+
applyChaos(this.chaos, {
|
|
346
|
+
live: () => this.pending.get(messageId) === entry,
|
|
347
|
+
fail: () => this.handleReply(slot, { messageId, taskId: msg.taskId, success: false, error: CHAOS_FAILURE }),
|
|
348
|
+
timeout: () => this.cancel(entry, new TaskTimeoutError()),
|
|
349
|
+
post: () => slot.worker.postMessage(msg),
|
|
350
|
+
});
|
|
351
|
+
} else {
|
|
352
|
+
slot.worker.postMessage(msg);
|
|
353
|
+
}
|
|
283
354
|
}
|
|
284
355
|
|
|
285
356
|
/** Detaches signal/timeout listeners from a finalized entry. */
|
|
@@ -302,6 +373,7 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
302
373
|
entry.done = true;
|
|
303
374
|
this.detach(entry);
|
|
304
375
|
this.statsAgg.aborted++;
|
|
376
|
+
emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: entry.callId, taskId: entry.contract.taskId, outcome: err instanceof TaskTimeoutError ? 'timeout' : 'aborted' });
|
|
305
377
|
entry.reject(err);
|
|
306
378
|
this.drainQueue();
|
|
307
379
|
this.checkDrained();
|
|
@@ -333,6 +405,7 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
333
405
|
const entry: TaskEntry = {
|
|
334
406
|
contract,
|
|
335
407
|
args: args as any[],
|
|
408
|
+
callId: ++this.nextCallId,
|
|
336
409
|
enqueuedAt: performance.now(),
|
|
337
410
|
resolve,
|
|
338
411
|
reject,
|
|
@@ -348,12 +421,14 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
348
421
|
entry.timer = setTimeout(() => this.cancel(entry, new TaskTimeoutError()), timeout);
|
|
349
422
|
}
|
|
350
423
|
|
|
424
|
+
emitDevtools({ type: 'task:enqueue', poolId: this.poolId, callId: entry.callId, taskId: contract.taskId });
|
|
351
425
|
const slot = this.pickWorker();
|
|
352
426
|
if (slot) {
|
|
353
427
|
this.send(slot, entry);
|
|
354
428
|
} else if (this.queue.length >= this.maxQueue) {
|
|
355
429
|
entry.done = true;
|
|
356
430
|
this.detach(entry);
|
|
431
|
+
emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: entry.callId, taskId: contract.taskId, outcome: 'queue-full' });
|
|
357
432
|
reject(new PoolQueueFullError()); // never dispatched — stats untouched
|
|
358
433
|
return;
|
|
359
434
|
} else {
|
|
@@ -376,7 +451,7 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
376
451
|
* (e.g. a second pool of HTTP workers via withSharedBuffer).
|
|
377
452
|
*/
|
|
378
453
|
public get sharedBuffer(): SharedArrayBuffer | undefined {
|
|
379
|
-
return this.memoryManager?.getBuffer();
|
|
454
|
+
return this.memoryManager?.getBuffer() ?? this.injectedBuffer;
|
|
380
455
|
}
|
|
381
456
|
|
|
382
457
|
/**
|
|
@@ -420,17 +495,20 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
420
495
|
|
|
421
496
|
public terminate(): void {
|
|
422
497
|
this.closed = true;
|
|
498
|
+
this.devtoolsOff?.();
|
|
423
499
|
// Final flush + release — fire-and-forget; terminate() stays synchronous.
|
|
424
500
|
void this.persistence?.stop()?.catch?.((e: unknown) =>
|
|
425
501
|
poolLog.warn('memory persistence stop failed', e),
|
|
426
502
|
);
|
|
427
503
|
poolLog.info(`terminating ${this.slots.length} worker(s)`);
|
|
504
|
+
emitDevtools({ type: 'pool:terminate', poolId: this.poolId });
|
|
428
505
|
const crashed = new WorkerCrashedError('pool terminated');
|
|
429
506
|
for (const [, entry] of this.pending) {
|
|
430
507
|
this.detach(entry);
|
|
431
508
|
if (entry.done) continue; // orphaned — already counted as aborted
|
|
432
509
|
entry.done = true;
|
|
433
510
|
this.statsAgg.failed++;
|
|
511
|
+
emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: entry.callId, taskId: entry.contract.taskId, outcome: 'crashed', error: 'pool terminated' });
|
|
434
512
|
entry.reject(crashed);
|
|
435
513
|
}
|
|
436
514
|
this.pending.clear();
|
|
@@ -438,6 +516,7 @@ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
|
|
|
438
516
|
entry.done = true;
|
|
439
517
|
this.detach(entry);
|
|
440
518
|
this.statsAgg.failed++;
|
|
519
|
+
emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: entry.callId, taskId: entry.contract.taskId, outcome: 'crashed', error: 'pool terminated' });
|
|
441
520
|
entry.reject(crashed);
|
|
442
521
|
}
|
|
443
522
|
for (const slot of this.slots) {
|