@atolljs/core 0.1.7 → 0.1.9

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.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/dist/contract/memoryDevtools.d.ts +30 -0
  3. package/dist/contract/sharedMemory.d.ts +4 -2
  4. package/dist/contract/types.d.ts +11 -0
  5. package/dist/devtools.d.ts +127 -4
  6. package/dist/index.d.ts +8 -4
  7. package/dist/index.js +19 -17
  8. package/dist/index10.js +29 -14
  9. package/dist/index11.js +14 -307
  10. package/dist/index12.js +29 -50
  11. package/dist/index13.js +46 -60
  12. package/dist/index14.js +56 -22
  13. package/dist/index15.js +332 -55
  14. package/dist/index16.js +246 -45
  15. package/dist/index17.js +56 -51
  16. package/dist/index18.js +9 -39
  17. package/dist/index19.js +56 -80
  18. package/dist/index20.js +24 -53
  19. package/dist/index21.js +56 -52
  20. package/dist/index22.js +48 -0
  21. package/dist/index23.js +68 -0
  22. package/dist/index24.js +41 -0
  23. package/dist/index25.js +46 -0
  24. package/dist/index26.js +113 -0
  25. package/dist/index27.js +60 -0
  26. package/dist/index28.js +57 -0
  27. package/dist/index5.js +135 -64
  28. package/dist/index6.js +160 -35
  29. package/dist/index7.js +45 -503
  30. package/dist/index8.js +513 -19
  31. package/dist/index9.js +19 -33
  32. package/dist/pool/dedicatedWorker.d.ts +63 -0
  33. package/dist/pool/devtoolsCommands.d.ts +54 -0
  34. package/dist/pool/subWorkerClient.d.ts +50 -0
  35. package/dist/pool/workerChannel.d.ts +44 -0
  36. package/dist/pool/workerClient.d.ts +36 -8
  37. package/dist/pool/workerPool.d.ts +10 -1
  38. package/dist/reactive.d.ts +15 -0
  39. package/dist/reactiveGraph.d.ts +9 -0
  40. package/dist/userTiming.d.ts +27 -0
  41. package/package.json +4 -3
  42. package/src/contract/memoryDevtools.ts +249 -0
  43. package/src/contract/sharedMemory.ts +20 -6
  44. package/src/contract/types.ts +11 -0
  45. package/src/devtools.ts +251 -6
  46. package/src/index.ts +13 -1
  47. package/src/log.ts +22 -0
  48. package/src/observable.ts +16 -3
  49. package/src/pool/dedicatedWorker.ts +332 -0
  50. package/src/pool/devtoolsCommands.ts +165 -0
  51. package/src/pool/subWorkerClient.ts +70 -0
  52. package/src/pool/workerChannel.ts +99 -0
  53. package/src/pool/workerClient.ts +61 -15
  54. package/src/pool/workerPool.ts +75 -45
  55. package/src/reactive.ts +87 -12
  56. package/src/reactiveGraph.ts +91 -0
  57. package/src/testing/inProcessWorker.ts +4 -3
  58. package/src/userTiming.ts +71 -0
  59. package/src/worker/workerBootstrap.ts +33 -5
@@ -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
+ }