@atolljs/core 0.1.7 → 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 +4 -2
- package/dist/contract/types.d.ts +11 -0
- package/dist/devtools.d.ts +127 -4
- package/dist/index.d.ts +8 -4
- package/dist/index.js +19 -17
- package/dist/index10.js +29 -14
- package/dist/index11.js +14 -307
- package/dist/index12.js +29 -50
- package/dist/index13.js +46 -60
- package/dist/index14.js +56 -22
- package/dist/index15.js +332 -55
- package/dist/index16.js +246 -45
- package/dist/index17.js +56 -51
- package/dist/index18.js +9 -39
- package/dist/index19.js +56 -80
- package/dist/index20.js +24 -53
- package/dist/index21.js +56 -52
- 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 +135 -64
- package/dist/index6.js +160 -35
- package/dist/index7.js +45 -503
- package/dist/index8.js +513 -19
- package/dist/index9.js +19 -33
- 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 +36 -8
- package/dist/pool/workerPool.d.ts +10 -1
- 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 +20 -6
- package/src/contract/types.ts +11 -0
- package/src/devtools.ts +251 -6
- package/src/index.ts +13 -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 +61 -15
- package/src/pool/workerPool.ts +75 -45
- 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 +27 -4
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import type { MemoryManager } from './memory';
|
|
2
|
+
import { TaskAbortedError, TaskTimeoutError, WorkerCrashedError } from './errors';
|
|
3
|
+
import type { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory';
|
|
4
|
+
import type { MemoryPersistence, TaskContract, TaskMap, TaskResult, WorkerPoolConfig } from '../contract/types';
|
|
5
|
+
import { devtoolsEnabled, emitDevtools, estimateCloneBytes, nextDevtoolsId, type EmittedDevtoolsEvent } from '../devtools';
|
|
6
|
+
import { fmtBytes, scoped } from '../log';
|
|
7
|
+
import { measureTaskCall } from '../userTiming';
|
|
8
|
+
import type { PoolStats, RunOptions } from './workerPool';
|
|
9
|
+
import { forwardWorkerDevtools, sendInit, setupChannelMemory, type ChannelMemory } from './workerChannel';
|
|
10
|
+
import { applyChaos, CHAOS_FAILURE, KILL_MESSAGE, registerDevtoolsRunner, type ChaosConfig } from './devtoolsCommands';
|
|
11
|
+
|
|
12
|
+
const workerLog = scoped('worker');
|
|
13
|
+
const taskLog = scoped('task');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Config for a single dedicated worker: the WorkerPool config minus the
|
|
17
|
+
* pool-only knobs (size, per-worker concurrency cap, queue bound, task
|
|
18
|
+
* methods). Every call posts straight to the worker.
|
|
19
|
+
*/
|
|
20
|
+
export type DedicatedWorkerConfig<S extends SharedSpec = SharedSpec> = Omit<
|
|
21
|
+
WorkerPoolConfig<S, TaskMap>,
|
|
22
|
+
'poolSize' | 'concurrency' | 'maxQueue' | 'tasks'
|
|
23
|
+
>;
|
|
24
|
+
|
|
25
|
+
interface Call {
|
|
26
|
+
contract: TaskContract<any[], any>;
|
|
27
|
+
callId: number;
|
|
28
|
+
sentAt: number;
|
|
29
|
+
resolve: (v: any) => void;
|
|
30
|
+
reject: (e: unknown) => void;
|
|
31
|
+
/** Settled (or orphaned by abort/timeout): a late reply is discarded. */
|
|
32
|
+
done: boolean;
|
|
33
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
onSignalAbort?: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const agg = () => ({
|
|
39
|
+
count: 0,
|
|
40
|
+
mean: 0,
|
|
41
|
+
max: 0,
|
|
42
|
+
add(v: number) {
|
|
43
|
+
this.count++;
|
|
44
|
+
this.mean += (v - this.mean) / this.count;
|
|
45
|
+
if (v > this.max) this.max = v;
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One worker, no pool: what `connectWorker({ workers: 1 })` (the default)
|
|
51
|
+
* builds. Calls are posted to the worker immediately, with no queue and no
|
|
52
|
+
* scheduler; the worker's own message loop runs them in arrival order (a
|
|
53
|
+
* task that awaits lets the next message start, exactly as in a pool slot
|
|
54
|
+
* with a raised concurrency cap). Kept from the pool: per-call
|
|
55
|
+
* timeout/abort, crash respawn, `stats()`, and the same devtools events,
|
|
56
|
+
* with `pool:init` flagged `dedicated: true`.
|
|
57
|
+
*/
|
|
58
|
+
export class DedicatedWorker<S extends SharedSpec = SharedSpec> {
|
|
59
|
+
/**
|
|
60
|
+
* Devtools correlation id: '<name>-w' when the config has a `name`, else
|
|
61
|
+
* 'worker-N'. The same role a pool's id plays.
|
|
62
|
+
*/
|
|
63
|
+
readonly poolId: string;
|
|
64
|
+
readonly sharedMemory: (SharedMemory<S> & SharedAccess<S>) | undefined;
|
|
65
|
+
readonly persistence: MemoryPersistence | undefined;
|
|
66
|
+
private readonly mem: ChannelMemory<S>;
|
|
67
|
+
private readonly spawn: () => Worker;
|
|
68
|
+
private readonly respawn: boolean;
|
|
69
|
+
private readonly taskTimeout?: number;
|
|
70
|
+
private readonly label?: string;
|
|
71
|
+
private worker: Worker | null = null;
|
|
72
|
+
private pending = new Map<number, Call>();
|
|
73
|
+
private nextMessageId = 0;
|
|
74
|
+
private nextCallId = 0;
|
|
75
|
+
private closed = false;
|
|
76
|
+
/** Dashboard fault injection (`pool.chaos`); null unless devtools set it. */
|
|
77
|
+
private chaos: ChaosConfig | null = null;
|
|
78
|
+
private devtoolsOff?: () => void;
|
|
79
|
+
private drainWaiters: Array<() => void> = [];
|
|
80
|
+
private statsAgg = { completed: 0, failed: 0, aborted: 0, runMs: agg() };
|
|
81
|
+
|
|
82
|
+
constructor(config: DedicatedWorkerConfig<S>) {
|
|
83
|
+
this.mem = setupChannelMemory(config, 'DedicatedWorker');
|
|
84
|
+
if (!config.workerUrl && !config.createWorker) {
|
|
85
|
+
throw new Error('DedicatedWorker requires either `workerUrl` or `createWorker` in its config.');
|
|
86
|
+
}
|
|
87
|
+
const url = config.workerUrl;
|
|
88
|
+
this.spawn = config.createWorker ?? (() => new Worker(url!, { type: 'module' }));
|
|
89
|
+
this.respawn = config.respawn ?? true;
|
|
90
|
+
this.taskTimeout = config.taskTimeout;
|
|
91
|
+
this.label = config.name;
|
|
92
|
+
this.poolId = nextDevtoolsId('worker', config.name);
|
|
93
|
+
this.sharedMemory = this.mem.sharedMemory;
|
|
94
|
+
this.persistence = this.mem.persistence;
|
|
95
|
+
|
|
96
|
+
const bytes = this.mem.sharedMemory?.totalBytes ?? this.mem.injectedBuffer?.byteLength;
|
|
97
|
+
workerLog.info(
|
|
98
|
+
bytes !== undefined ? `spawning 1 dedicated worker with ${fmtBytes(bytes)} shared buffer` : 'spawning 1 dedicated worker, message-only',
|
|
99
|
+
);
|
|
100
|
+
emitDevtools({
|
|
101
|
+
type: 'pool:init',
|
|
102
|
+
poolId: this.poolId,
|
|
103
|
+
label: this.label,
|
|
104
|
+
poolSize: 1,
|
|
105
|
+
concurrency: 0,
|
|
106
|
+
memoryBytes: bytes,
|
|
107
|
+
dedicated: true,
|
|
108
|
+
});
|
|
109
|
+
this.attach(this.spawn());
|
|
110
|
+
if (devtoolsEnabled()) {
|
|
111
|
+
this.devtoolsOff = registerDevtoolsRunner({
|
|
112
|
+
poolId: this.poolId,
|
|
113
|
+
label: this.label,
|
|
114
|
+
dedicated: true,
|
|
115
|
+
size: () => (this.worker ? 1 : 0),
|
|
116
|
+
stats: () => this.stats(),
|
|
117
|
+
kill: (slot) => {
|
|
118
|
+
if (slot !== 0 || !this.worker) throw new Error(`${this.poolId} has no live worker at slot ${slot}`);
|
|
119
|
+
this.handleError({ message: KILL_MESSAGE });
|
|
120
|
+
},
|
|
121
|
+
getChaos: () => this.chaos,
|
|
122
|
+
setChaos: (chaos) => { this.chaos = chaos; },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private attach(worker: Worker): void {
|
|
128
|
+
this.worker = worker;
|
|
129
|
+
worker.addEventListener('message', (event: MessageEvent) => {
|
|
130
|
+
if (this.worker !== worker) return;
|
|
131
|
+
const data = event.data as TaskResult & { messageId?: number; type?: string; event?: EmittedDevtoolsEvent };
|
|
132
|
+
if (data.type === 'ATOLL_DEVTOOLS' && data.event) {
|
|
133
|
+
forwardWorkerDevtools(this.poolId, 0, data.event);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.handleReply(data);
|
|
137
|
+
});
|
|
138
|
+
worker.addEventListener('error', (event: unknown) => {
|
|
139
|
+
if (this.worker === worker) this.handleError(event);
|
|
140
|
+
});
|
|
141
|
+
const init = sendInit(worker, this.mem);
|
|
142
|
+
workerLog.debug(`dedicated worker spawned, ${init} sent`);
|
|
143
|
+
emitDevtools({ type: 'worker:spawn', poolId: this.poolId, slot: 0 });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private detach(call: Call): void {
|
|
147
|
+
if (call.timer) clearTimeout(call.timer);
|
|
148
|
+
if (call.signal && call.onSignalAbort) call.signal.removeEventListener('abort', call.onSignalAbort);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private handleReply(data: TaskResult & { messageId?: number }): void {
|
|
152
|
+
const call = data.messageId !== undefined ? this.pending.get(data.messageId) : undefined;
|
|
153
|
+
if (!call) return;
|
|
154
|
+
this.pending.delete(data.messageId!);
|
|
155
|
+
this.detach(call);
|
|
156
|
+
if (!call.done) {
|
|
157
|
+
call.done = true;
|
|
158
|
+
const runMs = performance.now() - call.sentAt;
|
|
159
|
+
this.statsAgg.runMs.add(runMs);
|
|
160
|
+
if (data.success) {
|
|
161
|
+
this.statsAgg.completed++;
|
|
162
|
+
taskLog.debug(`← ${call.contract.taskId} ok`);
|
|
163
|
+
if (devtoolsEnabled()) {
|
|
164
|
+
emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: call.callId, taskId: call.contract.taskId, outcome: 'ok', runMs, resultBytes: estimateCloneBytes(data.result) });
|
|
165
|
+
measureTaskCall(this.poolId, call.contract.taskId, call.callId, 0, call.sentAt, call.sentAt + runMs, true);
|
|
166
|
+
}
|
|
167
|
+
call.resolve(data.result);
|
|
168
|
+
} else {
|
|
169
|
+
this.statsAgg.failed++;
|
|
170
|
+
taskLog.warn(`← ${call.contract.taskId} failed: ${data.error}`);
|
|
171
|
+
if (devtoolsEnabled()) {
|
|
172
|
+
emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: call.callId, taskId: call.contract.taskId, outcome: 'error', runMs, error: data.error });
|
|
173
|
+
measureTaskCall(this.poolId, call.contract.taskId, call.callId, 0, call.sentAt, call.sentAt + runMs, false);
|
|
174
|
+
}
|
|
175
|
+
call.reject(new Error(data.error));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
this.checkDrained();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private handleError(event: unknown): void {
|
|
182
|
+
if (this.closed) return;
|
|
183
|
+
const e = event as { message?: string; error?: Error } | undefined;
|
|
184
|
+
const message = e?.message ?? (e?.error ? String(e.error) : 'worker error');
|
|
185
|
+
workerLog.warn(`worker error: ${message}`);
|
|
186
|
+
emitDevtools({ type: 'worker:error', poolId: this.poolId, slot: 0, message });
|
|
187
|
+
this.rejectPending(message);
|
|
188
|
+
this.worker?.terminate();
|
|
189
|
+
this.worker = null;
|
|
190
|
+
if (this.respawn) {
|
|
191
|
+
workerLog.info('respawning crashed worker');
|
|
192
|
+
this.attach(this.spawn());
|
|
193
|
+
emitDevtools({ type: 'worker:respawn', poolId: this.poolId, slot: 0 });
|
|
194
|
+
}
|
|
195
|
+
this.checkDrained();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private rejectPending(message: string): void {
|
|
199
|
+
const err = new WorkerCrashedError(message);
|
|
200
|
+
for (const call of this.pending.values()) {
|
|
201
|
+
this.detach(call);
|
|
202
|
+
if (call.done) continue;
|
|
203
|
+
call.done = true;
|
|
204
|
+
this.statsAgg.failed++;
|
|
205
|
+
emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: call.callId, taskId: call.contract.taskId, outcome: 'crashed', error: message });
|
|
206
|
+
call.reject(err);
|
|
207
|
+
}
|
|
208
|
+
this.pending.clear();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Abort/timeout: the caller rejects now; the worker's eventual reply is discarded. */
|
|
212
|
+
private cancel(call: Call, err: Error): void {
|
|
213
|
+
if (call.done) return;
|
|
214
|
+
call.done = true;
|
|
215
|
+
this.detach(call);
|
|
216
|
+
this.statsAgg.aborted++;
|
|
217
|
+
emitDevtools({ type: 'task:settle', poolId: this.poolId, callId: call.callId, taskId: call.contract.taskId, outcome: err instanceof TaskTimeoutError ? 'timeout' : 'aborted' });
|
|
218
|
+
call.reject(err);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private checkDrained(): void {
|
|
222
|
+
if (this.pending.size > 0) return;
|
|
223
|
+
for (const w of this.drainWaiters.splice(0)) w();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Post the call straight to the worker — no queue, no scheduler. */
|
|
227
|
+
public dispatch<Args extends any[], Result>(
|
|
228
|
+
contract: TaskContract<Args, Result>,
|
|
229
|
+
args: Args,
|
|
230
|
+
options?: RunOptions,
|
|
231
|
+
): Promise<Result> {
|
|
232
|
+
const worker = this.worker;
|
|
233
|
+
if (this.closed || worker === null) {
|
|
234
|
+
return Promise.reject(new WorkerCrashedError('DedicatedWorker has been terminated — no worker available.'));
|
|
235
|
+
}
|
|
236
|
+
if (options?.signal?.aborted) return Promise.reject(new TaskAbortedError());
|
|
237
|
+
return new Promise<Result>((resolve, reject) => {
|
|
238
|
+
const call: Call = {
|
|
239
|
+
contract,
|
|
240
|
+
callId: ++this.nextCallId,
|
|
241
|
+
sentAt: performance.now(),
|
|
242
|
+
resolve,
|
|
243
|
+
reject,
|
|
244
|
+
done: false,
|
|
245
|
+
signal: options?.signal,
|
|
246
|
+
};
|
|
247
|
+
if (options?.signal) {
|
|
248
|
+
call.onSignalAbort = () => this.cancel(call, new TaskAbortedError());
|
|
249
|
+
options.signal.addEventListener('abort', call.onSignalAbort, { once: true });
|
|
250
|
+
}
|
|
251
|
+
const timeout = options?.timeout ?? this.taskTimeout;
|
|
252
|
+
if (timeout !== undefined) call.timer = setTimeout(() => this.cancel(call, new TaskTimeoutError()), timeout);
|
|
253
|
+
|
|
254
|
+
const messageId = this.nextMessageId++;
|
|
255
|
+
this.pending.set(messageId, call);
|
|
256
|
+
taskLog.debug(`→ ${contract.taskId}`, { args });
|
|
257
|
+
emitDevtools({ type: 'task:enqueue', poolId: this.poolId, callId: call.callId, taskId: contract.taskId });
|
|
258
|
+
if (devtoolsEnabled()) {
|
|
259
|
+
emitDevtools({ type: 'task:dispatch', poolId: this.poolId, callId: call.callId, taskId: contract.taskId, slot: 0, waitMs: 0, argBytes: estimateCloneBytes(args) });
|
|
260
|
+
}
|
|
261
|
+
const msg = { type: 'EXECUTE_TASK', messageId, taskId: contract.taskId, args };
|
|
262
|
+
if (this.chaos) {
|
|
263
|
+
applyChaos(this.chaos, {
|
|
264
|
+
live: () => this.pending.get(messageId) === call,
|
|
265
|
+
fail: () => this.handleReply({ messageId, taskId: contract.taskId, success: false, error: CHAOS_FAILURE }),
|
|
266
|
+
timeout: () => this.cancel(call, new TaskTimeoutError()),
|
|
267
|
+
post: () => worker.postMessage(msg),
|
|
268
|
+
});
|
|
269
|
+
} else {
|
|
270
|
+
worker.postMessage(msg);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
public runTask<Args extends any[], Result>(contract: TaskContract<Args, Result>, ...args: Args): Promise<Result> {
|
|
276
|
+
return this.dispatch(contract, args);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The shared buffer handed to the worker via INIT_MEMORY; undefined when message-only. */
|
|
280
|
+
public get sharedBuffer(): SharedArrayBuffer | undefined {
|
|
281
|
+
return (this.mem.memoryManager as MemoryManager | undefined)?.getBuffer() ?? this.mem.injectedBuffer;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The worker, as a one-element snapshot (same shape as `WorkerPool.workers`). */
|
|
285
|
+
public get workers(): readonly Worker[] {
|
|
286
|
+
return this.worker ? [this.worker] : [];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Same shape as `WorkerPool.stats()`; `queued` and `waitMs` stay zero (there is no queue). */
|
|
290
|
+
public stats(): PoolStats {
|
|
291
|
+
const live = this.worker ? 1 : 0;
|
|
292
|
+
const { runMs } = this.statsAgg;
|
|
293
|
+
return {
|
|
294
|
+
workers: live,
|
|
295
|
+
idle: live && this.pending.size === 0 ? 1 : 0,
|
|
296
|
+
inFlight: this.pending.size,
|
|
297
|
+
queued: 0,
|
|
298
|
+
completed: this.statsAgg.completed,
|
|
299
|
+
failed: this.statsAgg.failed,
|
|
300
|
+
aborted: this.statsAgg.aborted,
|
|
301
|
+
waitMs: { count: 0, mean: 0, max: 0 },
|
|
302
|
+
runMs: { count: runMs.count, mean: runMs.mean, max: runMs.max },
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Resolves once nothing is in flight, then terminates. */
|
|
307
|
+
public close(): Promise<void> {
|
|
308
|
+
if (this.pending.size === 0) {
|
|
309
|
+
this.terminate();
|
|
310
|
+
return Promise.resolve();
|
|
311
|
+
}
|
|
312
|
+
return new Promise((resolve) => {
|
|
313
|
+
this.drainWaiters.push(() => {
|
|
314
|
+
this.terminate();
|
|
315
|
+
resolve();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
public terminate(): void {
|
|
321
|
+
if (this.closed) return;
|
|
322
|
+
this.closed = true;
|
|
323
|
+
this.devtoolsOff?.();
|
|
324
|
+
void this.persistence?.stop()?.catch?.((e: unknown) => workerLog.warn('memory persistence stop failed', e));
|
|
325
|
+
workerLog.info('terminating dedicated worker');
|
|
326
|
+
emitDevtools({ type: 'pool:terminate', poolId: this.poolId });
|
|
327
|
+
this.rejectPending('worker terminated');
|
|
328
|
+
this.worker?.terminate();
|
|
329
|
+
this.worker = null;
|
|
330
|
+
this.checkDrained();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard control over the live runners of this thread: `pool.list`,
|
|
3
|
+
* `pool.stats`, `worker.kill`, `pool.chaos`. Runners (`WorkerPool`,
|
|
4
|
+
* `DedicatedWorker`) register here from their constructor only when
|
|
5
|
+
* `devtoolsEnabled()`, and unregister on terminate — with devtools off
|
|
6
|
+
* nothing is registered and the runners' only cost is one `chaos` field
|
|
7
|
+
* check per dispatch.
|
|
8
|
+
*
|
|
9
|
+
* Scope: one registry per thread. Pools spawned inside workers (sub-pools,
|
|
10
|
+
* nested island clients) register in the worker's own registry, which the
|
|
11
|
+
* dashboard's control channel (main thread only) cannot reach.
|
|
12
|
+
*/
|
|
13
|
+
import { registerDevtoolsCommand } from '../devtools';
|
|
14
|
+
import type { PoolStats } from './workerPool';
|
|
15
|
+
|
|
16
|
+
/** Fault injection applied at dispatch. All rates are probabilities in 0..1. */
|
|
17
|
+
export interface ChaosConfig {
|
|
18
|
+
/** Delay before the call is posted to the worker. */
|
|
19
|
+
delayMs?: number;
|
|
20
|
+
/** Share of calls rejected with `CHAOS_FAILURE` (settle outcome 'error'). */
|
|
21
|
+
failRate?: number;
|
|
22
|
+
/** Share of calls failed through the timeout path (settle outcome 'timeout'). */
|
|
23
|
+
timeoutRate?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Error message of a chaos-injected failure. */
|
|
27
|
+
export const CHAOS_FAILURE = 'chaos: injected failure';
|
|
28
|
+
/** Crash message of a dashboard `worker.kill`. */
|
|
29
|
+
export const KILL_MESSAGE = 'killed from devtools';
|
|
30
|
+
|
|
31
|
+
/** What a runner exposes to the dashboard commands. */
|
|
32
|
+
export interface DevtoolsRunnerHandle {
|
|
33
|
+
poolId: string;
|
|
34
|
+
label?: string;
|
|
35
|
+
dedicated: boolean;
|
|
36
|
+
/** Live worker count. */
|
|
37
|
+
size(): number;
|
|
38
|
+
stats(): PoolStats;
|
|
39
|
+
/** Run the runner's real crash handler for that slot (throws on a bad slot). */
|
|
40
|
+
kill(slot: number): void;
|
|
41
|
+
getChaos(): ChaosConfig | null;
|
|
42
|
+
setChaos(chaos: ChaosConfig | null): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const runners = new Map<string, DevtoolsRunnerHandle>();
|
|
46
|
+
let uninstall: (() => void) | null = null;
|
|
47
|
+
|
|
48
|
+
const runnerOf = (args: Record<string, unknown>): DevtoolsRunnerHandle => {
|
|
49
|
+
const id = args.poolId;
|
|
50
|
+
if (typeof id !== 'string') throw new Error('poolId (string) is required');
|
|
51
|
+
const r = runners.get(id);
|
|
52
|
+
if (!r) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
id.includes('~')
|
|
55
|
+
? `runner '${id}' lives inside a worker: nested pools are not controllable from the dashboard`
|
|
56
|
+
: `no live runner '${id}' on this thread`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return r;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const rate = (v: unknown, name: string): number | undefined => {
|
|
63
|
+
if (v === undefined || v === null) return undefined;
|
|
64
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1) {
|
|
65
|
+
throw new Error(`${name} must be a number in 0..1`);
|
|
66
|
+
}
|
|
67
|
+
return v || undefined;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Validate `pool.chaos` args into a config; null when nothing is active (clears). */
|
|
71
|
+
export const parseChaos = (args: Record<string, unknown>): ChaosConfig | null => {
|
|
72
|
+
const { delayMs } = args;
|
|
73
|
+
if (delayMs !== undefined && delayMs !== null && (typeof delayMs !== 'number' || !Number.isFinite(delayMs) || delayMs < 0)) {
|
|
74
|
+
throw new Error('delayMs must be a non-negative number');
|
|
75
|
+
}
|
|
76
|
+
const failRate = rate(args.failRate, 'failRate');
|
|
77
|
+
const timeoutRate = rate(args.timeoutRate, 'timeoutRate');
|
|
78
|
+
if ((failRate ?? 0) + (timeoutRate ?? 0) > 1) throw new Error('failRate + timeoutRate must not exceed 1');
|
|
79
|
+
const chaos: ChaosConfig = {};
|
|
80
|
+
if (delayMs) chaos.delayMs = delayMs as number;
|
|
81
|
+
if (failRate) chaos.failRate = failRate;
|
|
82
|
+
if (timeoutRate) chaos.timeoutRate = timeoutRate;
|
|
83
|
+
return Object.keys(chaos).length ? chaos : null;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const listRunners = () =>
|
|
87
|
+
[...runners.values()].map((r) => ({
|
|
88
|
+
poolId: r.poolId,
|
|
89
|
+
label: r.label ?? r.poolId,
|
|
90
|
+
size: r.size(),
|
|
91
|
+
dedicated: r.dedicated,
|
|
92
|
+
chaos: r.getChaos(),
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
const install = (): (() => void) => {
|
|
96
|
+
const offs = [
|
|
97
|
+
registerDevtoolsCommand('pool.list', () => listRunners()),
|
|
98
|
+
registerDevtoolsCommand('pool.stats', (args) => runnerOf(args).stats()),
|
|
99
|
+
registerDevtoolsCommand('worker.kill', (args) => {
|
|
100
|
+
const r = runnerOf(args);
|
|
101
|
+
const slot = args.slot ?? 0;
|
|
102
|
+
if (typeof slot !== 'number' || !Number.isInteger(slot) || slot < 0) {
|
|
103
|
+
throw new Error('slot must be a non-negative integer');
|
|
104
|
+
}
|
|
105
|
+
r.kill(slot);
|
|
106
|
+
return { poolId: r.poolId, slot, killed: true };
|
|
107
|
+
}),
|
|
108
|
+
registerDevtoolsCommand('pool.chaos', (args) => {
|
|
109
|
+
const r = runnerOf(args);
|
|
110
|
+
r.setChaos(parseChaos(args));
|
|
111
|
+
return r.getChaos();
|
|
112
|
+
}),
|
|
113
|
+
];
|
|
114
|
+
return () => offs.forEach((off) => off());
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Register a live runner; returns its unregister. The pool commands are
|
|
119
|
+
* installed with the first runner and removed with the last, so the
|
|
120
|
+
* dashboard's `devtools.commands` only advertises them while there is
|
|
121
|
+
* something to control.
|
|
122
|
+
*/
|
|
123
|
+
export const registerDevtoolsRunner = (handle: DevtoolsRunnerHandle): (() => void) => {
|
|
124
|
+
runners.set(handle.poolId, handle);
|
|
125
|
+
uninstall ??= install();
|
|
126
|
+
return () => {
|
|
127
|
+
if (runners.get(handle.poolId) !== handle) return;
|
|
128
|
+
runners.delete(handle.poolId);
|
|
129
|
+
if (runners.size === 0 && uninstall) {
|
|
130
|
+
uninstall();
|
|
131
|
+
uninstall = null;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** Hooks a runner supplies to `applyChaos` for one dispatched call. */
|
|
137
|
+
export interface ChaosCall {
|
|
138
|
+
/** Still awaiting its reply (not crashed/terminated away during the delay). */
|
|
139
|
+
live(): boolean;
|
|
140
|
+
/** Settle as a worker-reported failure (the runner's reply path). */
|
|
141
|
+
fail(): void;
|
|
142
|
+
/** Settle through the runner's timeout path. */
|
|
143
|
+
timeout(): void;
|
|
144
|
+
/** Post the call to the worker. */
|
|
145
|
+
post(): void;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Apply an active chaos config to one dispatched call. An injected failure
|
|
150
|
+
* never reaches the worker; an injected timeout settles the caller through
|
|
151
|
+
* the timeout path and still posts the call, exactly like a real timeout
|
|
152
|
+
* (the worker runs it; its late reply frees the slot and is discarded).
|
|
153
|
+
*/
|
|
154
|
+
export const applyChaos = (chaos: ChaosConfig, call: ChaosCall): void => {
|
|
155
|
+
const r = Math.random();
|
|
156
|
+
const fail = r < (chaos.failRate ?? 0);
|
|
157
|
+
const timeout = !fail && r < (chaos.failRate ?? 0) + (chaos.timeoutRate ?? 0);
|
|
158
|
+
if (!chaos.delayMs && !fail && !timeout) return call.post();
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
if (!call.live()) return;
|
|
161
|
+
if (fail) return call.fail();
|
|
162
|
+
if (timeout) call.timeout();
|
|
163
|
+
call.post();
|
|
164
|
+
}, chaos.delayMs ?? 0);
|
|
165
|
+
};
|
|
@@ -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
|
+
}
|