@atolljs/core 0.1.0

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 (44) hide show
  1. package/README.md +110 -0
  2. package/dist/index.js +19 -0
  3. package/dist/index10.js +186 -0
  4. package/dist/index11.js +52 -0
  5. package/dist/index12.js +66 -0
  6. package/dist/index13.js +27 -0
  7. package/dist/index14.js +63 -0
  8. package/dist/index15.js +50 -0
  9. package/dist/index16.js +46 -0
  10. package/dist/index17.js +42 -0
  11. package/dist/index18.js +61 -0
  12. package/dist/index19.js +56 -0
  13. package/dist/index2.js +10 -0
  14. package/dist/index20.js +59 -0
  15. package/dist/index3.js +120 -0
  16. package/dist/index4.js +40 -0
  17. package/dist/index5.js +481 -0
  18. package/dist/index6.js +23 -0
  19. package/dist/index7.js +10 -0
  20. package/dist/index8.js +37 -0
  21. package/dist/index9.js +22 -0
  22. package/package.json +68 -0
  23. package/src/contract/listSchema.ts +211 -0
  24. package/src/contract/msgpackCodec.ts +9 -0
  25. package/src/contract/msgpackrCodec.ts +16 -0
  26. package/src/contract/mz.ts +48 -0
  27. package/src/contract/sharedMemory.ts +909 -0
  28. package/src/contract/types.ts +123 -0
  29. package/src/index.ts +114 -0
  30. package/src/log.ts +69 -0
  31. package/src/observable.ts +151 -0
  32. package/src/pool/errors.ts +31 -0
  33. package/src/pool/memory.ts +68 -0
  34. package/src/pool/workerClient.ts +181 -0
  35. package/src/pool/workerPool.ts +438 -0
  36. package/src/reactive.ts +161 -0
  37. package/src/service.ts +283 -0
  38. package/src/shared/sharedWorkerClient.ts +142 -0
  39. package/src/shared/sharedWorkerHost.ts +104 -0
  40. package/src/task.ts +99 -0
  41. package/src/testing/inProcessWorker.ts +127 -0
  42. package/src/worker/defineWorker.ts +135 -0
  43. package/src/worker/registry.ts +42 -0
  44. package/src/worker/workerBootstrap.ts +56 -0
@@ -0,0 +1,181 @@
1
+ import type { ServiceMethod, TaskRunner } from '../service';
2
+ import type { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory';
3
+ import type { MemoryConfig, Prettify } from '../contract/types';
4
+ import { WorkerPool, type RunOptions } from './workerPool';
5
+ import type {
6
+ WorkerDefinition,
7
+ WorkerMethodMap,
8
+ } from '../worker/defineWorker';
9
+
10
+ /** Maps a worker method's declared type to its async client signature. */
11
+ type Sig<F> = F extends ServiceMethod<infer A extends any[], infer R>
12
+ ? (...args: A) => Promise<R>
13
+ : F extends { run: (...args: infer A extends any[]) => infer R }
14
+ ? (...args: A) => Promise<Awaited<R>>
15
+ : F extends (...args: infer A extends any[]) => infer R
16
+ ? (...args: A) => Promise<Awaited<R>>
17
+ : never;
18
+
19
+ /** Flat method map → client signatures. */
20
+ export type ClientMethods<M extends WorkerMethodMap> = { [K in keyof M]: Sig<M[K]> };
21
+
22
+ /**
23
+ * The typed client surface a {@link WorkerDefinition} exposes: flat methods
24
+ * plus one nested client per service namespace.
25
+ */
26
+ export type WorkerMethods<W extends WorkerDefinition> = ClientMethods<W['methods']> & {
27
+ [K in keyof W['services']]: W['services'][K] extends infer M extends WorkerMethodMap
28
+ ? ClientMethods<M>
29
+ : never;
30
+ } & {
31
+ /** Per-call options — `client.with({ signal, timeout }).method(args)`. */
32
+ with(options: RunOptions): WorkerMethods<W>;
33
+ };
34
+
35
+ const makeClientProxy = (
36
+ path: string[],
37
+ resolveRunner: () => TaskRunner,
38
+ reserved?: Record<string, unknown>,
39
+ options?: RunOptions,
40
+ ): unknown => {
41
+ // Cache nested proxies so `client.method` is referentially stable across
42
+ // accesses — matters for memoized bindings (e.g. useTask's [source] deps).
43
+ const children = new Map<string, unknown>();
44
+ return new Proxy(() => {}, {
45
+ get(_, prop) {
46
+ // `then` and symbols return undefined so the proxy is never mistaken
47
+ // for a thenable (await client, Promise.resolve(client), assimilation).
48
+ if (prop === 'then' || typeof prop === 'symbol') return undefined;
49
+ if (prop === 'with') {
50
+ // client.with({ signal, timeout }) — same surface, calls dispatch
51
+ // with RunOptions. Root-level only makes sense, but works anywhere.
52
+ return (opts: RunOptions) =>
53
+ makeClientProxy(path, resolveRunner, reserved, opts);
54
+ }
55
+ if (path.length === 0 && reserved && prop in reserved) {
56
+ return Reflect.get(reserved, prop);
57
+ }
58
+ let child = children.get(prop);
59
+ if (!child) {
60
+ child = makeClientProxy([...path, prop], resolveRunner, reserved, options);
61
+ children.set(prop, child);
62
+ }
63
+ return child;
64
+ },
65
+ apply(_, __, args: unknown[]) {
66
+ const runner = resolveRunner();
67
+ const contract = { taskId: path.join('.') };
68
+ return runner.dispatch
69
+ ? runner.dispatch(contract, args, options)
70
+ : runner.runTask(contract, ...args);
71
+ },
72
+ });
73
+ };
74
+
75
+ /**
76
+ * Typed Proxy over any {@link TaskRunner} (WorkerPool, SharedWorkerClient,
77
+ * test stub). The path joins with '.', so `client.query(q)` dispatches
78
+ * taskId "query" and `client.pricing.reprice(x)` dispatches
79
+ * "pricing.reprice" — no runtime knowledge of the shape needed.
80
+ */
81
+ export function workerClient<W extends WorkerDefinition>(
82
+ runner: TaskRunner | (() => TaskRunner),
83
+ ): WorkerMethods<W> {
84
+ let resolved: TaskRunner | undefined;
85
+ const resolveRunner =
86
+ typeof runner === 'function'
87
+ ? () => (resolved ??= runner())
88
+ : () => runner;
89
+ return makeClientProxy([], resolveRunner) as WorkerMethods<W>;
90
+ }
91
+
92
+ export interface ConnectWorkerConfig<S extends SharedSpec> {
93
+ /** Omit for a message-only pool — the SharedArrayBuffer check is skipped. */
94
+ sharedMemory?: SharedMemory<S> & Prettify<SharedAccess<S>>;
95
+ /**
96
+ * Bundler-detectable factory `() => new Worker(new URL('./x.worker.ts', import.meta.url), { type: 'module' })`,
97
+ * or a URL.
98
+ */
99
+ worker: (() => Worker) | URL;
100
+ poolSize?: number | 'auto';
101
+ memory?: MemoryConfig;
102
+ /** Max in-flight tasks per worker (default 1); excess calls queue FIFO. */
103
+ concurrency?: number;
104
+ /** Max queued calls (default Infinity); a full queue rejects with PoolQueueFullError. */
105
+ maxQueue?: number;
106
+ /** Default true — a crashed worker is replaced and its in-flight calls reject. */
107
+ respawn?: boolean;
108
+ /** Default per-call timeout ms (`client.with({ timeout })` overrides). */
109
+ taskTimeout?: number;
110
+ /** Default true: pool spawns on first method call (or start()). False spawns immediately. */
111
+ lazy?: boolean;
112
+ }
113
+
114
+ /** A typed worker client plus pool lifecycle controls. */
115
+ export type WorkerClient<W extends WorkerDefinition, S extends SharedSpec> = WorkerMethods<W> & {
116
+ /** Spawn the pool now (idempotent). */
117
+ start(): void;
118
+ /** Terminate the pool; the next method call re-spawns it. */
119
+ terminate(): void;
120
+ readonly pool: WorkerPool<S> | null;
121
+ readonly sharedMemory: ConnectWorkerConfig<S>['sharedMemory'] | undefined;
122
+ };
123
+
124
+ /**
125
+ * One-line worker hookup for the main thread — the WorkerPool boilerplate
126
+ * (worker factory, shared memory, sizing) collapses into the client itself:
127
+ *
128
+ * import type { IncidentsWorker } from './worker/incidents.worker';
129
+ *
130
+ * export const incidents = connectWorker<IncidentsWorker>({
131
+ * sharedMemory: incidentsMemory,
132
+ * worker: () => new Worker(new URL('./worker/incidents.worker.ts', import.meta.url), { type: 'module' }),
133
+ * poolSize: 1,
134
+ * });
135
+ *
136
+ * await incidents.queryIncidents(q); // typed dispatch to the pool
137
+ * incidents.terminate(); // next call lazily re-spawns
138
+ *
139
+ * Lazy by default: no Worker is touched until the first method call (or
140
+ * `start()`), so importing the client is SSR-safe.
141
+ */
142
+ export function connectWorker<
143
+ W extends WorkerDefinition,
144
+ S extends SharedSpec = W extends WorkerDefinition<infer X> ? X : SharedSpec,
145
+ >(config: ConnectWorkerConfig<S>): WorkerClient<W, S> {
146
+ let pool: WorkerPool<S> | null = null;
147
+ const spawn = (): WorkerPool<S> =>
148
+ (pool ??= new WorkerPool<S>({
149
+ sharedMemory: config.sharedMemory,
150
+ createWorker:
151
+ typeof config.worker === 'function'
152
+ ? config.worker
153
+ : () => new Worker(config.worker as URL, { type: 'module' }),
154
+ poolSize: config.poolSize,
155
+ memory: config.memory,
156
+ concurrency: config.concurrency,
157
+ maxQueue: config.maxQueue,
158
+ respawn: config.respawn,
159
+ taskTimeout: config.taskTimeout,
160
+ }));
161
+
162
+ if (config.lazy === false) spawn();
163
+
164
+ const reserved = {
165
+ start: () => {
166
+ spawn();
167
+ },
168
+ terminate: () => {
169
+ pool?.terminate();
170
+ pool = null;
171
+ },
172
+ get pool() {
173
+ return pool;
174
+ },
175
+ get sharedMemory() {
176
+ return config.sharedMemory;
177
+ },
178
+ };
179
+
180
+ return makeClientProxy([], spawn, reserved) as WorkerClient<W, S>;
181
+ }
@@ -0,0 +1,438 @@
1
+ import { MemoryManager } from './memory';
2
+ import { PoolQueueFullError, TaskAbortedError, TaskTimeoutError, WorkerCrashedError } from './errors';
3
+ import { SharedAccess, SharedMemory, SharedSpec } from '../contract/sharedMemory';
4
+ import { PoolTasks, TaskContract, TaskMap, TaskResult, WorkerPoolConfig } from '../contract/types';
5
+ import { fmtBytes, scoped } from '../log';
6
+
7
+ const poolLog = scoped('pool');
8
+ const taskLog = scoped('task');
9
+
10
+ /** Per-call dispatch controls. */
11
+ export interface RunOptions {
12
+ /**
13
+ * Cancels the call: a queued task is removed and rejects with
14
+ * TaskAbortedError; an in-flight task rejects immediately and the worker's
15
+ * eventual reply is discarded. JS cannot be interrupted — the worker keeps
16
+ * running, and the worker's slot is released only when its reply arrives.
17
+ * Callers needing a hard cancel should `terminate()`.
18
+ */
19
+ signal?: AbortSignal;
20
+ /**
21
+ * Per-call timeout in ms — overrides the pool's `taskTimeout`. The timer
22
+ * starts at enqueue, so it bounds queue-wait + execution time together.
23
+ */
24
+ timeout?: number;
25
+ }
26
+
27
+ interface RunningAgg {
28
+ count: number;
29
+ mean: number;
30
+ max: number;
31
+ add(v: number): void;
32
+ }
33
+ const agg = (): RunningAgg => ({
34
+ count: 0,
35
+ mean: 0,
36
+ max: 0,
37
+ add(v: number) {
38
+ this.count++;
39
+ this.mean += (v - this.mean) / this.count;
40
+ if (v > this.max) this.max = v;
41
+ },
42
+ });
43
+
44
+ export interface PoolStats {
45
+ workers: number;
46
+ idle: number;
47
+ inFlight: number;
48
+ queued: number;
49
+ completed: number;
50
+ failed: number;
51
+ aborted: number;
52
+ /** queue→dispatch timings. */
53
+ waitMs: { count: number; mean: number; max: number };
54
+ /** dispatch→reply timings. */
55
+ runMs: { count: number; mean: number; max: number };
56
+ }
57
+
58
+ /** One dispatched call — first queued, then in-flight on a worker slot. */
59
+ interface TaskEntry {
60
+ contract: TaskContract<any[], any>;
61
+ args: any[];
62
+ enqueuedAt: number;
63
+ resolve: (v: any) => void;
64
+ reject: (e: unknown) => void;
65
+ /** Finalized — settled (or reply already discarded); no further transitions. */
66
+ done: boolean;
67
+ /** Set once handed to a worker. */
68
+ messageId?: number;
69
+ slot?: WorkerSlot;
70
+ sentAt?: number;
71
+ timer?: ReturnType<typeof setTimeout>;
72
+ signal?: AbortSignal;
73
+ onSignalAbort?: () => void;
74
+ }
75
+
76
+ interface WorkerSlot {
77
+ worker: Worker;
78
+ inFlight: number;
79
+ }
80
+
81
+ class WorkerPoolImpl<S extends SharedSpec = SharedSpec> {
82
+ private slots: WorkerSlot[] = [];
83
+ private pending = new Map<number, TaskEntry>();
84
+ private nextMessageId = 0;
85
+ private queue: TaskEntry[] = [];
86
+ private memoryManager?: MemoryManager;
87
+ private workerUrl?: URL;
88
+ private spawn: () => Worker;
89
+ private readonly concurrency: number;
90
+ private readonly maxQueue: number;
91
+ private readonly respawn: boolean;
92
+ private readonly taskTimeout?: number;
93
+ private closed = false;
94
+ private drainWaiters: Array<() => void> = [];
95
+ private statsAgg = {
96
+ completed: 0,
97
+ failed: 0,
98
+ /** aborts + timeouts. */
99
+ aborted: 0,
100
+ waitMs: agg(),
101
+ runMs: agg(),
102
+ };
103
+ public readonly sharedMemory: (SharedMemory<S> & SharedAccess<S>) | undefined;
104
+
105
+ constructor(config: WorkerPoolConfig<S, TaskMap>) {
106
+ if (config.sharedMemory) {
107
+ if (typeof SharedArrayBuffer === 'undefined' || (typeof crossOriginIsolated !== 'undefined' && !crossOriginIsolated)) {
108
+ throw new Error(
109
+ 'WorkerPool requires SharedArrayBuffer in a cross-origin isolated context — serve the page with COOP/COEP headers.'
110
+ );
111
+ }
112
+ }
113
+ if (!config.workerUrl && !config.createWorker) {
114
+ throw new Error('WorkerPool requires either `workerUrl` or `createWorker` in its config.');
115
+ }
116
+ this.workerUrl = config.workerUrl;
117
+ this.spawn = config.createWorker ?? (() => new Worker(this.workerUrl!, { type: 'module' }));
118
+ this.concurrency = config.concurrency ?? 1;
119
+ this.maxQueue = config.maxQueue ?? Infinity;
120
+ this.respawn = config.respawn ?? true;
121
+ this.taskTimeout = config.taskTimeout;
122
+
123
+ if (config.sharedMemory) {
124
+ this.sharedMemory = config.sharedMemory;
125
+ this.memoryManager = new MemoryManager(config.memory);
126
+ this.memoryManager.ensureCapacity(this.sharedMemory.totalBytes);
127
+ this.sharedMemory.bind(this.memoryManager.getBuffer());
128
+ }
129
+
130
+ const requestedSize = config.poolSize ?? 'auto';
131
+ const poolSize =
132
+ requestedSize === 'auto' ? (navigator.hardwareConcurrency ?? 4) : requestedSize;
133
+
134
+ poolLog.info(
135
+ this.sharedMemory
136
+ ? `spawning ${poolSize} worker(s) with ${fmtBytes(this.sharedMemory.totalBytes)} shared buffer`
137
+ : `spawning ${poolSize} worker(s), message-only`,
138
+ { workerUrl: config.workerUrl ? String(config.workerUrl) : 'createWorker()' },
139
+ );
140
+ poolLog.debug(`tip: setLogLevel('trace') for per-task detail`);
141
+
142
+ for (let i = 0; i < poolSize; i++) {
143
+ this.addWorker(this.spawn());
144
+ }
145
+
146
+ // Install first-class task methods: pool.queryIncidents(q) →
147
+ // runTask(QueryIncidents, q). Typed through PoolTasks<T> at the
148
+ // WorkerPool constructor signature.
149
+ const tasks = config.tasks ?? {};
150
+ for (const [name, contract] of Object.entries(tasks)) {
151
+ if (name in this) {
152
+ poolLog.warn(`task "${name}" collides with a WorkerPool member — not installed`);
153
+ continue;
154
+ }
155
+ (this as Record<string, unknown>)[name] = (...args: unknown[]) =>
156
+ this.runTask(contract as TaskContract<any[], any>, ...(args as any[]));
157
+ }
158
+ if (Object.keys(tasks).length > 0) {
159
+ poolLog.info(`task methods: ${Object.keys(tasks).join(', ')}`);
160
+ }
161
+ }
162
+
163
+ /** Adds a spawned worker: one message + one error listener, then the INIT handshake. */
164
+ private addWorker(worker: Worker): WorkerSlot {
165
+ const slot: WorkerSlot = { worker, inFlight: 0 };
166
+ worker.addEventListener('message', (event: MessageEvent) => {
167
+ this.handleReply(slot, event.data as TaskResult & { messageId?: number });
168
+ });
169
+ worker.addEventListener('error', (event: unknown) => {
170
+ this.handleWorkerError(slot, event);
171
+ });
172
+ if (this.memoryManager) {
173
+ worker.postMessage({ type: 'INIT_MEMORY', memory: this.memoryManager.memory });
174
+ } else {
175
+ worker.postMessage({ type: 'INIT' });
176
+ }
177
+ this.slots.push(slot);
178
+ poolLog.debug(`worker spawned, ${this.memoryManager ? 'INIT_MEMORY' : 'INIT'} sent`);
179
+ return slot;
180
+ }
181
+
182
+ private handleReply(slot: WorkerSlot, data: TaskResult & { messageId?: number }) {
183
+ const entry = data.messageId !== undefined ? this.pending.get(data.messageId) : undefined;
184
+ if (!entry) return; // stray reply — discard
185
+ this.pending.delete(data.messageId!);
186
+ slot.inFlight--;
187
+ this.detach(entry);
188
+ // Orphaned (aborted/timed-out) entries free the slot here but skip
189
+ // stats and settlement — the caller was already rejected.
190
+ if (!entry.done) {
191
+ entry.done = true;
192
+ this.statsAgg.runMs.add(performance.now() - (entry.sentAt ?? entry.enqueuedAt));
193
+ if (data.success) {
194
+ this.statsAgg.completed++;
195
+ taskLog.debug(`← ${entry.contract.taskId} ok`);
196
+ entry.resolve(data.result);
197
+ } else {
198
+ this.statsAgg.failed++;
199
+ taskLog.warn(`← ${entry.contract.taskId} failed: ${data.error}`);
200
+ entry.reject(new Error(data.error));
201
+ }
202
+ }
203
+ this.drainQueue();
204
+ this.checkDrained();
205
+ }
206
+
207
+ private handleWorkerError(slot: WorkerSlot, event: unknown) {
208
+ if (this.closed) return; // pool terminated — a late error event can't respawn
209
+ const e = event as { message?: string; error?: Error } | undefined;
210
+ const message = e?.message ?? (e?.error ? String(e.error) : 'worker error');
211
+ poolLog.warn(`worker error: ${message}`);
212
+
213
+ // Reject the dead worker's in-flight tasks. Orphaned (already
214
+ // aborted/timed-out) entries are dropped without re-counting.
215
+ for (const [id, entry] of [...this.pending]) {
216
+ if (entry.slot === slot) {
217
+ this.pending.delete(id);
218
+ this.detach(entry);
219
+ if (entry.done) continue;
220
+ entry.done = true;
221
+ this.statsAgg.failed++;
222
+ entry.reject(new WorkerCrashedError(message));
223
+ }
224
+ }
225
+
226
+ const idx = this.slots.indexOf(slot);
227
+ if (idx >= 0) this.slots.splice(idx, 1);
228
+ slot.worker.terminate();
229
+
230
+ if (this.respawn) {
231
+ poolLog.info('respawning crashed worker');
232
+ this.addWorker(this.spawn());
233
+ } else if (this.slots.length === 0) {
234
+ // No workers left to ever serve the queue.
235
+ for (const entry of this.queue.splice(0)) {
236
+ entry.done = true;
237
+ this.detach(entry);
238
+ this.statsAgg.failed++;
239
+ entry.reject(new WorkerCrashedError(message));
240
+ }
241
+ }
242
+ this.drainQueue();
243
+ this.checkDrained();
244
+ }
245
+
246
+ /** Least-busy worker below its concurrency cap, if any. */
247
+ private pickWorker(): WorkerSlot | undefined {
248
+ let best: WorkerSlot | undefined;
249
+ for (const slot of this.slots) {
250
+ if (slot.inFlight >= this.concurrency) continue;
251
+ if (!best || slot.inFlight < best.inFlight) best = slot;
252
+ }
253
+ return best;
254
+ }
255
+
256
+ private drainQueue() {
257
+ let slot: WorkerSlot | undefined;
258
+ while (this.queue.length > 0 && (slot = this.pickWorker())) {
259
+ const entry = this.queue.shift()!;
260
+ if (entry.done) continue;
261
+ this.send(slot, entry);
262
+ }
263
+ }
264
+
265
+ private send(slot: WorkerSlot, entry: TaskEntry) {
266
+ const messageId = this.nextMessageId++;
267
+ entry.messageId = messageId;
268
+ entry.slot = slot;
269
+ entry.sentAt = performance.now();
270
+ this.statsAgg.waitMs.add(performance.now() - entry.enqueuedAt);
271
+ slot.inFlight++;
272
+ this.pending.set(messageId, entry);
273
+ taskLog.debug(`→ ${entry.contract.taskId}`, { worker: this.slots.indexOf(slot), args: entry.args });
274
+ slot.worker.postMessage({
275
+ type: 'EXECUTE_TASK',
276
+ messageId,
277
+ taskId: entry.contract.taskId,
278
+ args: entry.args,
279
+ });
280
+ }
281
+
282
+ /** Detaches signal/timeout listeners from a finalized entry. */
283
+ private detach(entry: TaskEntry) {
284
+ if (entry.timer) clearTimeout(entry.timer);
285
+ if (entry.signal && entry.onSignalAbort) {
286
+ entry.signal.removeEventListener('abort', entry.onSignalAbort);
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Abort/timeout path: a queued task is removed outright; an in-flight task
292
+ * stays in `pending` as an orphan — the worker is still running it, so the
293
+ * slot remains occupied until the reply arrives (handleReply frees it).
294
+ */
295
+ private cancel(entry: TaskEntry, err: Error) {
296
+ if (entry.done) return;
297
+ const qi = this.queue.indexOf(entry);
298
+ if (qi >= 0) this.queue.splice(qi, 1);
299
+ entry.done = true;
300
+ this.detach(entry);
301
+ this.statsAgg.aborted++;
302
+ entry.reject(err);
303
+ this.drainQueue();
304
+ this.checkDrained();
305
+ }
306
+
307
+ private checkDrained() {
308
+ if (this.queue.length === 0 && this.pending.size === 0) {
309
+ const waiters = this.drainWaiters.splice(0);
310
+ for (const w of waiters) w();
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Full dispatch: least-busy scheduling, `concurrency` cap per worker,
316
+ * FIFO queue bounded by `maxQueue`, abort/timeout, and queue→reply timing.
317
+ */
318
+ public dispatch<Args extends any[], Result>(
319
+ contract: TaskContract<Args, Result>,
320
+ args: Args,
321
+ options?: RunOptions,
322
+ ): Promise<Result> {
323
+ if (this.closed || this.slots.length === 0) {
324
+ return Promise.reject(new WorkerCrashedError('WorkerPool has been terminated — no workers available.'));
325
+ }
326
+ if (options?.signal?.aborted) {
327
+ return Promise.reject(new TaskAbortedError());
328
+ }
329
+ return new Promise<Result>((resolve, reject) => {
330
+ const entry: TaskEntry = {
331
+ contract,
332
+ args: args as any[],
333
+ enqueuedAt: performance.now(),
334
+ resolve,
335
+ reject,
336
+ done: false,
337
+ signal: options?.signal,
338
+ };
339
+ if (options?.signal) {
340
+ entry.onSignalAbort = () => this.cancel(entry, new TaskAbortedError());
341
+ options.signal.addEventListener('abort', entry.onSignalAbort, { once: true });
342
+ }
343
+ const timeout = options?.timeout ?? this.taskTimeout;
344
+ if (timeout !== undefined) {
345
+ entry.timer = setTimeout(() => this.cancel(entry, new TaskTimeoutError()), timeout);
346
+ }
347
+
348
+ const slot = this.pickWorker();
349
+ if (slot) {
350
+ this.send(slot, entry);
351
+ } else if (this.queue.length >= this.maxQueue) {
352
+ entry.done = true;
353
+ this.detach(entry);
354
+ reject(new PoolQueueFullError()); // never dispatched — stats untouched
355
+ return;
356
+ } else {
357
+ this.queue.push(entry);
358
+ }
359
+ });
360
+ }
361
+
362
+ /**
363
+ * Dispatches a task to the least-busy worker in the pool.
364
+ * The task contract guarantees the argument and result types across the thread boundary.
365
+ */
366
+ public runTask<Args extends any[], Result>(contract: TaskContract<Args, Result>, ...args: Args): Promise<Result> {
367
+ return this.dispatch(contract, args);
368
+ }
369
+
370
+ /** Running aggregates — no histograms, just counts and means. */
371
+ public stats(): PoolStats {
372
+ return {
373
+ workers: this.slots.length,
374
+ idle: this.slots.filter((s) => s.inFlight === 0).length,
375
+ inFlight: this.pending.size,
376
+ queued: this.queue.length,
377
+ completed: this.statsAgg.completed,
378
+ failed: this.statsAgg.failed,
379
+ aborted: this.statsAgg.aborted,
380
+ waitMs: { count: this.statsAgg.waitMs.count, mean: this.statsAgg.waitMs.mean, max: this.statsAgg.waitMs.max },
381
+ runMs: { count: this.statsAgg.runMs.count, mean: this.statsAgg.runMs.mean, max: this.statsAgg.runMs.max },
382
+ };
383
+ }
384
+
385
+ /** Resolves once the queue is empty and nothing is in flight, then terminates. */
386
+ public close(): Promise<void> {
387
+ if (this.queue.length === 0 && this.pending.size === 0) {
388
+ this.terminate();
389
+ return Promise.resolve();
390
+ }
391
+ return new Promise((resolve) => {
392
+ this.drainWaiters.push(() => {
393
+ this.terminate();
394
+ resolve();
395
+ });
396
+ });
397
+ }
398
+
399
+ public terminate(): void {
400
+ this.closed = true;
401
+ poolLog.info(`terminating ${this.slots.length} worker(s)`);
402
+ const crashed = new WorkerCrashedError('pool terminated');
403
+ for (const [, entry] of this.pending) {
404
+ this.detach(entry);
405
+ if (entry.done) continue; // orphaned — already counted as aborted
406
+ entry.done = true;
407
+ this.statsAgg.failed++;
408
+ entry.reject(crashed);
409
+ }
410
+ this.pending.clear();
411
+ for (const entry of this.queue.splice(0)) {
412
+ entry.done = true;
413
+ this.detach(entry);
414
+ this.statsAgg.failed++;
415
+ entry.reject(crashed);
416
+ }
417
+ for (const slot of this.slots) {
418
+ slot.worker.terminate();
419
+ }
420
+ this.slots = [];
421
+ this.checkDrained();
422
+ }
423
+ }
424
+
425
+ /**
426
+ * A WorkerPool with first-class task methods baked in from `config.tasks`.
427
+ * Constructed as `new WorkerPool({ workerUrl, sharedMemory, tasks })` —
428
+ * `tasks: { queryIncidents: QueryIncidents }` exposes `pool.queryIncidents(q)`
429
+ * with the contract's declared args/result types.
430
+ */
431
+ export type WorkerPool<S extends SharedSpec = SharedSpec, T extends TaskMap = TaskMap> =
432
+ WorkerPoolImpl<S> & PoolTasks<T>;
433
+
434
+ export const WorkerPool = WorkerPoolImpl as {
435
+ new <S extends SharedSpec = SharedSpec, T extends TaskMap = TaskMap>(
436
+ config: WorkerPoolConfig<S, T>
437
+ ): WorkerPool<S, T>;
438
+ };