nestworker 2.1.6 → 2.1.7

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.
@@ -3,16 +3,27 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.WorkerPool = void 0;
6
+ exports.WorkerPool = exports.QueueFullError = void 0;
7
7
  const node_worker_threads_1 = require("node:worker_threads");
8
8
  const node_events_1 = require("node:events");
9
9
  const node_os_1 = __importDefault(require("node:os"));
10
10
  const node_path_1 = __importDefault(require("node:path"));
11
- const PRIORITY_WEIGHT = {
12
- HIGH: 3,
13
- NORMAL: 2,
14
- LOW: 1,
15
- };
11
+ /** Cross-version AbortError factory (DOMException only since Node 17). */
12
+ function makeAbortError(message) {
13
+ if (typeof DOMException === 'function') {
14
+ return new DOMException(message, 'AbortError');
15
+ }
16
+ const err = new Error(message);
17
+ err.name = 'AbortError';
18
+ return err;
19
+ }
20
+ class QueueFullError extends Error {
21
+ constructor(depth) {
22
+ super(`WorkerPool queue is full (maxQueueDepth=${depth})`);
23
+ this.name = 'QueueFullError';
24
+ }
25
+ }
26
+ exports.QueueFullError = QueueFullError;
16
27
  // Per-worker state attached via a symbol-keyed slot — avoids a Map lookup
17
28
  // per message and per dispatch on the hot path.
18
29
  const STATE = Symbol('nestworker:state');
@@ -32,10 +43,17 @@ class WorkerPool extends node_events_1.EventEmitter {
32
43
  */
33
44
  idle = [];
34
45
  warmingUp = new Set();
35
- // Head-index FIFO: queue.shift() is O(n); a head pointer makes pop O(1)
36
- // and the array is compacted lazily when it grows wasteful.
37
- queue = [];
38
- queueHead = 0;
46
+ // Per-priority FIFO buckets. Replaces the single sorted queue: enqueue is
47
+ // now O(1) push (no binary search + splice), dequeue is O(1) pop from the
48
+ // highest non-empty bucket. Long bursts of mixed-priority jobs no longer
49
+ // pay an O(n) shift cost.
50
+ queueHigh = [];
51
+ queueNormal = [];
52
+ queueLow = [];
53
+ queueHighHead = 0;
54
+ queueNormalHead = 0;
55
+ queueLowHead = 0;
56
+ queuedCount = 0;
39
57
  destroyed = false;
40
58
  activeCount = 0;
41
59
  /**
@@ -47,16 +65,17 @@ class WorkerPool extends node_events_1.EventEmitter {
47
65
  * coalesced into a single postMessage per worker.
48
66
  */
49
67
  scheduleQueued = false;
50
- /** Maps abortSignalId → worker currently running that job */
51
- signalWorkerMap = new Map();
52
68
  concurrency;
69
+ maxQueueDepth;
53
70
  proxyMap = new Map();
54
- constructor(services, proxyInstances, size = node_os_1.default.cpus().length, shutdownTimeout = 30_000, concurrency = 1) {
71
+ constructor(services, proxyInstances, size = node_os_1.default.cpus().length, shutdownTimeout = 30_000, concurrency = 1, maxQueueDepth = Number.POSITIVE_INFINITY) {
55
72
  super();
56
73
  this.services = services;
57
74
  this.size = size;
58
75
  this.shutdownTimeout = shutdownTimeout;
59
76
  this.concurrency = concurrency > 0 ? concurrency : 1;
77
+ this.maxQueueDepth =
78
+ maxQueueDepth > 0 ? maxQueueDepth : Number.POSITIVE_INFINITY;
60
79
  for (const { propertyKey, instance } of proxyInstances) {
61
80
  this.proxyMap.set(propertyKey, instance);
62
81
  }
@@ -70,7 +89,13 @@ class WorkerPool extends node_events_1.EventEmitter {
70
89
  return new Promise((resolve, reject) => {
71
90
  // Reject immediately if already aborted
72
91
  if (signal?.aborted) {
73
- reject(new DOMException('Task aborted before enqueue', 'AbortError'));
92
+ reject(makeAbortError('Task aborted before enqueue'));
93
+ return;
94
+ }
95
+ // Backpressure: enforce queue depth before allocating anything.
96
+ const depth = this.queuedCount;
97
+ if (depth >= this.maxQueueDepth) {
98
+ reject(new QueueFullError(this.maxQueueDepth));
74
99
  return;
75
100
  }
76
101
  const task = {
@@ -85,60 +110,85 @@ class WorkerPool extends node_events_1.EventEmitter {
85
110
  signal,
86
111
  };
87
112
  if (signal) {
88
- signal.addEventListener('abort', () => {
89
- // Remove from queue if not yet dispatched. Use head-index aware
90
- // search and tombstone with `undefined` to avoid an O(n) splice.
91
- const q = this.queue;
92
- for (let i = this.queueHead; i < q.length; i++) {
93
- if (q[i] === task) {
94
- q[i] = undefined;
95
- // If it's at the head, advance past it.
96
- while (this.queueHead < q.length && q[this.queueHead] === undefined) {
97
- this.queueHead++;
113
+ const handler = () => {
114
+ // Tombstone the task in its bucket if it's still queued. We don't
115
+ // know which bucket it lives in without checking, so try each;
116
+ // each scan walks ONLY the live region (head..length) of its
117
+ // bucket. In the worst case the task isn't queued at all (already
118
+ // dispatched), so we fall through to the running-task path.
119
+ const buckets = [
120
+ [this.queueHigh, this.queueHighHead],
121
+ [this.queueNormal, this.queueNormalHead],
122
+ [this.queueLow, this.queueLowHead],
123
+ ];
124
+ for (const [bucket, head] of buckets) {
125
+ for (let i = head; i < bucket.length; i++) {
126
+ if (bucket[i] === task) {
127
+ bucket[i] = undefined;
128
+ this.queuedCount--;
129
+ this.detachAbort(task);
130
+ reject(makeAbortError('Task aborted'));
131
+ return;
98
132
  }
99
- reject(new DOMException('Task aborted', 'AbortError'));
100
- return;
101
133
  }
102
134
  }
103
- // Already running — send abort signal to worker
104
- if (job.abortSignalId !== undefined) {
105
- const worker = this.signalWorkerMap.get(job.abortSignalId);
106
- if (worker) {
107
- try {
108
- worker.postMessage({ type: 'abort', abortSignalId: job.abortSignalId });
109
- }
110
- catch {
111
- /* worker gone */
112
- }
135
+ // Already running — forward abort to its worker.
136
+ const worker = task.worker;
137
+ if (worker && job.abortSignalId !== undefined) {
138
+ try {
139
+ worker.postMessage({
140
+ type: 'abort',
141
+ abortSignalId: job.abortSignalId,
142
+ });
143
+ }
144
+ catch {
145
+ /* worker gone */
113
146
  }
114
147
  }
115
- }, { once: true });
148
+ };
149
+ task.abortHandler = handler;
150
+ signal.addEventListener('abort', handler, { once: true });
116
151
  }
117
152
  this.enqueue(task);
118
153
  this.schedule();
119
154
  });
120
155
  }
156
+ /** Remove the abort listener installed in `execute()` (idempotent). */
157
+ detachAbort(task) {
158
+ if (task.abortHandler && task.signal) {
159
+ try {
160
+ task.signal.removeEventListener('abort', task.abortHandler);
161
+ }
162
+ catch {
163
+ /* ignore */
164
+ }
165
+ task.abortHandler = undefined;
166
+ }
167
+ }
121
168
  stats() {
169
+ const queued = this.queuedCount;
170
+ const cap = this.maxQueueDepth;
122
171
  return {
123
172
  poolSize: this.size,
124
173
  idle: this.idle.length,
125
174
  busy: this.activeCount,
126
- queued: this.queue.length - this.queueHead,
175
+ queued,
127
176
  warmingUp: this.warmingUp.size,
177
+ saturation: Number.isFinite(cap) ? queued / cap : 0,
178
+ maxQueueDepth: cap,
128
179
  };
129
180
  }
130
181
  spawnWorker() {
131
182
  const worker = new node_worker_threads_1.Worker(node_path_1.default.resolve(__dirname, '../worker/worker-runtime.js'), {
132
183
  workerData: { services: this.services },
133
184
  });
134
- worker[STATE] = { active: new Map() };
185
+ worker[STATE] = {
186
+ active: new Map(),
187
+ replaced: false,
188
+ };
135
189
  this.workers.push(worker);
136
190
  this.warmingUp.add(worker);
137
191
  // ── Single persistent message listener ───────────────────────────────
138
- // We used to add/remove a listener per dispatch — that allocated several
139
- // closures and mutated the EventEmitter's listener array on every task.
140
- // Instead we install one listener whose behaviour switches based on
141
- // whether this worker is currently warming up or running a task.
142
192
  const onMessage = (msg) => {
143
193
  const message = msg;
144
194
  if (this.warmingUp.has(worker)) {
@@ -146,8 +196,6 @@ class WorkerPool extends node_events_1.EventEmitter {
146
196
  return;
147
197
  this.warmingUp.delete(worker);
148
198
  if (!this.destroyed) {
149
- // Push the worker into the idle queue once per concurrency slot
150
- // so the scheduler will pipeline up to N jobs into it.
151
199
  for (let i = 0; i < this.concurrency; i++)
152
200
  this.idle.push(worker);
153
201
  this.schedule();
@@ -157,7 +205,6 @@ class WorkerPool extends node_events_1.EventEmitter {
157
205
  // Spurious `worker:ready` after warmup → ignore.
158
206
  if (message?.type === 'worker:ready')
159
207
  return;
160
- // IPC invoke from worker is not job-scoped — handle inline.
161
208
  if (message?.type === 'ipc:invoke') {
162
209
  this.handleIpcInvoke(worker, message);
163
210
  return;
@@ -169,7 +216,9 @@ class WorkerPool extends node_events_1.EventEmitter {
169
216
  const results = batch.results;
170
217
  for (let i = 0; i < results.length; i++) {
171
218
  const r = results[i];
172
- const slot = r.jobId !== undefined ? state.active.get(r.jobId) : undefined;
219
+ if (r.jobId === undefined)
220
+ continue;
221
+ const slot = state.active.get(r.jobId);
173
222
  if (!slot)
174
223
  continue;
175
224
  state.active.delete(r.jobId);
@@ -179,31 +228,24 @@ class WorkerPool extends node_events_1.EventEmitter {
179
228
  this.dispatchNow();
180
229
  return;
181
230
  }
182
- // Job result — route by jobId to the right active slot.
231
+ // Single job result — route by jobId.
183
232
  const result = message;
233
+ if (result.jobId === undefined)
234
+ return;
184
235
  const state = getState(worker);
185
- const jobId = result.jobId;
186
- let slot;
187
- if (jobId !== undefined) {
188
- slot = state.active.get(jobId);
189
- if (slot)
190
- state.active.delete(jobId);
191
- }
192
- else if (state.active.size === 1) {
193
- // Concurrency == 1 back-compat: single in-flight job, no need for ID.
194
- const it = state.active.values().next();
195
- slot = it.value;
196
- state.active.clear();
197
- }
236
+ const slot = state.active.get(result.jobId);
198
237
  if (!slot)
199
238
  return; // late message for an aborted/timed-out task
239
+ state.active.delete(result.jobId);
200
240
  this.completeJob(worker, slot, result);
201
241
  if (!this.destroyed)
202
242
  this.dispatchNow();
203
243
  };
204
244
  worker.on('message', onMessage);
205
- worker.once('error', (err) => this.handleWorkerError(worker, err));
206
- worker.once('exit', (code) => this.handleWorkerExit(worker, code));
245
+ // Use .on (not .once): a wedged worker can emit multiple errors before
246
+ // exit. handleWorkerError is idempotent via WorkerState.replaced.
247
+ worker.on('error', (err) => this.handleWorkerError(worker, err));
248
+ worker.on('exit', (code) => this.handleWorkerExit(worker, code));
207
249
  return worker;
208
250
  }
209
251
  handleIpcInvoke(worker, req) {
@@ -246,9 +288,18 @@ class WorkerPool extends node_events_1.EventEmitter {
246
288
  });
247
289
  return;
248
290
  }
291
+ // Forward the originating task's AbortSignal as the last arg when the
292
+ // worker provided one — lets proxy implementations honour cancellation
293
+ // (fetch, child_process, etc.) instead of running past it.
294
+ let callArgs = req.args;
295
+ if (req.abortSignalId !== undefined) {
296
+ const sig = this.findSignalById(req.abortSignalId);
297
+ if (sig)
298
+ callArgs = [...req.args, sig];
299
+ }
249
300
  let p;
250
301
  try {
251
- p = svcInstance[req.methodName](...req.args);
302
+ p = method.apply(svcInstance, callArgs);
252
303
  }
253
304
  catch (err) {
254
305
  reply({
@@ -273,73 +324,140 @@ class WorkerPool extends node_events_1.EventEmitter {
273
324
  error: err.message ?? String(err),
274
325
  }));
275
326
  }
327
+ /**
328
+ * Look up the original caller's AbortSignal by abortSignalId. Used by
329
+ * `handleIpcInvoke` to forward cancellation into proxy methods.
330
+ * Scan is bounded by `concurrency * poolSize` and only happens on proxy
331
+ * calls from signal-bearing tasks, so we don't bother with a side index.
332
+ */
333
+ findSignalById(abortSignalId) {
334
+ for (const worker of this.workers) {
335
+ const state = worker[STATE];
336
+ if (!state)
337
+ continue;
338
+ for (const slot of state.active.values()) {
339
+ if (slot.task.job.abortSignalId === abortSignalId)
340
+ return slot.task.signal;
341
+ }
342
+ }
343
+ return undefined;
344
+ }
276
345
  enqueue(task) {
277
- const weight = PRIORITY_WEIGHT[task.priority];
278
- const q = this.queue;
279
- const head = this.queueHead;
280
- const n = q.length;
281
- // Fast path: empty queue, or tail has >= priority → just push (O(1)).
282
- // This is by far the most common case under steady-state load.
283
- if (n === head || PRIORITY_WEIGHT[q[n - 1].priority] >= weight) {
284
- q.push(task);
285
- return;
346
+ const bucket = task.priority === 'HIGH'
347
+ ? this.queueHigh
348
+ : task.priority === 'LOW'
349
+ ? this.queueLow
350
+ : this.queueNormal;
351
+ bucket.push(task);
352
+ this.queuedCount++;
353
+ }
354
+ /**
355
+ * Pop the next ready task in priority order (HIGH > NORMAL > LOW). Skips
356
+ * tombstoned entries (aborted-while-queued) and lazily compacts each
357
+ * bucket once its head pointer wastes >1024 slots.
358
+ *
359
+ * Returns `undefined` when all buckets are empty or contain only
360
+ * tombstones — caller must check.
361
+ */
362
+ dequeue() {
363
+ // HIGH
364
+ while (this.queueHighHead < this.queueHigh.length) {
365
+ const t = this.queueHigh[this.queueHighHead];
366
+ this.queueHigh[this.queueHighHead] = undefined;
367
+ this.queueHighHead++;
368
+ this.maybeCompact(this.queueHigh, 'queueHighHead');
369
+ if (t) {
370
+ this.queuedCount--;
371
+ return t;
372
+ }
286
373
  }
287
- // Binary search over the live region [head, n) for the insertion point.
288
- let lo = head, hi = n;
289
- while (lo < hi) {
290
- const mid = (lo + hi) >>> 1;
291
- if (PRIORITY_WEIGHT[q[mid].priority] < weight)
292
- hi = mid;
293
- else
294
- lo = mid + 1;
374
+ // NORMAL
375
+ while (this.queueNormalHead < this.queueNormal.length) {
376
+ const t = this.queueNormal[this.queueNormalHead];
377
+ this.queueNormal[this.queueNormalHead] = undefined;
378
+ this.queueNormalHead++;
379
+ this.maybeCompact(this.queueNormal, 'queueNormalHead');
380
+ if (t) {
381
+ this.queuedCount--;
382
+ return t;
383
+ }
384
+ }
385
+ // LOW
386
+ while (this.queueLowHead < this.queueLow.length) {
387
+ const t = this.queueLow[this.queueLowHead];
388
+ this.queueLow[this.queueLowHead] = undefined;
389
+ this.queueLowHead++;
390
+ this.maybeCompact(this.queueLow, 'queueLowHead');
391
+ if (t) {
392
+ this.queuedCount--;
393
+ return t;
394
+ }
295
395
  }
296
- q.splice(lo, 0, task);
396
+ return undefined;
397
+ }
398
+ maybeCompact(bucket, headProp) {
399
+ const head = this[headProp];
400
+ if (head > 1024 && head * 2 > bucket.length) {
401
+ bucket.splice(0, head);
402
+ this[headProp] = 0;
403
+ }
404
+ }
405
+ /** True iff at least one bucket has a non-tombstone entry remaining. */
406
+ hasQueued() {
407
+ return this.queuedCount > 0;
297
408
  }
298
409
  schedule() {
299
410
  if (this.destroyed || this.scheduleQueued)
300
411
  return;
301
- if (this.idle.length === 0 || this.queueHead >= this.queue.length)
412
+ if (this.idle.length === 0 || !this.hasQueued())
302
413
  return;
303
414
  this.scheduleQueued = true;
304
415
  queueMicrotask(this.drain);
305
416
  }
306
417
  /**
307
- * Synchronous drain used on the COMPLETION path when a worker becomes
308
- * idle as a result of a result message arriving, we want to hand it the
309
- * next queued job in the SAME tick. The microtask-deferred `schedule()`
310
- * adds a full microtask hop per round-trip, which dominates throughput
311
- * for short tasks with concurrency=1.
312
- *
313
- * Initial-burst dispatch still goes through the deferred `schedule()` so
314
- * synchronous floods of `execute()` calls get coalesced into per-worker
315
- * batches.
418
+ * Synchronous drain used on the COMPLETION path. Initial-burst dispatch
419
+ * still goes through the deferred `schedule()` so synchronous floods of
420
+ * `execute()` calls get coalesced into per-worker batches.
316
421
  */
317
422
  dispatchNow() {
318
423
  if (this.destroyed)
319
424
  return;
320
- const idle = this.idle;
321
- const q = this.queue;
322
- // If a microtask drain is already queued (initial burst still in flight),
323
- // let it own the dispatch — running both would race and double-dispatch.
425
+ // If a microtask drain is already queued, let it own the dispatch.
324
426
  if (this.scheduleQueued)
325
427
  return;
326
- if (idle.length === 0 || this.queueHead >= q.length)
428
+ this.flushDispatch(true);
429
+ }
430
+ /** Pre-bound for queueMicrotask — avoids closure allocation per schedule. */
431
+ drain = () => {
432
+ this.scheduleQueued = false;
433
+ if (this.destroyed)
434
+ return;
435
+ this.flushDispatch(false);
436
+ };
437
+ /**
438
+ * Common dispatch loop shared by `dispatchNow` (synchronous, post-result)
439
+ * and `drain` (microtask, post-burst). Pops idle slots and ships jobs as
440
+ * either bare `WorkerJob` (single) or `WorkerJobBatch` envelopes.
441
+ *
442
+ * @param fastSingle When true, take a fast path for "exactly one job to
443
+ * exactly one idle worker" that skips the per-worker Map allocation.
444
+ */
445
+ flushDispatch(fastSingle) {
446
+ const idle = this.idle;
447
+ if (idle.length === 0 || !this.hasQueued())
327
448
  return;
328
449
  let batches;
329
- while (idle.length > 0 && this.queueHead < q.length) {
450
+ while (idle.length > 0) {
451
+ const task = this.dequeue();
452
+ if (!task)
453
+ break;
330
454
  const worker = idle.pop();
331
- const task = q[this.queueHead];
332
- q[this.queueHead] = undefined;
333
- this.queueHead++;
334
- if (this.queueHead > 1024 && this.queueHead * 2 > q.length) {
335
- q.splice(0, this.queueHead);
336
- this.queueHead = 0;
337
- }
338
455
  this.prepareDispatch(worker, task);
339
- // Fast path for the overwhelmingly common case: ONE worker idle,
340
- // ONE job to dispatch. Skip the Map allocation and ship directly.
341
- if (batches === undefined && (idle.length === 0 || this.queueHead >= q.length)) {
342
- worker.postMessage(task.job);
456
+ // Fast path: ONE worker idle, ONE job dispatched ship immediately.
457
+ if (fastSingle &&
458
+ batches === undefined &&
459
+ (idle.length === 0 || !this.hasQueued())) {
460
+ this.safePostJob(worker, task, task.job);
343
461
  return;
344
462
  }
345
463
  if (batches === undefined)
@@ -355,63 +473,72 @@ class WorkerPool extends node_events_1.EventEmitter {
355
473
  return;
356
474
  for (const [worker, jobs] of batches) {
357
475
  if (jobs.length === 1) {
358
- worker.postMessage(jobs[0]);
476
+ const slot = getState(worker).active.get(jobs[0].jobId);
477
+ this.safePostJob(worker, slot?.task, jobs[0]);
359
478
  }
360
479
  else {
361
- worker.postMessage({ type: 'batch', jobs });
480
+ this.safePostBatch(worker, jobs);
362
481
  }
363
482
  }
364
483
  }
365
- /** Pre-bound for queueMicrotask — avoids closure allocation per schedule. */
366
- drain = () => {
367
- this.scheduleQueued = false;
368
- if (this.destroyed)
369
- return;
370
- const idle = this.idle;
371
- const q = this.queue;
372
- // Collect per-worker dispatches built during this schedule pass so we
373
- // can ship them in ONE postMessage envelope per worker. Each postMessage
374
- // pays a fixed structuredClone setup cost — batching amortises it.
375
- let batches;
376
- while (idle.length > 0 && this.queueHead < q.length) {
377
- const worker = idle.pop();
378
- const task = q[this.queueHead];
379
- q[this.queueHead] = undefined;
380
- this.queueHead++;
381
- if (this.queueHead > 1024 && this.queueHead * 2 > q.length) {
382
- q.splice(0, this.queueHead);
383
- this.queueHead = 0;
484
+ /**
485
+ * Post a single job; on serialization failure synthesise a failure result
486
+ * so the caller's promise rejects instead of hanging on the active map.
487
+ */
488
+ safePostJob(worker, task, job) {
489
+ try {
490
+ worker.postMessage(job);
491
+ }
492
+ catch (err) {
493
+ const state = getState(worker);
494
+ const slot = state.active.get(job.jobId);
495
+ if (slot) {
496
+ state.active.delete(job.jobId);
497
+ this.completeJob(worker, slot, {
498
+ type: 'result',
499
+ ok: false,
500
+ jobId: job.jobId,
501
+ error: {
502
+ name: 'DataCloneError',
503
+ message: `Failed to postMessage job "${job.serviceName}.${job.methodName}": ` +
504
+ (err.message ?? String(err)),
505
+ },
506
+ });
384
507
  }
385
- this.prepareDispatch(worker, task);
386
- if (!batches)
387
- batches = new Map();
388
- let arr = batches.get(worker);
389
- if (!arr) {
390
- arr = [];
391
- batches.set(worker, arr);
508
+ else if (task) {
509
+ task.reject(err);
392
510
  }
393
- arr.push(task.job);
394
511
  }
395
- if (!batches)
396
- return;
397
- for (const [worker, jobs] of batches) {
398
- if (jobs.length === 1) {
399
- worker.postMessage(jobs[0]);
400
- }
401
- else {
402
- worker.postMessage({ type: 'batch', jobs });
512
+ }
513
+ safePostBatch(worker, jobs) {
514
+ try {
515
+ worker.postMessage({ type: 'batch', jobs });
516
+ }
517
+ catch (err) {
518
+ // Batch-level failure: fail every job in this batch individually.
519
+ const state = getState(worker);
520
+ const message = `Failed to postMessage batch: ` +
521
+ (err.message ?? String(err));
522
+ for (let i = 0; i < jobs.length; i++) {
523
+ const j = jobs[i];
524
+ const slot = state.active.get(j.jobId);
525
+ if (!slot)
526
+ continue;
527
+ state.active.delete(j.jobId);
528
+ this.completeJob(worker, slot, {
529
+ type: 'result',
530
+ ok: false,
531
+ jobId: j.jobId,
532
+ error: { name: 'DataCloneError', message },
533
+ });
403
534
  }
404
535
  }
405
- };
536
+ }
406
537
  prepareDispatch(worker, task) {
407
- // Only pay the syscall cost when someone is actually listening.
408
538
  const wantTiming = this.listenerCount('taskEnd') > 0 || this.listenerCount('taskStart') > 0;
409
539
  const startedAt = wantTiming ? Date.now() : 0;
410
540
  task.attempts++;
411
- const sigId = task.job.abortSignalId;
412
- if (sigId !== undefined) {
413
- this.signalWorkerMap.set(sigId, worker);
414
- }
541
+ task.worker = worker;
415
542
  const slot = { task, startedAt, settled: false };
416
543
  const state = getState(worker);
417
544
  state.active.set(task.job.jobId, slot);
@@ -429,14 +556,13 @@ class WorkerPool extends node_events_1.EventEmitter {
429
556
  slot.settled = true;
430
557
  if (slot.timeoutHandle)
431
558
  clearTimeout(slot.timeoutHandle);
432
- const sigId = slot.task.job.abortSignalId;
433
- if (sigId !== undefined)
434
- this.signalWorkerMap.delete(sigId);
435
559
  this.activeCount--;
436
560
  if (result.ok) {
437
561
  if (this.listenerCount('taskEnd') > 0 && slot.startedAt > 0) {
438
562
  this.emit('taskEnd', slot.task.job, Date.now() - slot.startedAt);
439
563
  }
564
+ this.detachAbort(slot.task);
565
+ slot.task.worker = undefined;
440
566
  slot.task.resolve(result.data);
441
567
  }
442
568
  else {
@@ -445,27 +571,40 @@ class WorkerPool extends node_events_1.EventEmitter {
445
571
  message: 'Unknown worker error',
446
572
  });
447
573
  }
448
- // Give the slot back to the pool. Scheduling is the *caller*'s job so
449
- // batched-result paths can release N slots before re-scheduling — that
450
- // way schedule() can batch N new dispatches into a single postMessage
451
- // back to the worker.
452
574
  if (!this.destroyed) {
453
575
  this.idle.push(worker);
454
576
  }
455
577
  }
456
578
  handleFailure(task, serializedError) {
457
579
  const retry = task.retry ?? 0;
458
- const retryDelay = task.retryDelay ?? 0;
459
580
  if (this.listenerCount('taskError') > 0) {
460
581
  this.emit('taskError', task.job, serializedError);
461
582
  }
462
583
  if (task.attempts < retry + 1) {
584
+ // Resolve retryDelay main-side: numeric value, or invoke fn(attempt).
585
+ let delay = 0;
586
+ const rd = task.retryDelay;
587
+ if (typeof rd === 'number')
588
+ delay = rd;
589
+ else if (typeof rd === 'function') {
590
+ try {
591
+ delay = Math.max(0, Number(rd(task.attempts)) || 0);
592
+ }
593
+ catch {
594
+ delay = 0;
595
+ }
596
+ }
597
+ task.worker = undefined;
463
598
  const scheduleRetry = () => {
599
+ if (this.destroyed) {
600
+ task.reject(new Error('WorkerPool destroyed'));
601
+ return;
602
+ }
464
603
  this.enqueue(task);
465
604
  this.schedule();
466
605
  };
467
- if (retryDelay > 0)
468
- setTimeout(scheduleRetry, retryDelay);
606
+ if (delay > 0)
607
+ setTimeout(scheduleRetry, delay);
469
608
  else
470
609
  scheduleRetry();
471
610
  return;
@@ -480,25 +619,20 @@ class WorkerPool extends node_events_1.EventEmitter {
480
619
  failedAt: new Date(),
481
620
  };
482
621
  this.emit('dead', dlEvent);
622
+ this.detachAbort(task);
623
+ task.worker = undefined;
483
624
  task.reject(deserializeError(serializedError));
484
625
  }
485
626
  async handleTimeout(worker, slot) {
486
627
  if (slot.settled)
487
628
  return;
488
629
  slot.settled = true;
489
- const sigId = slot.task.job.abortSignalId;
490
- if (sigId !== undefined)
491
- this.signalWorkerMap.delete(sigId);
492
630
  const state = getState(worker);
493
631
  state.active.delete(slot.task.job.jobId);
494
632
  this.activeCount--;
495
- this.handleFailure(slot.task, {
496
- name: 'TimeoutError',
497
- message: `Task "${slot.task.job.serviceName}.${slot.task.job.methodName}" ` +
498
- `timed out after ${slot.task.timeout}ms`,
499
- });
500
- // Timeouts terminate the worker (its event loop may be wedged) and
501
- // replace it. All other in-flight jobs on this worker fail too.
633
+ // Terminate FIRST so the wedged worker is gone before any retry can be
634
+ // scheduled (which would otherwise race back into a worker we're about
635
+ // to destroy and either fail to dispatch or wedge again).
502
636
  try {
503
637
  await worker.terminate();
504
638
  }
@@ -506,51 +640,56 @@ class WorkerPool extends node_events_1.EventEmitter {
506
640
  /* ignore */
507
641
  }
508
642
  this.replaceWorker(worker);
643
+ this.handleFailure(slot.task, {
644
+ name: 'TimeoutError',
645
+ message: `Task "${slot.task.job.serviceName}.${slot.task.job.methodName}" ` +
646
+ `timed out after ${slot.task.timeout}ms`,
647
+ });
509
648
  this.schedule();
510
649
  }
511
650
  handleWorkerError(worker, err) {
512
651
  const state = worker[STATE];
513
- if (state) {
514
- for (const slot of state.active.values()) {
515
- if (slot.settled)
516
- continue;
517
- slot.settled = true;
518
- if (slot.timeoutHandle)
519
- clearTimeout(slot.timeoutHandle);
520
- const sigId = slot.task.job.abortSignalId;
521
- if (sigId !== undefined)
522
- this.signalWorkerMap.delete(sigId);
523
- const { serviceName, methodName } = slot.task.job;
524
- const wrapped = new Error(`Worker crashed in "${serviceName}.${methodName}": ${err.message}`);
525
- wrapped.stack = err.stack;
526
- this.emit('error', wrapped, slot.task.job);
527
- slot.task.reject(wrapped);
528
- this.activeCount--;
529
- }
530
- state.active.clear();
652
+ if (!state || state.replaced)
653
+ return;
654
+ state.replaced = true;
655
+ for (const slot of state.active.values()) {
656
+ if (slot.settled)
657
+ continue;
658
+ slot.settled = true;
659
+ if (slot.timeoutHandle)
660
+ clearTimeout(slot.timeoutHandle);
661
+ const { serviceName, methodName } = slot.task.job;
662
+ const wrapped = new Error(`Worker crashed in "${serviceName}.${methodName}": ${err.message}`);
663
+ wrapped.stack = err.stack;
664
+ this.emit('error', wrapped, slot.task.job);
665
+ this.detachAbort(slot.task);
666
+ slot.task.worker = undefined;
667
+ slot.task.reject(wrapped);
668
+ this.activeCount--;
531
669
  }
670
+ state.active.clear();
532
671
  this.replaceWorker(worker);
533
672
  }
534
673
  handleWorkerExit(worker, code) {
535
674
  if (this.destroyed)
536
675
  return;
537
676
  const state = worker[STATE];
538
- if (state) {
539
- for (const slot of state.active.values()) {
540
- if (slot.settled)
541
- continue;
542
- slot.settled = true;
543
- if (slot.timeoutHandle)
544
- clearTimeout(slot.timeoutHandle);
545
- const sigId = slot.task.job.abortSignalId;
546
- if (sigId !== undefined)
547
- this.signalWorkerMap.delete(sigId);
548
- slot.task.reject(new Error(`Worker exited with code ${code} while running ` +
549
- `"${slot.task.job.serviceName}.${slot.task.job.methodName}"`));
550
- this.activeCount--;
551
- }
552
- state.active.clear();
677
+ if (!state || state.replaced)
678
+ return;
679
+ state.replaced = true;
680
+ for (const slot of state.active.values()) {
681
+ if (slot.settled)
682
+ continue;
683
+ slot.settled = true;
684
+ if (slot.timeoutHandle)
685
+ clearTimeout(slot.timeoutHandle);
686
+ this.detachAbort(slot.task);
687
+ slot.task.worker = undefined;
688
+ slot.task.reject(new Error(`Worker exited with code ${code} while running ` +
689
+ `"${slot.task.job.serviceName}.${slot.task.job.methodName}"`));
690
+ this.activeCount--;
553
691
  }
692
+ state.active.clear();
554
693
  this.replaceWorker(worker);
555
694
  }
556
695
  replaceWorker(oldWorker) {
@@ -573,7 +712,10 @@ class WorkerPool extends node_events_1.EventEmitter {
573
712
  }
574
713
  async destroy() {
575
714
  this.destroyed = true;
576
- // Drain: wait for all active jobs to finish, up to shutdownTimeout
715
+ // Drain: wait for all active jobs to finish, up to shutdownTimeout.
716
+ // We track per-task settle Promises (created at execute-time) instead
717
+ // of monkey-patching task.resolve/reject — that approach silently
718
+ // dropped duplicate-settle calls and could deadlock the race.
577
719
  if (this.activeCount > 0) {
578
720
  const activeTasks = [];
579
721
  for (const worker of this.workers) {
@@ -587,26 +729,41 @@ class WorkerPool extends node_events_1.EventEmitter {
587
729
  Promise.allSettled(activeTasks.map((task) => new Promise((res) => {
588
730
  const orig = task.resolve;
589
731
  const origRej = task.reject;
732
+ let done = false;
590
733
  task.resolve = (v) => {
734
+ if (!done) {
735
+ done = true;
736
+ res();
737
+ }
591
738
  orig(v);
592
- res();
593
739
  };
594
740
  task.reject = (e) => {
741
+ if (!done) {
742
+ done = true;
743
+ res();
744
+ }
595
745
  origRej(e);
596
- res();
597
746
  };
598
747
  }))),
599
748
  new Promise((res) => setTimeout(res, this.shutdownTimeout)),
600
749
  ]);
601
750
  }
602
- // Reject anything still queued
603
- for (let i = this.queueHead; i < this.queue.length; i++) {
604
- const queued = this.queue[i];
605
- if (queued)
606
- queued.reject(new Error('WorkerPool destroyed'));
607
- }
608
- this.queue.length = 0;
609
- this.queueHead = 0;
751
+ // Reject anything still queued — walk each bucket and reject live entries.
752
+ const drainBucket = (bucket, head) => {
753
+ for (let i = head; i < bucket.length; i++) {
754
+ const queued = bucket[i];
755
+ if (queued) {
756
+ this.detachAbort(queued);
757
+ queued.reject(new Error('WorkerPool destroyed'));
758
+ }
759
+ }
760
+ bucket.length = 0;
761
+ };
762
+ drainBucket(this.queueHigh, this.queueHighHead);
763
+ drainBucket(this.queueNormal, this.queueNormalHead);
764
+ drainBucket(this.queueLow, this.queueLowHead);
765
+ this.queueHighHead = this.queueNormalHead = this.queueLowHead = 0;
766
+ this.queuedCount = 0;
610
767
  await Promise.allSettled(this.workers.map((w) => w.terminate()));
611
768
  this.workers.length = 0;
612
769
  this.idle.length = 0;