@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.
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 +6 -2
  4. package/dist/contract/types.d.ts +13 -0
  5. package/dist/devtools.d.ts +346 -0
  6. package/dist/index.d.ts +8 -2
  7. package/dist/index.js +19 -16
  8. package/dist/index10.js +28 -184
  9. package/dist/index11.js +18 -48
  10. package/dist/index12.js +29 -63
  11. package/dist/index13.js +49 -24
  12. package/dist/index14.js +57 -59
  13. package/dist/index15.js +335 -45
  14. package/dist/index16.js +242 -47
  15. package/dist/index17.js +57 -38
  16. package/dist/index18.js +9 -86
  17. package/dist/index19.js +58 -48
  18. package/dist/index20.js +24 -56
  19. package/dist/index21.js +61 -0
  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 +227 -35
  28. package/dist/index6.js +143 -471
  29. package/dist/index7.js +51 -20
  30. package/dist/index8.js +507 -27
  31. package/dist/index9.js +19 -18
  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 +38 -8
  37. package/dist/pool/workerPool.d.ts +13 -0
  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 +2 -1
  42. package/src/contract/memoryDevtools.ts +249 -0
  43. package/src/contract/sharedMemory.ts +39 -9
  44. package/src/contract/types.ts +13 -0
  45. package/src/devtools.ts +735 -0
  46. package/src/index.ts +27 -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 +64 -15
  54. package/src/pool/workerPool.ts +115 -36
  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 +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
+ }
@@ -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
- /** Max in-flight tasks per worker (default 1); excess calls queue FIFO. */
120
+ /** Pool only: max in-flight tasks per worker (default 1); excess calls queue FIFO. */
103
121
  concurrency?: number;
104
- /** Max queued calls (default Infinity); a full queue rejects with PoolQueueFullError. */
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
- /** A typed worker client plus pool lifecycle controls. */
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 pool now (idempotent). */
145
+ /** Spawn the worker(s) now (idempotent). */
117
146
  start(): void;
118
- /** Terminate the pool; the next method call re-spawns it. */
147
+ /** Terminate the worker(s); the next method call re-spawns. */
119
148
  terminate(): void;
120
- readonly pool: WorkerPool<S> | null;
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
- * poolSize: 1,
167
+ * workers: 3, // omit for one dedicated worker
134
168
  * });
135
169
  *
136
- * await incidents.queryIncidents(q); // typed dispatch to the pool
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: WorkerPool<S> | null = null;
147
- const spawn = (): WorkerPool<S> =>
148
- (pool ??= new WorkerPool<S>({
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
- poolSize: config.poolSize,
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
 
@@ -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
- if (config.sharedMemory) {
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
- if (config.sharedMemory) {
126
- this.sharedMemory = config.sharedMemory;
127
- this.memoryManager = new MemoryManager(config.memory);
128
- this.memoryManager.ensureCapacity(this.sharedMemory.totalBytes);
129
- this.sharedMemory.bind(this.memoryManager.getBuffer());
130
- this.persistence = config.persistence?.(config.sharedMemory);
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' ? (navigator.hardwareConcurrency ?? 4) : requestedSize;
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
- : `spawning ${poolSize} worker(s), message-only`,
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
- this.handleReply(slot, event.data as TaskResult & { messageId?: number });
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
- if (this.memoryManager) {
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, ${this.memoryManager ? 'INIT_MEMORY' : 'INIT'} sent`);
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
- this.statsAgg.runMs.add(performance.now() - (entry.sentAt ?? entry.enqueuedAt));
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
- this.statsAgg.waitMs.add(performance.now() - entry.enqueuedAt);
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
- taskLog.debug(`→ ${entry.contract.taskId}`, { worker: this.slots.indexOf(slot), args: entry.args });
277
- slot.worker.postMessage({
278
- type: 'EXECUTE_TASK',
279
- messageId,
280
- taskId: entry.contract.taskId,
281
- args: entry.args,
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) {