]> Piment Noir Git Repositories - poolifier.git/commitdiff
fix(pool): reject task promises on unexpected worker exit (#3343)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 16 Oct 2026 17:36:35 +0000 (19:36 +0200)
committerGitHub <noreply@github.com>
Fri, 16 Oct 2026 17:36:35 +0000 (19:36 +0200)
* fix(pool): reject task promises on unexpected worker exit

* fix(pool): finalize exited workers before settling tasks

* docs(api): scope worker response guarantees by transport

* fix(pool): preserve affinity and normalize worker failures

* fix(pool): settle exited worker tasks when replacement fails

* fix(pool): complete exit callbacks and thread termination

* fix(pool): preserve aborts during queue redistribution

* fix(pool): defer dequeue notifications until redistribution completes

* [autofix.ci] apply automated fixes

* fix(pool): redistribute tasks after worker reindexing

* fix(pool): isolate synchronous task send failures

* fix(pool): preserve detached queue recovery and cancellation

* docs(api): clarify worker failure guarantees

* fix(pool): defer dequeue notifications across worker removal

* fix(pool): preserve affinity while survivors initialize

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
12 files changed:
docs/api.md
src/pools/abstract-pool.ts
src/pools/worker-node.ts
src/pools/worker.ts
src/utility-types.ts
tests/pools/cluster/fixed.test.mjs
tests/pools/thread/fixed.test.mjs
tests/pools/worker-exit.test.mjs [new file with mode: 0644]
tests/pools/worker-node.test.mjs
tests/worker-files/cluster/crashWorker.cjs [new file with mode: 0644]
tests/worker-files/thread/bootstrapAffinityWorker.mjs [new file with mode: 0644]
tests/worker-files/thread/crashWorker.mjs [new file with mode: 0644]

index e88c4ee59dd2bff43048caf647c068e82f89225f..fa7bc3d1003f6b3a8f6a34fead05905772777848 100644 (file)
 
 This method is available on both pool implementations and returns a promise with the task function execution response.
 
+#### Task failures
+
+- If sending a task throws, its promise rejects with the original error, without failing other tasks.
+- On unexpected worker exit, unfinished dispatched tasks reject with an `Error` and are not retried. Thread responses already posted to the pool retain their result or task error; cluster responses must reach the pool before exit.
+- Queued tasks are redistributed to ready workers respecting worker node affinity. Worker node affinity is preserved while other workers initialize. Affinity uses current worker node indices after removal and any replacement attempt. Tasks without an eligible destination reject. Eligible handoffs complete before `backPressureEnd` is emitted, including when worker-removal event listeners disable task queues.
+- Disabling task queues from a worker-removal event listener does not discard queued tasks awaiting redistribution: eligible tasks are dispatched directly.
+- Queued tasks aborted after worker removal and before reassignment reject with the original abort reason if it is an `Error`, a new `Error` with that message if it is a string, or a task-specific abort `Error` otherwise.
+- Non-`Error` native worker failures are wrapped in an `Error` with the original value in `cause`. The pool's `error` event retains the original value.
+- Failure to construct a replacement worker does not prevent task settlement; the construction error still propagates.
+
+Unexpected-exit handling does not apply to intentional pool or worker termination.
+
 ### `pool.mapExecute(data, name, abortSignals, transferList)`
 
 `data` An iterable of objects that you want to pass to your worker task function implementation.  
@@ -56,13 +68,15 @@ This method is available on both pool implementations and returns a promise with
 
 This method is available on both pool implementations and returns a promise with the task function execution responses array.
 
+Task failures follow the same rules as [`pool.execute()`](#task-failures).
+
 ### `pool.start()`
 
 This method is available on both pool implementations and will start the minimum number of workers.
 
 ### `pool.destroy()`
 
-This method is available on both pool implementations and will call the terminate method on each worker.
+This method is available on both pool implementations and returns a promise for pool destruction. Workers that have already exited do not block destruction.
 
 ### `pool.hasTaskFunction(name)`
 
@@ -110,7 +124,7 @@ An object with these properties:
   Default: `() => {}`
 - `errorHandler` (optional) - A function that will listen for error event on each worker.  
   Default: `() => {}`
-- `exitHandler` (optional) - A function that will listen for exit event on each worker.  
+- `exitHandler` (optional) - A function called once per worker exit after pool cleanup, including when replacement construction fails. Receives the exit code with the native worker as `this`.  
   Default: `() => {}`
 
 - `workerChoiceStrategy` (optional) - The default worker choice strategy to use in this pool:
index c05997a04f24207f56ec75a4052a22fb2aded05e..317cb621ab64edcd09f38ae77b54308c65e5f10b 100644 (file)
@@ -4,6 +4,7 @@ import { AsyncResource } from 'node:async_hooks'
 import { randomUUID } from 'node:crypto'
 import { EventEmitterAsyncResource } from 'node:events'
 import { performance } from 'node:perf_hooks'
+import { receiveMessageOnPort } from 'node:worker_threads'
 
 import type {
   MessageValue,
@@ -459,6 +460,11 @@ export abstract class AbstractPool<
    */
   private startTimestamp?: number
 
+  /**
+   * Whether task dequeuing events are deferred until worker exit handoffs finish.
+   */
+  private taskDequeuingEventsDeferred = false
+
   /**
    * The task functions added at runtime map:
    * - `key`: The task function name.
@@ -469,6 +475,13 @@ export abstract class AbstractPool<
     TaskFunctionObject<Data, Response>
   >
 
+  /**
+   * Worker nodes whose termination was requested by the pool.
+   */
+  private readonly terminatingWorkerNodes = new WeakSet<
+    IWorkerNode<Worker, Data>
+  >()
+
   /**
    * Whether the pool is ready or not.
    * @returns The pool readiness boolean status.
@@ -916,9 +929,10 @@ export abstract class AbstractPool<
   }
 
   /**
-   * Hook executed after the worker task execution.
+   * Hook executed after task execution for a live worker node.
+   * Recovered responses from removed worker nodes do not invoke this hook.
    * Can be overridden.
-   * @param workerNodeKey - The worker node key.
+   * @param workerNodeKey - The live worker node key.
    * @param message - The received message.
    */
   protected afterTaskExecutionHook (
@@ -1115,6 +1129,9 @@ export abstract class AbstractPool<
    */
   protected createAndSetupWorkerNode (): number {
     const workerNode = this.createWorkerNode()
+    const exitHandler = this.opts.exitHandler
+    let workerError: Error | undefined
+    let taskResponses: MessageValue<Response>[] | undefined
     workerNode.registerWorkerEventHandler(
       'online',
       this.opts.onlineHandler ?? EMPTY_FUNCTION
@@ -1127,38 +1144,98 @@ export abstract class AbstractPool<
       'error',
       this.opts.errorHandler ?? EMPTY_FUNCTION
     )
-    workerNode.registerOnceWorkerEventHandler('error', (error: Error) => {
-      workerNode.info.ready = false
-      this.emitter?.emit(PoolEvents.error, error)
-      if (this.started && !this.destroying) {
-        if (this.opts.restartWorkerOnError === true) {
-          if (workerNode.info.dynamic) {
-            this.createAndSetupDynamicWorkerNode()
-          } else if (!this.startingMinimumNumberOfWorkers) {
-            this.startMinimumNumberOfWorkers(true)
+    workerNode.registerOnceWorkerEventHandler(
+      'error',
+      (rawWorkerError: unknown) => {
+        workerError =
+          rawWorkerError instanceof Error
+            ? rawWorkerError
+            : new Error(
+              typeof rawWorkerError === 'string'
+                ? rawWorkerError
+                : 'Worker failed with a non-Error value',
+              { cause: rawWorkerError }
+            )
+        taskResponses = this.captureTaskResponses(workerNode, taskResponses)
+        workerNode.info.ready = false
+        this.emitter?.emit(PoolEvents.error, rawWorkerError)
+        if (this.started && !this.destroying) {
+          if (this.opts.restartWorkerOnError === true) {
+            if (workerNode.info.dynamic) {
+              this.createAndSetupDynamicWorkerNode()
+            } else if (!this.startingMinimumNumberOfWorkers) {
+              this.startMinimumNumberOfWorkers(true)
+            }
           }
         }
-        if (this.opts.enableTasksQueue === true) {
-          this.redistributeQueuedTasks(this.workerNodes.indexOf(workerNode))
-        }
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+        workerNode?.terminate().catch((error: unknown) => {
+          this.emitter?.emit(PoolEvents.error, error)
+        })
       }
-      // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, promise/no-promise-in-callback
-      workerNode?.terminate().catch((error: unknown) => {
-        this.emitter?.emit(PoolEvents.error, error)
-      })
-    })
-    workerNode.registerWorkerEventHandler(
-      'exit',
-      this.opts.exitHandler ?? EMPTY_FUNCTION
     )
-    workerNode.registerOnceWorkerEventHandler('exit', () => {
-      this.removeWorkerNode(workerNode)
-      if (
-        this.started &&
-        !this.startingMinimumNumberOfWorkers &&
-        !this.destroying
-      ) {
-        this.startMinimumNumberOfWorkers(true)
+    workerNode.registerOnceWorkerEventHandler('exit', (exitCode: number) => {
+      const taskDequeuingEventsDeferred = this.taskDequeuingEventsDeferred
+      try {
+        const settlingTasks =
+          workerError != null || !this.terminatingWorkerNodes.has(workerNode)
+        if (settlingTasks) {
+          taskResponses = this.captureTaskResponses(workerNode, taskResponses)
+        }
+        workerNode.info.ready = false
+        let workerNodeTasks:
+          | Map<
+              `${string}-${string}-${string}-${string}-${string}`,
+              PromiseResponseWrapper<Response>
+            >
+          | undefined
+        if (settlingTasks) {
+          const workerNodeKey = this.workerNodes.indexOf(workerNode)
+          for (const [taskId, promiseResponse] of this.promiseResponseMap) {
+            if (promiseResponse.workerNodeKey === workerNodeKey) {
+              ;(workerNodeTasks ??= new Map()).set(taskId, promiseResponse)
+            }
+          }
+        }
+        if (settlingTasks) {
+          this.taskDequeuingEventsDeferred = true
+        }
+        this.removeWorkerNode(workerNode)
+        try {
+          if (
+            this.started &&
+            !this.startingMinimumNumberOfWorkers &&
+            !this.destroying
+          ) {
+            this.startMinimumNumberOfWorkers(true)
+          }
+        } finally {
+          try {
+            if (settlingTasks) {
+              this.redistributeQueuedTasks(workerNode)
+            }
+          } finally {
+            if (settlingTasks) {
+              workerNode.clearTasksQueue()
+            }
+            if (settlingTasks) {
+              this.taskDequeuingEventsDeferred = taskDequeuingEventsDeferred
+              this.checkAndEmitTaskDequeuingEvents()
+            }
+            if (taskResponses != null) {
+              for (const message of taskResponses) {
+                this.handleTaskExecutionResponse(message)
+              }
+            }
+            if (workerNodeTasks != null) {
+              this.rejectWorkerNodeTasks(workerNodeTasks, workerError)
+            }
+            this.checkAndEmitTaskExecutionFinishedEvents()
+          }
+        }
+      } finally {
+        this.taskDequeuingEventsDeferred = taskDequeuingEventsDeferred
+        exitHandler?.call(workerNode.worker, exitCode)
       }
     })
     const workerNodeKey = this.addWorkerNode(workerNode)
@@ -1183,6 +1260,7 @@ export abstract class AbstractPool<
    * @param workerNodeKey - The worker node key.
    */
   protected async destroyWorkerNode (workerNodeKey: number): Promise<void> {
+    this.terminatingWorkerNodes.add(this.workerNodes[workerNodeKey])
     this.flagWorkerNodeAsNotReady(workerNodeKey)
     const flushedTasks = this.flushTasksQueue(workerNodeKey)
     const workerNode = this.workerNodes[workerNodeKey]
@@ -1211,8 +1289,9 @@ export abstract class AbstractPool<
     let flushedTasks = 0
     while (this.tasksQueueSize(workerNodeKey) > 0) {
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      this.executeTask(workerNodeKey, this.dequeueTask(workerNodeKey)!)
-      ++flushedTasks
+      if (this.executeTask(workerNodeKey, this.dequeueTask(workerNodeKey)!)) {
+        ++flushedTasks
+      }
     }
     this.workerNodes[workerNodeKey].clearTasksQueue()
     return flushedTasks
@@ -1430,6 +1509,25 @@ export abstract class AbstractPool<
     )
   }
 
+  private captureTaskResponses (
+    workerNode: IWorkerNode<Worker, Data>,
+    taskResponses: MessageValue<Response>[] | undefined
+  ): MessageValue<Response>[] | undefined {
+    // Capture before termination closes the thread's private channel.
+    while (workerNode.messageChannel != null) {
+      const response = receiveMessageOnPort(workerNode.messageChannel.port1)
+      if (response == null) {
+        break
+      }
+      const message = response.message as MessageValue<Response>
+      if (message.taskId != null) {
+        this.checkMessageWorkerId(message)
+        ;(taskResponses ??= []).push(message)
+      }
+    }
+    return taskResponses
+  }
+
   private checkAndEmitReadyEvent (): void {
     if (this.emitter != null && !this.readyEventEmitted && this.ready) {
       this.emitter.listenerCount(PoolEvents.ready) > 0 &&
@@ -1442,6 +1540,7 @@ export abstract class AbstractPool<
     if (
       this.emitter != null &&
       this.backPressureEventEmitted &&
+      !this.taskDequeuingEventsDeferred &&
       !this.backPressure
     ) {
       this.emitter.listenerCount(PoolEvents.backPressureEnd) > 0 &&
@@ -1591,11 +1690,11 @@ export abstract class AbstractPool<
    * @returns The chosen worker node key.
    */
   private chooseWorkerNode (name?: string): number {
-    const workerNodeKeysSet = this.getTaskFunctionWorkerNodeKeysSet(name)
-    if (workerNodeKeysSet != null) {
+    const workerNodeKeys = this.getTaskFunctionWorkerNodeKeys(name)
+    if (workerNodeKeys != null) {
       const maxPoolSize =
         this.maximumNumberOfWorkers ?? this.minimumNumberOfWorkers
-      const targetSize = max(...workerNodeKeysSet) + 1
+      const targetSize = max(...workerNodeKeys) + 1
       while (
         this.started &&
         !this.destroying &&
@@ -1616,7 +1715,7 @@ export abstract class AbstractPool<
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     return this.workerChoiceStrategiesContext!.execute(
       this.getTaskFunctionWorkerChoiceStrategy(name),
-      workerNodeKeysSet
+      workerNodeKeys != null ? new Set(workerNodeKeys) : undefined
     )
   }
 
@@ -1658,6 +1757,7 @@ export abstract class AbstractPool<
   }
 
   private enqueueTask (workerNodeKey: number, task: Task<Data>): number {
+    this.updateTaskWorkerNodeKey(workerNodeKey, task)
     const tasksQueueSize = this.workerNodes[workerNodeKey].enqueueTask(task)
     this.checkAndEmitTaskQueuingEvents()
     return tasksQueueSize
@@ -1667,12 +1767,43 @@ export abstract class AbstractPool<
    * Executes the given task on the worker given its worker node key.
    * @param workerNodeKey - The worker node key.
    * @param task - The task to execute.
+   * @returns Whether the worker accepted the task.
    */
-  private executeTask (workerNodeKey: number, task: Task<Data>): void {
+  private executeTask (workerNodeKey: number, task: Task<Data>): boolean {
+    this.updateTaskWorkerNodeKey(workerNodeKey, task)
+    const workerNode = this.workerNodes[workerNodeKey]
     const { transferList } = task
     this.beforeTaskExecutionHook(workerNodeKey, task)
-    this.sendToWorker(workerNodeKey, task, transferList)
+    try {
+      this.sendToWorker(workerNodeKey, task, transferList)
+    } catch (error) {
+      if (workerNode.usage.tasks.executing > 0) {
+        --workerNode.usage.tasks.executing
+      }
+      if (
+        Array.isArray(workerNode.info.taskFunctionsProperties) &&
+        workerNode.info.taskFunctionsProperties.length > 2
+      ) {
+        const taskFunctionWorkerUsage =
+          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+          workerNode.getTaskFunctionWorkerUsage(task.name!)
+        if (
+          taskFunctionWorkerUsage != null &&
+          taskFunctionWorkerUsage.tasks.executing > 0
+        ) {
+          --taskFunctionWorkerUsage.tasks.executing
+        }
+      }
+      if (task.taskId != null) {
+        const promiseResponse = this.promiseResponseMap.get(task.taskId)
+        if (promiseResponse != null) {
+          this.rejectTask(task.taskId, promiseResponse, error)
+        }
+      }
+      return false
+    }
     this.checkAndEmitTaskExecutionEvents()
+    return true
   }
 
   private flushTasksQueues (): void {
@@ -1730,23 +1861,28 @@ export abstract class AbstractPool<
   }
 
   /**
-   * Gets task function worker node keys affinity set, if any.
+   * Gets task function worker node keys affinity array, if any.
    * @param name - The task function name.
-   * @returns The task function worker node keys affinity set, or `undefined` if not defined.
+   * @param taskFunctionsProperties - The task function properties used to resolve affinity.
+   * @returns The task function worker node keys affinity array, or `undefined` if not defined.
    */
-  private readonly getTaskFunctionWorkerNodeKeysSet = (
-    name?: string
-  ): ReadonlySet<number> | undefined => {
+  private readonly getTaskFunctionWorkerNodeKeys = (
+    name?: string,
+    taskFunctionsProperties: readonly TaskFunctionProperties[] = this.listTaskFunctionsProperties()
+  ): readonly number[] | undefined => {
     name = name ?? DEFAULT_TASK_NAME
-    const taskFunctionsProperties = this.listTaskFunctionsProperties()
     if (name === DEFAULT_TASK_NAME) {
       name = taskFunctionsProperties[1]?.name
     }
-    const workerNodeKeys = taskFunctionsProperties.find(
-      (taskFunctionProperties: TaskFunctionProperties) =>
-        taskFunctionProperties.name === name
-    )?.workerNodeKeys
-    return workerNodeKeys != null ? new Set(workerNodeKeys) : undefined
+    // Keep the affinity lookup free of iterator allocation.
+    // eslint-disable-next-line @typescript-eslint/prefer-for-of
+    for (let i = 0; i < taskFunctionsProperties.length; i++) {
+      const taskFunctionProperties = taskFunctionsProperties[i]
+      if (taskFunctionProperties.name === name) {
+        return taskFunctionProperties.workerNodeKeys
+      }
+    }
+    return undefined
   }
 
   private getTasksQueuePriority (): boolean {
@@ -1849,10 +1985,15 @@ export abstract class AbstractPool<
     if (promiseResponse != null) {
       const { asyncResource, reject, resolve, workerNodeKey } = promiseResponse
       const workerNode = this.workerNodes[workerNodeKey]
+      let error: Error | undefined
       if (workerError != null) {
-        this.emitter?.emit(PoolEvents.taskError, workerError)
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-        const error = this.handleWorkerError(taskId!, workerError)
+        error = this.handleWorkerError(taskId!, workerError)
+      }
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      this.promiseResponseMap.delete(taskId!)
+      if (workerError != null) {
+        this.emitter?.emit(PoolEvents.taskError, workerError)
         asyncResource != null
           ? asyncResource.runInAsyncScope(reject, this.emitter, error)
           : reject(error)
@@ -1862,24 +2003,57 @@ export abstract class AbstractPool<
           : resolve(data as Response)
       }
       asyncResource?.emitDestroy()
-      this.afterTaskExecutionHook(workerNodeKey, message)
+      // A replayed response can outlive its removed worker node.
+      // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+      if (workerNode != null) {
+        this.afterTaskExecutionHook(workerNodeKey, message)
+      }
       queueMicrotask(() => {
+        let currentWorkerNodeKey =
+          this.workerNodes[workerNodeKey] === workerNode
+            ? workerNodeKey
+            : this.workerNodes.indexOf(workerNode)
         // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
         workerNode?.emit('taskFinished', taskId)
-        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-        this.promiseResponseMap.delete(taskId!)
         this.checkAndEmitTaskExecutionFinishedEvents()
-        if (this.opts.enableTasksQueue === true && !this.destroying) {
-          if (
-            !this.isWorkerNodeBusy(workerNodeKey) &&
-            this.tasksQueueSize(workerNodeKey) > 0
+        if (currentWorkerNodeKey === -1 || !workerNode.info.ready) {
+          return
+        }
+        if (
+          this.started &&
+          this.opts.enableTasksQueue === true &&
+          !this.destroying
+        ) {
+          while (
+            !this.isWorkerNodeBusy(currentWorkerNodeKey) &&
+            this.tasksQueueSize(currentWorkerNodeKey) > 0
           ) {
-            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-            this.executeTask(workerNodeKey, this.dequeueTask(workerNodeKey)!)
+            if (
+              this.executeTask(
+                currentWorkerNodeKey,
+                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+                this.dequeueTask(currentWorkerNodeKey)!
+              )
+            ) {
+              break
+            }
+            currentWorkerNodeKey = this.workerNodes.indexOf(workerNode)
+            // Rejection callbacks can synchronously change pool and worker state.
+            /* eslint-disable @typescript-eslint/no-unnecessary-condition */
+            if (
+              currentWorkerNodeKey === -1 ||
+              !this.started ||
+              this.destroying ||
+              !workerNode.info.ready ||
+              !this.opts.enableTasksQueue
+            ) {
+              return
+            }
+            /* eslint-enable @typescript-eslint/no-unnecessary-condition */
           }
-          if (this.isWorkerNodeIdle(workerNodeKey)) {
+          if (this.isWorkerNodeIdle(currentWorkerNodeKey)) {
             workerNode.emit('idle', {
-              workerNodeKey,
+              workerNodeKey: currentWorkerNodeKey,
             })
           }
         }
@@ -2090,19 +2264,8 @@ export abstract class AbstractPool<
         timestamp,
         transferList,
       }
-      abortSignal?.addEventListener(
-        'abort',
-        () => {
-          this.workerNodes[workerNodeKey]?.emit('abortTask', {
-            taskId: task.taskId,
-            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-            workerId: this.getWorkerInfo(workerNodeKey)!.id!,
-          })
-        },
-        { once: true }
-      )
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      this.promiseResponseMap.set(task.taskId!, {
+
+      const promiseResponse: PromiseResponseWrapper<Response> = {
         reject,
         resolve,
         workerNodeKey,
@@ -2113,7 +2276,27 @@ export abstract class AbstractPool<
           }),
         }),
         abortSignal,
-      })
+      }
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      this.promiseResponseMap.set(task.taskId!, promiseResponse)
+      abortSignal?.addEventListener(
+        'abort',
+        () => {
+          const workerNodeKey = this.promiseResponseMap.get(
+            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+            task.taskId!
+          )?.workerNodeKey
+          if (workerNodeKey != null) {
+            const workerNode = this.workerNodes[workerNodeKey]
+            // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+            workerNode?.emit('abortTask', {
+              taskId: task.taskId,
+              workerId: workerNode.info.id,
+            })
+          }
+        },
+        { once: true }
+      )
       if (
         this.opts.enableTasksQueue === false ||
         (this.opts.enableTasksQueue === true &&
@@ -2193,34 +2376,112 @@ export abstract class AbstractPool<
     )
   }
 
-  private redistributeQueuedTasks (sourceWorkerNodeKey: number): void {
-    if (sourceWorkerNodeKey === -1 || this.cannotStealTask()) {
-      return
-    }
-    while (this.tasksQueueSize(sourceWorkerNodeKey) > 0) {
-      const destinationWorkerNodeKey = this.workerNodes.reduce(
-        (minWorkerNodeKey, workerNode, workerNodeKey, workerNodes) => {
-          if (workerNodeKey === sourceWorkerNodeKey || !workerNode.info.ready) {
-            return minWorkerNodeKey
-          }
-          if (minWorkerNodeKey === -1) {
-            return workerNodeKey
-          }
-          return workerNode.usage.tasks.queued <
-            workerNodes[minWorkerNodeKey].usage.tasks.queued
-            ? workerNodeKey
-            : minWorkerNodeKey
-        },
-        -1
+  private redistributeQueuedTasks (
+    sourceWorkerNode: IWorkerNode<Worker, Data>
+  ): void {
+    while (sourceWorkerNode.tasksQueue.size > 0) {
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      const task = sourceWorkerNode.dequeueTask()!
+      const { taskId } = task
+      if (taskId == null) {
+        continue
+      }
+      const promiseResponse = this.promiseResponseMap.get(taskId)
+      if (promiseResponse == null) {
+        continue
+      }
+      if (promiseResponse.abortSignal?.aborted === true) {
+        this.rejectTask(
+          taskId,
+          promiseResponse,
+          this.getAbortError(task.name ?? DEFAULT_TASK_NAME, taskId)
+        )
+        continue
+      }
+      if (!this.started || this.destroying) {
+        continue
+      }
+      const liveTaskFunctionsProperties = this.listTaskFunctionsProperties()
+      const workerNodeKeys = this.getTaskFunctionWorkerNodeKeys(
+        task.name,
+        liveTaskFunctionsProperties.length > 0
+          ? liveTaskFunctionsProperties
+          : (sourceWorkerNode.info.taskFunctionsProperties ??
+              liveTaskFunctionsProperties)
       )
+      const candidateCount = workerNodeKeys?.length ?? this.workerNodes.length
+      let destinationWorkerNodeKey = -1
+      let minimumQueuedTasks = Number.POSITIVE_INFINITY
+      for (let i = 0; i < candidateCount; i++) {
+        const candidateWorkerNodeKey =
+          workerNodeKeys == null ? i : workerNodeKeys[i]
+        if (candidateWorkerNodeKey >= this.workerNodes.length) {
+          continue
+        }
+        const candidateWorkerNode = this.workerNodes[candidateWorkerNodeKey]
+        if (!candidateWorkerNode.info.ready) {
+          continue
+        }
+        const queuedTasks = candidateWorkerNode.usage.tasks.queued
+        if (
+          queuedTasks < minimumQueuedTasks ||
+          (queuedTasks === minimumQueuedTasks &&
+            candidateWorkerNodeKey < destinationWorkerNodeKey)
+        ) {
+          destinationWorkerNodeKey = candidateWorkerNodeKey
+          minimumQueuedTasks = queuedTasks
+        }
+      }
       if (destinationWorkerNodeKey === -1) {
-        break
+        continue
+      }
+      if (this.opts.enableTasksQueue === true) {
+        this.handleTask(destinationWorkerNodeKey, task)
+      } else {
+        this.executeTask(destinationWorkerNodeKey, task)
+      }
+    }
+  }
+
+  private rejectTask (
+    taskId: `${string}-${string}-${string}-${string}-${string}`,
+    promiseResponse: PromiseResponseWrapper<Response>,
+    error: unknown
+  ): void {
+    if (this.promiseResponseMap.get(taskId) !== promiseResponse) {
+      return
+    }
+    this.promiseResponseMap.delete(taskId)
+    const { asyncResource, reject } = promiseResponse
+    try {
+      asyncResource != null
+        ? asyncResource.runInAsyncScope(reject, this.emitter, error)
+        : reject(error)
+    } finally {
+      asyncResource?.emitDestroy()
+    }
+  }
+
+  /**
+   * Rejects tasks still owned by a worker that cannot complete them.
+   * @param workerNodeTasks - The task wrappers owned by the failed worker node.
+   * @param error - The worker failure.
+   */
+  private rejectWorkerNodeTasks (
+    workerNodeTasks: Map<
+      `${string}-${string}-${string}-${string}-${string}`,
+      PromiseResponseWrapper<Response>
+    >,
+    error?: Error
+  ): void {
+    for (const [taskId, promiseResponse] of workerNodeTasks) {
+      if (
+        this.promiseResponseMap.get(taskId) === promiseResponse &&
+        promiseResponse.workerNodeKey === -1
+      ) {
+        error ??= new Error('Worker exited before completing the task')
+        this.rejectTask(taskId, promiseResponse, error)
       }
-      this.handleTask(
-        destinationWorkerNodeKey,
-        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-        this.dequeueTask(sourceWorkerNodeKey)!
-      )
     }
   }
 
@@ -2232,6 +2493,13 @@ export abstract class AbstractPool<
     const workerNodeKey = this.workerNodes.indexOf(workerNode)
     if (workerNodeKey !== -1) {
       this.workerNodes.splice(workerNodeKey, 1)
+      for (const promiseResponse of this.promiseResponseMap.values()) {
+        if (promiseResponse.workerNodeKey === workerNodeKey) {
+          promiseResponse.workerNodeKey = -1
+        } else if (promiseResponse.workerNodeKey > workerNodeKey) {
+          --promiseResponse.workerNodeKey
+        }
+      }
       this.workerChoiceStrategiesContext?.remove(workerNodeKey)
       workerNode.info.dynamic &&
         this.checkAndEmitDynamicWorkerDestructionEvents()
@@ -2629,6 +2897,23 @@ export abstract class AbstractPool<
     }
   }
 
+  /**
+   * Updates task ownership before dispatch or synchronous queue events.
+   * @param workerNodeKey - The destination worker node key.
+   * @param task - The task being moved.
+   */
+  private updateTaskWorkerNodeKey (
+    workerNodeKey: number,
+    task: Task<Data>
+  ): void {
+    if (task.taskId != null) {
+      const promiseResponse = this.promiseResponseMap.get(task.taskId)
+      if (promiseResponse != null) {
+        promiseResponse.workerNodeKey = workerNodeKey
+      }
+    }
+  }
+
   private readonly workerNodeStealTask = (
     workerNodeKey: number
   ): Task<Data> | undefined => {
index 5632c77a795370077c5b461b8f5bfe230face197..8e9ad71ac4d9325595c429a10e30fc493ed7855c 100644 (file)
@@ -171,26 +171,34 @@ export class WorkerNode<Worker extends IWorker, Data = unknown>
 
   /** @inheritdoc */
   public async terminate (): Promise<void> {
-    const waitWorkerExit = new Promise<void>(resolve => {
-      this.registerOnceWorkerEventHandler('exit', () => {
-        resolve()
-      })
-    })
     this.closeMessageChannel()
     this.removeAllListeners()
     switch (this.info.type) {
-      case WorkerTypes.cluster:
-        this.registerOnceWorkerEventHandler('disconnect', () => {
-          this.worker.kill?.()
+      case WorkerTypes.cluster: {
+        if (this.worker.isDead?.() === true) {
+          break
+        }
+        const waitWorkerExit = new Promise<void>(resolve => {
+          this.registerOnceWorkerEventHandler('exit', () => {
+            resolve()
+          })
         })
-        this.worker.disconnect?.()
+        if (this.worker.isConnected?.() === true) {
+          this.registerOnceWorkerEventHandler('disconnect', () => {
+            this.worker.kill?.()
+          })
+          this.worker.disconnect?.()
+        } else {
+          this.worker.kill?.()
+        }
+        await waitWorkerExit
         break
+      }
       case WorkerTypes.thread:
         this.worker.unref?.()
         await this.worker.terminate?.()
         break
     }
-    await waitWorkerExit
     this.worker.removeAllListeners()
   }
 
index 7223fb2216be785f3f1665021569ce48052e9b8c..e4a26efe279b70bc9e3aad5aa31c912eab61825b 100644 (file)
@@ -151,6 +151,14 @@ export interface IWorker extends EventEmitter {
    * Cluster worker id.
    */
   readonly id?: number
+  /**
+   * Whether the cluster worker is connected to its primary process.
+   */
+  readonly isConnected?: () => boolean
+  /**
+   * Whether the cluster worker process has exited.
+   */
+  readonly isDead?: () => boolean
   /**
    * Cluster worker kill.
    */
index 9c17112b14042bbbf7ab82ae39e006af6f1d0dc0..ed5ff6ef6ecc81ab35678b9d85f35ebfd013b712 100644 (file)
@@ -98,9 +98,9 @@ export interface PromiseResponseWrapper<Response = unknown> {
    */
   readonly resolve: (value: PromiseLike<Response> | Response) => void
   /**
-   * The worker node key executing the task.
+   * The worker node key owning the task, or -1 after its removal.
    */
-  readonly workerNodeKey: number
+  workerNodeKey: number
 }
 
 /**
index 8d8cdf785fdfb883b0a28474e6d91ea75f64193c..aac84a21f05fcacb5f937097634f282cdb00aa58 100644 (file)
@@ -137,7 +137,6 @@ describe('Fixed cluster pool test suite', () => {
       expect(workerNode.usage.tasks.executing).toBeLessThanOrEqual(
         numberOfWorkers * maxMultiplier
       )
-      expect(workerNode.usage.tasks.executed).toBe(maxMultiplier)
       expect(workerNode.usage.tasks.queued).toBe(0)
       expect(workerNode.usage.tasks.maxQueued).toBe(
         maxMultiplier - queuePool.opts.tasksQueueOptions.concurrency
index 33b7dd5f6340f56b8cfd26765ee0d5f054f67604..ef4c9e68973d97dbc15c192664565eefcc311201 100644 (file)
@@ -136,7 +136,6 @@ describe('Fixed thread pool test suite', () => {
       expect(workerNode.usage.tasks.executing).toBeLessThanOrEqual(
         numberOfThreads * maxMultiplier
       )
-      expect(workerNode.usage.tasks.executed).toBe(maxMultiplier)
       expect(workerNode.usage.tasks.queued).toBe(0)
       expect(workerNode.usage.tasks.maxQueued).toBe(
         maxMultiplier - queuePool.opts.tasksQueueOptions.concurrency
@@ -325,26 +324,6 @@ describe('Fixed thread pool test suite', () => {
     expect(poolDestroy).toBe(1)
   })
 
-  it('Verify that thread pool options are checked', async () => {
-    const workerFilePath = './tests/worker-files/thread/testWorker.mjs'
-    let pool = new FixedThreadPool(numberOfThreads, workerFilePath)
-    expect(pool.opts.workerOptions).toBeUndefined()
-    await sleep(500)
-    await pool.destroy()
-    pool = new FixedThreadPool(numberOfThreads, workerFilePath, {
-      workerOptions: {
-        env: { TEST: 'test' },
-        name: 'test',
-      },
-    })
-    expect(pool.opts.workerOptions).toStrictEqual({
-      env: { TEST: 'test' },
-      name: 'test',
-    })
-    await sleep(500)
-    await pool.destroy()
-  })
-
   it('Verify destroyWorkerNode()', async () => {
     const workerFilePath = './tests/worker-files/thread/testWorker.mjs'
     const pool = new FixedThreadPool(numberOfThreads, workerFilePath)
diff --git a/tests/pools/worker-exit.test.mjs b/tests/pools/worker-exit.test.mjs
new file mode 100644 (file)
index 0000000..275fd02
--- /dev/null
@@ -0,0 +1,1682 @@
+// Required to exercise synchronous destruction from task async resource callbacks.
+// eslint-disable-next-line n/no-unsupported-features/node-builtins
+import { createHook } from 'node:async_hooks'
+import { execFile } from 'node:child_process'
+import { once } from 'node:events'
+import { promisify } from 'node:util'
+import { MessageChannel } from 'node:worker_threads'
+import { describe, expect, it } from 'vitest'
+
+import {
+  DynamicClusterPool,
+  DynamicThreadPool,
+  FixedClusterPool,
+  FixedThreadPool,
+  WorkerChoiceStrategies,
+} from '../../lib/index.mjs'
+
+const bounded = async promise => {
+  let timeout
+  try {
+    return await Promise.race([
+      promise,
+      new Promise((resolve, reject) => {
+        timeout = setTimeout(
+          () => reject(new Error('Task remained pending')),
+          5000
+        )
+      }),
+    ])
+  } finally {
+    clearTimeout(timeout)
+  }
+}
+
+const send = (workerNode, message) => {
+  const workerMessage = { ...message, workerId: workerNode.info.id }
+  if (workerNode.worker.postMessage != null) {
+    workerNode.worker.postMessage(workerMessage)
+  } else {
+    workerNode.worker.send(workerMessage)
+  }
+}
+
+const started = (workerNode, id) => {
+  return new Promise(resolve => {
+    const listener = message => {
+      if (message.started === id) {
+        workerNode.worker.off('message', listener)
+        resolve()
+      }
+    }
+    workerNode.worker.on('message', listener)
+  })
+}
+
+const cleanupRegressionPool = async (pool, capturedNodes) => {
+  pool.opts.restartWorkerOnError = false
+  // Do not flush held tasks or rely on WorkerNode.terminate during failure cleanup.
+  await Promise.all(
+    [...new Set([...capturedNodes, ...pool.workerNodes])].map(async node => {
+      if (node.worker.process != null) {
+        if (!node.worker.isDead()) {
+          const exit = new Promise(resolve => node.worker.once('exit', resolve))
+          node.worker.process.kill()
+          await bounded(exit)
+        }
+      } else {
+        await bounded(node.worker.terminate())
+      }
+    })
+  )
+  await bounded(pool.destroy())
+}
+
+for (const [workerType, PoolClass, workerFile] of [
+  ['thread', FixedThreadPool, './tests/worker-files/thread/crashWorker.mjs'],
+  ['cluster', FixedClusterPool, './tests/worker-files/cluster/crashWorker.cjs'],
+]) {
+  describe(`${workerType} worker exit`, { retry: 0 }, () => {
+    const createPool = async (size = 1, options = {}) => {
+      const pool = new PoolClass(size, workerFile, {
+        workerChoiceStrategy: WorkerChoiceStrategies.ROUND_ROBIN,
+        ...options,
+      })
+      pool.emitter.on('error', () => {})
+      if (!pool.info.ready) await bounded(once(pool.emitter, 'ready'))
+      return pool
+    }
+
+    for (const restartWorkerOnError of [true, false]) {
+      it(`rejects a crashed worker's running and stranded queued tasks (restart: ${restartWorkerOnError})`, async () => {
+        const pool = await createPool(1, {
+          enableTasksQueue: true,
+          restartWorkerOnError,
+        })
+        try {
+          const workerNode = pool.workerNodes[0]
+          const running = started(workerNode, 'running')
+          const results = [
+            pool.execute({ id: 'running', wait: true }).catch(error => error),
+            pool.execute({ id: 'queued' }).catch(error => error),
+          ]
+          await bounded(running)
+          const exit = new Promise(resolve =>
+            workerNode.worker.once('exit', resolve)
+          )
+          send(workerNode, { crash: true })
+          const outcomes = await bounded(Promise.all(results))
+          expect(outcomes[0]).toBeInstanceOf(Error)
+          expect(outcomes[1]).toBeInstanceOf(Error)
+          await bounded(exit)
+          expect(pool.promiseResponseMap.size).toBe(0)
+        } finally {
+          await pool.destroy()
+        }
+      })
+    }
+
+    for (const exitCode of [0, 1]) {
+      it(`rejects a task when its worker exits with code ${exitCode} without an error event`, async () => {
+        const pool = await createPool()
+        try {
+          const result = await bounded(
+            pool.execute({ exitCode }).catch(error => error)
+          )
+          expect(result).toBeInstanceOf(Error)
+          expect(pool.promiseResponseMap.size).toBe(0)
+        } finally {
+          await pool.destroy()
+        }
+      })
+    }
+
+    it('can destroy the pool from the configured exit handler', async () => {
+      let destruction
+      const pool = await createPool(1, {
+        exitHandler () {
+          destruction ??= pool.destroy()
+        },
+      })
+      try {
+        expect(
+          await bounded(pool.execute({ exitCode: 1 }).catch(error => error))
+        ).toBeInstanceOf(Error)
+        await bounded(destruction)
+        expect(pool.promiseResponseMap.size).toBe(0)
+        expect(pool.info.started).toBe(false)
+      } finally {
+        if (pool.info.started) await bounded(pool.destroy())
+      }
+    })
+
+    it('can destroy the pool from a task async resource before callback', async () => {
+      const pool = await createPool()
+      const taskResources = new Set()
+      let destruction
+      const hook = createHook({
+        before (asyncId) {
+          if (taskResources.has(asyncId) && destruction == null) {
+            destruction = pool.destroy()
+          }
+        },
+        init (asyncId, type) {
+          if (type === 'poolifier:task') taskResources.add(asyncId)
+        },
+      })
+      hook.enable()
+      try {
+        expect(
+          await bounded(pool.execute({ exitCode: 1 }).catch(error => error))
+        ).toBeInstanceOf(Error)
+        await bounded(destruction)
+        expect(pool.promiseResponseMap.size).toBe(0)
+        expect(pool.info.started).toBe(false)
+      } finally {
+        hook.disable()
+        if (pool.info.started) await bounded(pool.destroy())
+      }
+    })
+
+    it('can destroy the pool from backPressureEnd during exit redistribution', async () => {
+      const pool = await createPool(2, {
+        enableTasksQueue: true,
+        tasksQueueOptions: {
+          concurrency: 1,
+          size: 1,
+          tasksStealingOnBackPressure: false,
+          taskStealing: false,
+        },
+      })
+      let destruction
+      try {
+        await pool.execute({ warmup: true })
+        const [source, destination] = pool.workerNodes
+        const sourceStarted = started(source, 'source')
+        const destinationStarted = started(destination, 'destination')
+        const failed = pool
+          .execute({ id: 'source', wait: true })
+          .catch(error => error)
+        pool.execute({ id: 'destination', wait: true }).catch(() => {})
+        const backPressure = once(pool.emitter, 'backPressure')
+        pool.execute({ id: 'source-queued' }).catch(() => {})
+        pool.execute({ id: 'destination-queued' }).catch(() => {})
+        await bounded(
+          Promise.all([sourceStarted, destinationStarted, backPressure])
+        )
+        pool.emitter.once('backPressureEnd', () => {
+          destruction = pool.destroy()
+        })
+        send(source, { exitCode: 1 })
+        expect(await bounded(failed)).toBeInstanceOf(Error)
+        await bounded(destruction)
+        expect(pool.info.started).toBe(false)
+      } finally {
+        if (pool.info.started) await bounded(pool.destroy())
+      }
+    })
+
+    it('keeps healthy tasks and redistributed queued tasks after an earlier worker exits', async () => {
+      const pool = await createPool(2, {
+        enableTasksQueue: true,
+        tasksQueueOptions: {
+          tasksStealingOnBackPressure: false,
+          taskStealing: false,
+        },
+      })
+      try {
+        await pool.execute({ warmup: true })
+        const [failedNode, healthyNode] = pool.workerNodes
+        const failedStarted = started(failedNode, 'failed')
+        const healthyStarted = started(healthyNode, 'healthy')
+        const failed = pool
+          .execute({ id: 'failed', wait: true })
+          .catch(error => error)
+        const healthyData = { id: 'healthy', wait: true }
+        const healthy = pool.execute(healthyData)
+        const queuedData = { id: 'redistributed' }
+        const queued = pool.execute(queuedData)
+        await bounded(Promise.all([failedStarted, healthyStarted]))
+        const exit = new Promise(resolve =>
+          failedNode.worker.once('exit', resolve)
+        )
+        send(failedNode, { crash: true })
+        expect(await bounded(failed)).toBeInstanceOf(Error)
+        await bounded(exit)
+        send(healthyNode, { release: 'healthy' })
+        expect(await bounded(healthy)).toStrictEqual(healthyData)
+        expect(await bounded(queued)).toStrictEqual(queuedData)
+        expect(healthyNode.usage.tasks.executing).toBe(0)
+      } finally {
+        await pool.destroy()
+      }
+    })
+
+    for (const [failure, message] of [
+      ['crash', { crash: true }],
+      ['silent exit', { exitCode: 1 }],
+    ]) {
+      it(`preserves sparse affinity against live worker indices after a ${failure}`, async () => {
+        const executions = []
+        const completions = []
+        class AffinityPool extends PoolClass {
+          afterTaskExecutionHook (workerNodeKey, response) {
+            if (response.data?.id === 'affinity') {
+              completions.push({
+                workerId: this.workerNodes[workerNodeKey].info.id,
+                workerNodeKey,
+              })
+            }
+            super.afterTaskExecutionHook(workerNodeKey, response)
+          }
+
+          beforeTaskExecutionHook (workerNodeKey, task) {
+            if (task.data?.id === 'affinity') {
+              executions.push({
+                workerId: this.workerNodes[workerNodeKey].info.id,
+                workerNodeKey,
+              })
+            }
+            super.beforeTaskExecutionHook(workerNodeKey, task)
+          }
+        }
+        const pool = new AffinityPool(3, workerFile, {
+          enableTasksQueue: true,
+          tasksQueueOptions: {
+            concurrency: 1,
+            tasksStealingOnBackPressure: false,
+            taskStealing: false,
+          },
+          workerChoiceStrategy: WorkerChoiceStrategies.ROUND_ROBIN,
+        })
+        pool.emitter.on('error', () => {})
+        try {
+          if (!pool.info.ready) await bounded(once(pool.emitter, 'ready'))
+          await bounded(
+            pool.addTaskFunction('affinityEcho', {
+              taskFunction: data => data,
+              workerNodeKeys: [0, 2],
+            })
+          )
+          const [source, firstHealthy, secondHealthy] = pool.workerNodes
+          const healthyData = [
+            { id: 'first-healthy', wait: true },
+            { id: 'second-healthy', wait: true },
+          ]
+          const running = [
+            started(firstHealthy, 'first-healthy'),
+            started(secondHealthy, 'second-healthy'),
+            started(source, 'source'),
+          ]
+          const healthy = healthyData.map(data => pool.execute(data))
+          const failed = pool
+            .execute({ id: 'source', wait: true })
+            .catch(error => error)
+          await bounded(Promise.all(running))
+          const queuedData = [{ id: 'first-queued' }, { id: 'second-queued' }]
+          const queued = queuedData.map(data => pool.execute(data))
+          const affinityData = { id: 'affinity' }
+          const affinity = pool
+            .execute(affinityData, 'affinityEcho')
+            .catch(error => error)
+          expect(source.tasksQueue.size).toBe(1)
+          const exit = new Promise(resolve =>
+            source.worker.once('exit', resolve)
+          )
+          send(source, message)
+          await bounded(exit)
+          expect(await bounded(failed)).toBeInstanceOf(Error)
+          send(firstHealthy, { release: 'first-healthy' })
+          send(secondHealthy, { release: 'second-healthy' })
+          expect(await bounded(Promise.all(healthy))).toStrictEqual(healthyData)
+          expect(await bounded(Promise.all(queued))).toStrictEqual(queuedData)
+          expect(await bounded(affinity)).toStrictEqual(affinityData)
+          expect(executions).toHaveLength(1)
+          expect([0, 2]).toContain(executions[0].workerNodeKey)
+          expect(executions[0].workerId).toBe(
+            pool.workerNodes[executions[0].workerNodeKey].info.id
+          )
+          expect(completions).toStrictEqual(executions)
+          expect(pool.promiseResponseMap.size).toBe(0)
+        } finally {
+          await pool.destroy()
+        }
+      })
+    }
+
+    it('rejects a queued default task pinned to the exited worker', async () => {
+      const pool = await createPool(2, {
+        enableTasksQueue: true,
+        tasksQueueOptions: {
+          concurrency: 1,
+          tasksStealingOnBackPressure: false,
+          taskStealing: false,
+        },
+      })
+      try {
+        await bounded(
+          pool.addTaskFunction('pinnedEcho', {
+            taskFunction: data => data,
+            workerNodeKeys: [1],
+          })
+        )
+        const source = pool.workerNodes[1]
+        const running = started(source, 'running')
+        const runningResult = pool
+          .execute({ id: 'running', wait: true })
+          .catch(error => error)
+        await bounded(running)
+        await bounded(pool.setDefaultTaskFunction('pinnedEcho'))
+        const queuedResult = pool
+          .execute({ id: 'queued' })
+          .catch(error => error)
+        expect(source.tasksQueue.size).toBe(1)
+        const exit = new Promise(resolve => source.worker.once('exit', resolve))
+        send(source, { exitCode: 0 })
+        const outcomes = await bounded(
+          Promise.all([runningResult, queuedResult])
+        )
+        expect(outcomes[0]).toBeInstanceOf(Error)
+        expect(outcomes[1]).toBeInstanceOf(Error)
+        await bounded(exit)
+      } finally {
+        await pool.destroy()
+      }
+    })
+
+    it('redistributes a later eligible task after a stranded pinned task', async () => {
+      const pool = await createPool(2, {
+        enableTasksQueue: true,
+        tasksQueueOptions: {
+          concurrency: 1,
+          tasksStealingOnBackPressure: false,
+          taskStealing: false,
+        },
+      })
+      try {
+        await bounded(
+          pool.addTaskFunction('pinnedEcho', {
+            taskFunction: data => data,
+            workerNodeKeys: [1],
+          })
+        )
+        const source = pool.workerNodes[1]
+        const running = started(source, 'running')
+        const runningResult = pool
+          .execute({ id: 'running', wait: true })
+          .catch(error => error)
+        await bounded(running)
+        const pinnedResult = pool
+          .execute({ id: 'pinned' }, 'pinnedEcho')
+          .catch(error => error)
+        const quickData = { id: 'quick' }
+        expect(await bounded(pool.execute(quickData))).toStrictEqual(quickData)
+        const eligibleData = { id: 'eligible' }
+        const eligibleResult = pool.execute(eligibleData)
+        expect(source.tasksQueue.size).toBe(2)
+        const exit = new Promise(resolve => source.worker.once('exit', resolve))
+        send(source, { exitCode: 0 })
+        const outcomes = await bounded(
+          Promise.all([runningResult, pinnedResult, eligibleResult])
+        )
+        expect(outcomes[0]).toBeInstanceOf(Error)
+        expect(outcomes[1]).toBeInstanceOf(Error)
+        expect(outcomes[2]).toStrictEqual(eligibleData)
+        await bounded(exit)
+      } finally {
+        await pool.destroy()
+      }
+    })
+
+    it('rejects a stolen task only when its actual execution worker crashes', async () => {
+      const pool = await createPool(2, {
+        enableTasksQueue: true,
+        tasksQueueOptions: {
+          tasksStealingOnBackPressure: false,
+          taskStealing: false,
+        },
+      })
+      try {
+        await pool.execute({ warmup: true })
+        const [source, destination] = pool.workerNodes
+        const sourceStarted = started(source, 'source')
+        const destinationStarted = started(destination, 'destination')
+        const sourceResult = pool
+          .execute({ id: 'source', wait: true })
+          .catch(error => error)
+        const destinationData = { id: 'destination', wait: true }
+        const destinationResult = pool.execute(destinationData)
+        const stolenStarted = started(destination, 'stolen')
+        const stolenResult = pool
+          .execute({ id: 'stolen', wait: true })
+          .catch(error => error)
+        await bounded(Promise.all([sourceStarted, destinationStarted]))
+        pool.stealTask(source, 1)
+        send(destination, { release: 'destination' })
+        expect(await bounded(destinationResult)).toStrictEqual(destinationData)
+        await bounded(stolenStarted)
+        const sourceExit = new Promise(resolve =>
+          source.worker.once('exit', resolve)
+        )
+        send(source, { crash: true })
+        expect(await bounded(sourceResult)).toBeInstanceOf(Error)
+        await bounded(sourceExit)
+        const destinationExit = new Promise(resolve =>
+          destination.worker.once('exit', resolve)
+        )
+        send(destination, { crash: true })
+        expect(await bounded(stolenResult)).toBeInstanceOf(Error)
+        await bounded(destinationExit)
+      } finally {
+        await pool.destroy()
+      }
+    })
+
+    it('can abort all redistributing tasks from backPressureEnd', async () => {
+      const pool = await createPool(2, {
+        enableTasksQueue: true,
+        tasksQueueOptions: {
+          concurrency: 1,
+          size: 2,
+          tasksStealingOnBackPressure: false,
+          taskStealing: false,
+        },
+      })
+      try {
+        await pool.execute({ warmup: true })
+        const [source, destination] = pool.workerNodes
+        const sourceStarted = started(source, 'source')
+        const destinationStarted = started(destination, 'destination')
+        const sourceResult = pool
+          .execute({ id: 'source', wait: true })
+          .catch(error => error)
+        const destinationData = { id: 'destination', wait: true }
+        const destinationResult = pool.execute(destinationData)
+        const controller = new AbortController()
+        const backPressure = once(pool.emitter, 'backPressure')
+        const firstController = new AbortController()
+        let firstAbortedError
+        const firstAbortedResult = pool
+          .execute({ id: 'first-aborted' }, undefined, firstController.signal)
+          .catch(error => {
+            firstAbortedError = error
+            return error
+          })
+        const firstHealthyData = { id: 'first-healthy' }
+        const firstHealthyResult = pool.execute(firstHealthyData)
+        let abortedError
+        const abortedResult = pool
+          .execute({ id: 'aborted' }, undefined, controller.signal)
+          .catch(error => {
+            abortedError = error
+            return error
+          })
+        const healthyData = { id: 'healthy' }
+        const healthyResult = pool.execute(healthyData)
+        await bounded(
+          Promise.all([sourceStarted, destinationStarted, backPressure])
+        )
+        expect(source.tasksQueue.size).toBe(2)
+        expect(destination.tasksQueue.size).toBe(2)
+        pool.emitter.once('backPressureEnd', () => {
+          firstController.abort()
+          controller.abort()
+        })
+        const exit = new Promise(resolve => source.worker.once('exit', resolve))
+        send(source, { crash: true })
+        expect(await bounded(sourceResult)).toBeInstanceOf(Error)
+        expect(controller.signal.aborted).toBe(true)
+        expect(firstController.signal.aborted).toBe(true)
+        expect(firstAbortedError).toBeInstanceOf(Error)
+        expect(abortedError).toBeInstanceOf(Error)
+        send(destination, { release: 'destination' })
+        expect(await bounded(abortedResult)).toBeInstanceOf(Error)
+        expect(await bounded(firstAbortedResult)).toBeInstanceOf(Error)
+        expect(await bounded(firstHealthyResult)).toStrictEqual(
+          firstHealthyData
+        )
+        expect(await bounded(destinationResult)).toStrictEqual(destinationData)
+        expect(await bounded(healthyResult)).toStrictEqual(healthyData)
+        await bounded(exit)
+        expect(pool.promiseResponseMap.size).toBe(0)
+      } finally {
+        await pool.destroy()
+      }
+    })
+
+    it('skips a detached queued task aborted by an earlier migration hook', async () => {
+      const controller = new AbortController()
+      const executions = []
+      class AbortingPool extends PoolClass {
+        beforeTaskExecutionHook (workerNodeKey, task) {
+          executions.push(task.data?.id)
+          if (task.data?.id === 'first-migrated') controller.abort()
+          super.beforeTaskExecutionHook(workerNodeKey, task)
+        }
+      }
+      const pool = new AbortingPool(2, workerFile, {
+        enableTasksQueue: true,
+        tasksQueueOptions: {
+          concurrency: 1,
+          tasksStealingOnBackPressure: false,
+          taskStealing: false,
+        },
+        workerChoiceStrategy: WorkerChoiceStrategies.ROUND_ROBIN,
+      })
+      pool.emitter.on('error', () => {})
+      try {
+        if (!pool.info.ready) await bounded(once(pool.emitter, 'ready'))
+        await bounded(pool.execute({ warmup: true }))
+        const [source, destination] = pool.workerNodes
+        const sourceStarted = started(source, 'source')
+        const destinationStarted = started(destination, 'destination')
+        const failed = pool
+          .execute({ id: 'source', wait: true })
+          .catch(error => error)
+        const healthyData = { id: 'destination', wait: true }
+        const healthy = pool.execute(healthyData)
+        const firstData = { id: 'first-migrated' }
+        const first = pool.execute(firstData)
+        const quickData = { id: 'healthy-queued' }
+        const quick = pool.execute(quickData)
+        const later = pool
+          .execute({ id: 'later-migrated' }, undefined, controller.signal)
+          .catch(error => error)
+        await bounded(Promise.all([sourceStarted, destinationStarted]))
+        expect(source.tasksQueue.size).toBe(2)
+        send(destination, { release: 'destination' })
+        expect(await bounded(healthy)).toStrictEqual(healthyData)
+        expect(await bounded(quick)).toStrictEqual(quickData)
+        const exit = new Promise(resolve => source.worker.once('exit', resolve))
+        send(source, { exitCode: 1 })
+        await bounded(exit)
+        expect(await bounded(failed)).toBeInstanceOf(Error)
+        expect(await bounded(first)).toStrictEqual(firstData)
+        expect(await bounded(later)).toBeInstanceOf(Error)
+        expect(controller.signal.aborted).toBe(true)
+        expect(executions.filter(id => id === 'first-migrated')).toHaveLength(1)
+        expect(executions).not.toContain('later-migrated')
+        expect(source.tasksQueue.size).toBe(0)
+        expect(pool.promiseResponseMap.size).toBe(0)
+      } finally {
+        await pool.destroy()
+      }
+    })
+
+    it('routes a queued task abort to its new owner after redistribution and index shifting', async () => {
+      const pool = await createPool(2, {
+        enableTasksQueue: true,
+        tasksQueueOptions: {
+          tasksStealingOnBackPressure: false,
+          taskStealing: false,
+        },
+      })
+      try {
+        await pool.execute({ warmup: true })
+        const [source, destination] = pool.workerNodes
+        const sourceStarted = started(source, 'source')
+        const destinationStarted = started(destination, 'destination')
+        const sourceResult = pool
+          .execute({ id: 'source', wait: true })
+          .catch(error => error)
+        const destinationData = { id: 'destination', wait: true }
+        const destinationResult = pool.execute(destinationData)
+        const controller = new AbortController()
+        const queued = pool
+          .execute({ id: 'aborted' }, undefined, controller.signal)
+          .catch(error => error)
+        await bounded(Promise.all([sourceStarted, destinationStarted]))
+        const exit = new Promise(resolve => source.worker.once('exit', resolve))
+        send(source, { crash: true })
+        expect(await bounded(sourceResult)).toBeInstanceOf(Error)
+        await bounded(exit)
+        const reason = new Error('Cancel redistributed task')
+        controller.abort(reason)
+        expect(await bounded(queued)).toBeInstanceOf(Error)
+        send(destination, { release: 'destination' })
+        expect(await bounded(destinationResult)).toStrictEqual(destinationData)
+        expect(destination.tasksQueue.size).toBe(0)
+      } finally {
+        await pool.destroy()
+      }
+    })
+    it('can destroy the pool immediately after catching a worker failure', async () => {
+      const pool = await createPool()
+      const workerNode = pool.workerNodes[0]
+      const running = started(workerNode, 'running')
+      const result = pool
+        .execute({ id: 'running', wait: true })
+        .catch(error => error)
+      await bounded(running)
+      send(workerNode, { crash: true })
+      expect(await bounded(result)).toBeInstanceOf(Error)
+      await bounded(pool.destroy())
+      expect(pool.info.started).toBe(false)
+    })
+
+    it('can destroy the pool from a busyEnd listener after a worker exits', async () => {
+      const pool = await createPool()
+      let destruction
+      pool.emitter.once('busyEnd', () => {
+        destruction = pool.destroy()
+      })
+      expect(
+        await bounded(pool.execute({ exitCode: 1 }).catch(error => error))
+      ).toBeInstanceOf(Error)
+      await bounded(destruction)
+      expect(pool.info.started).toBe(false)
+    })
+
+    it('does not attribute intentionally terminated tasks to a later crashed worker', async () => {
+      const pool = await createPool(2)
+      try {
+        await pool.execute({ warmup: true })
+        const [removed, survivor] = pool.workerNodes
+        const removedStarted = started(removed, 'removed')
+        const survivorStarted = started(survivor, 'survivor')
+        let removedTaskSettled = false
+        pool
+          .execute({ id: 'removed', wait: true })
+          .finally(() => {
+            removedTaskSettled = true
+          })
+          .catch(() => {})
+        const survivorResult = pool
+          .execute({ id: 'survivor', wait: true })
+          .catch(error => error)
+        await bounded(Promise.all([removedStarted, survivorStarted]))
+        await bounded(pool.destroyWorkerNode(0))
+        send(survivor, { crash: true })
+        expect(await bounded(survivorResult)).toBeInstanceOf(Error)
+        expect(removedTaskSettled).toBe(false)
+      } finally {
+        await pool.destroy()
+      }
+    })
+
+    if (workerType === 'thread') {
+      it('settles an initial native transfer failure without retaining execution usage', async () => {
+        const pool = await createPool()
+        const channel = new MessageChannel()
+        const transferredPort = structuredClone(channel.port1, {
+          transfer: [channel.port1],
+        })
+        try {
+          await bounded(pool.addTaskFunction('echo', data => ({ id: data.id })))
+          const workerNode = pool.workerNodes[0]
+          const error = await bounded(
+            pool
+              .execute({ port: channel.port1 }, 'echo', undefined, [
+                channel.port1,
+              ])
+              .catch(error => error)
+          )
+          expect(error).toBeInstanceOf(Error)
+          expect(error.name).toBe('DataCloneError')
+          expect(pool.promiseResponseMap.size).toBe(0)
+          expect(workerNode.usage.tasks.executing).toBe(0)
+          expect(
+            workerNode.getTaskFunctionWorkerUsage('echo').tasks.executing
+          ).toBe(0)
+          const data = { id: 'followup' }
+          expect(await bounded(pool.execute(data, 'echo'))).toStrictEqual(data)
+        } finally {
+          channel.port1.close()
+          channel.port2.close()
+          transferredPort.close()
+          if (pool.info.started) await bounded(pool.destroy())
+        }
+      })
+
+      it('preserves a buffered response when a queued transfer fails during exit migration', async () => {
+        const pool = await createPool(2, {
+          enableTasksQueue: true,
+          restartWorkerOnError: false,
+          tasksQueueOptions: {
+            concurrency: 1,
+            tasksStealingOnBackPressure: false,
+            taskStealing: false,
+          },
+        })
+        const channel = new MessageChannel()
+        let transferredPort
+        const source = pool.workerNodes[0]
+        try {
+          await bounded(
+            pool.addTaskFunction('migratingEcho', {
+              taskFunction: data => ({ id: data.id }),
+              workerNodeKeys: [0],
+            })
+          )
+          await bounded(pool.execute({ warmup: true }))
+          const destination = pool.workerNodes[1]
+          source.messageChannel.port1.off('message', pool.workerMessageListener)
+          const status = new Int32Array(new SharedArrayBuffer(4))
+          const response = { completed: true }
+          const result = pool.execute({
+            exitAfterResponse: status.buffer,
+            response,
+          })
+          const bad = pool
+            .execute({ port: channel.port1 }, 'migratingEcho', undefined, [
+              channel.port1,
+            ])
+            .catch(error => error)
+          const laterData = { id: 'later' }
+          const later = pool.execute(laterData, 'migratingEcho')
+          expect(source.tasksQueue.size).toBe(2)
+          transferredPort = structuredClone(channel.port1, {
+            transfer: [channel.port1],
+          })
+          const exit = new Promise(resolve =>
+            source.worker.once('exit', resolve)
+          )
+          Atomics.wait(status, 0, 0, 5000)
+          expect(Atomics.load(status, 0)).toBe(1)
+          const outcomes = await bounded(Promise.all([result, bad, later]))
+          expect(outcomes[0]).toStrictEqual(response)
+          expect(outcomes[1]).toBeInstanceOf(Error)
+          expect(outcomes[1].name).toBe('DataCloneError')
+          expect(outcomes[2]).toStrictEqual(laterData)
+          await bounded(exit)
+          expect(pool.promiseResponseMap.size).toBe(0)
+          expect(source.tasksQueue.size).toBe(0)
+          expect(destination.usage.tasks.executing).toBe(0)
+          expect(
+            destination.getTaskFunctionWorkerUsage('migratingEcho').tasks
+              .executing
+          ).toBe(0)
+        } finally {
+          channel.port1.close()
+          channel.port2.close()
+          transferredPort?.close()
+          source.messageChannel.port1.close()
+          source.messageChannel.port2.close()
+          if (pool.info.started) await bounded(pool.destroy())
+        }
+      })
+
+      it('does not wait for a taskFinished event for a failed transfer during destroy', async () => {
+        const pool = await createPool(1, {
+          enableTasksQueue: true,
+          tasksQueueOptions: {
+            concurrency: 1,
+            tasksFinishedTimeout: 30000,
+            tasksStealingOnBackPressure: false,
+            taskStealing: false,
+          },
+        })
+        const channel = new MessageChannel()
+        let transferredPort
+        try {
+          const workerNode = pool.workerNodes[0]
+          const runningStarted = started(workerNode, 'running')
+          pool.execute({ id: 'running', wait: true }).catch(() => {})
+          await bounded(runningStarted)
+          const bad = pool
+            .execute({ port: channel.port1 }, undefined, undefined, [
+              channel.port1,
+            ])
+            .catch(error => error)
+          transferredPort = structuredClone(channel.port1, {
+            transfer: [channel.port1],
+          })
+          await bounded(pool.destroy())
+          const error = await bounded(bad)
+          expect(error).toBeInstanceOf(Error)
+          expect(error.name).toBe('DataCloneError')
+          expect(pool.info.started).toBe(false)
+        } finally {
+          channel.port1.close()
+          channel.port2.close()
+          transferredPort?.close()
+          if (pool.info.started) await bounded(pool.destroy())
+        }
+      })
+
+      for (const destinationBusy of [false, true]) {
+        it(`isolates a queued transfer failure after worker exit (destination busy: ${destinationBusy})`, async () => {
+          const pool = await createPool(2, {
+            enableTasksQueue: true,
+            restartWorkerOnError: false,
+            tasksQueueOptions: {
+              concurrency: 1,
+              tasksStealingOnBackPressure: false,
+              taskStealing: false,
+            },
+          })
+          const channel = new MessageChannel()
+          let transferredPort
+          try {
+            await bounded(pool.execute({ warmup: true }))
+            const [source, destination] = pool.workerNodes
+            const sourceStarted = started(source, 'source')
+            const destinationStarted = started(destination, 'destination')
+            const running = pool
+              .execute({ id: 'source', wait: true })
+              .catch(error => error)
+            const healthyData = { id: 'destination', wait: true }
+            const healthy = pool.execute(healthyData)
+            await bounded(Promise.all([sourceStarted, destinationStarted]))
+            await bounded(
+              pool.addTaskFunction('migratingEcho', {
+                taskFunction: data => ({ id: data.id }),
+                workerNodeKeys: [0],
+              })
+            )
+            const bad = pool
+              .execute(
+                { id: 'bad', port: channel.port1 },
+                'migratingEcho',
+                undefined,
+                [channel.port1]
+              )
+              .catch(error => error)
+            const laterData = { id: 'later' }
+            const later = pool.execute(laterData, 'migratingEcho')
+            expect(source.tasksQueue.size).toBe(2)
+            transferredPort = structuredClone(channel.port1, {
+              transfer: [channel.port1],
+            })
+            if (!destinationBusy) {
+              send(destination, { release: 'destination' })
+              expect(await bounded(healthy)).toStrictEqual(healthyData)
+            }
+            const exit = new Promise(resolve =>
+              source.worker.once('exit', resolve)
+            )
+            send(source, { exitCode: 0 })
+            await bounded(exit)
+            if (destinationBusy) {
+              expect(destination.tasksQueue.size).toBe(2)
+              send(destination, { release: 'destination' })
+              expect(await bounded(healthy)).toStrictEqual(healthyData)
+            }
+            const outcomes = await bounded(Promise.all([running, bad, later]))
+            expect(outcomes[0]).toBeInstanceOf(Error)
+            expect(outcomes[1]).toBeInstanceOf(Error)
+            expect(outcomes[1].name).toBe('DataCloneError')
+            expect(outcomes[2]).toStrictEqual(laterData)
+            const followupData = { id: 'followup' }
+            expect(await bounded(pool.execute(followupData))).toStrictEqual(
+              followupData
+            )
+            expect(source.tasksQueue.size).toBe(0)
+            expect(pool.promiseResponseMap.size).toBe(0)
+            expect(destination.usage.tasks.executing).toBe(0)
+            expect(
+              destination.getTaskFunctionWorkerUsage('migratingEcho').tasks
+                .executing
+            ).toBe(0)
+          } finally {
+            channel.port1.close()
+            channel.port2.close()
+            transferredPort?.close()
+            if (pool.info.started) await bounded(pool.destroy())
+          }
+        })
+      }
+
+      it('settles exited worker tasks when replacement construction throws', async () => {
+        const { stdout } = await promisify(execFile)(
+          process.execPath,
+          [
+            '--eval',
+            `
+              const { setImmediate } = require('node:timers/promises')
+              const { MessageChannel } = require('node:worker_threads')
+              const { FixedThreadPool } = require('./lib/index.cjs')
+
+              const channel = new MessageChannel()
+              let pool
+              let exitedWorker
+              const exitHandlerCalls = []
+              const outcomes = []
+              let observeException
+              const exception = new Promise(resolve => {
+                observeException = resolve
+              })
+              process.once('uncaughtException', observeException)
+
+              ;(async () => {
+                try {
+                  pool = new FixedThreadPool(
+                    1,
+                    './tests/worker-files/thread/crashWorker.mjs',
+                    {
+                      enableTasksQueue: true,
+                      exitHandler (exitCode) {
+                        exitHandlerCalls.push({
+                          exitCode,
+                          receiverMatches: this === exitedWorker,
+                        })
+                      },
+                      workerOptions: {
+                        workerData: { port: channel.port1 },
+                        transferList: [channel.port1],
+                      },
+                    }
+                  )
+                  const exitedNode = pool.workerNodes[0]
+                  exitedWorker = exitedNode.worker
+                  for (const data of [{ exitCode: 0 }, { id: 'queued' }]) {
+                    const index = outcomes.length
+                    outcomes.push({ status: 'pending' })
+                    pool.execute(data).then(
+                      () => { outcomes[index] = { status: 'fulfilled' } },
+                      error => {
+                        outcomes[index] = {
+                          status: 'rejected',
+                          reasonIsError: error instanceof Error,
+                        }
+                      }
+                    )
+                  }
+                  const error = await exception
+                  // Observe settlement after all rejection microtasks have run.
+                  await setImmediate()
+                  console.log(JSON.stringify({
+                    exceptionName: error.name,
+                    exitHandlerCalls,
+                    outcomes,
+                    detachedQueueSize: exitedNode.tasksQueue.size,
+                    promiseResponseMapSize: pool.promiseResponseMap.size,
+                    workerNodesLength: pool.workerNodes.length,
+                    startingMinimumNumberOfWorkers: pool.startingMinimumNumberOfWorkers,
+                  }))
+                } finally {
+                  try {
+                    if (pool != null) await pool.destroy()
+                  } finally {
+                    channel.port1.close()
+                    channel.port2.close()
+                    process.off('uncaughtException', observeException)
+                  }
+                }
+              })().catch(error => {
+                console.error(error)
+                process.exitCode = 1
+              })
+            `,
+          ],
+          { timeout: 10000 }
+        )
+        expect(JSON.parse(stdout)).toStrictEqual({
+          detachedQueueSize: 0,
+          exceptionName: 'DataCloneError',
+          exitHandlerCalls: [{ exitCode: 0, receiverMatches: true }],
+          outcomes: [
+            { reasonIsError: true, status: 'rejected' },
+            { reasonIsError: true, status: 'rejected' },
+          ],
+          promiseResponseMapSize: 0,
+          startingMinimumNumberOfWorkers: false,
+          workerNodesLength: 0,
+        })
+      })
+
+      for (const [description, message] of [
+        ['number', { crashWith: 42 }],
+        ['string', { crashWith: 'Worker threw a string' }],
+        ['false', { crashWith: false }],
+        ['undefined', { crashWith: undefined }],
+        [
+          'object without coercion methods',
+          { crashWith: { toString: null, valueOf: null } },
+        ],
+        ['Error', { crash: true }],
+      ]) {
+        it(`rejects a task with an Error after a native ${description} failure`, async () => {
+          const pool = await createPool()
+          try {
+            const workerNode = pool.workerNodes[0]
+            const running = started(workerNode, 'running')
+            const result = pool
+              .execute({ id: 'running', wait: true })
+              .catch(error => error)
+            await bounded(running)
+            const nativeError = new Promise(resolve =>
+              pool.emitter.once('error', resolve)
+            )
+            send(workerNode, message)
+            const [error, rawWorkerError] = await bounded(
+              Promise.all([result, nativeError])
+            )
+            expect(error).toBeInstanceOf(Error)
+            if (Object.hasOwn(message, 'crashWith')) {
+              expect(rawWorkerError).toStrictEqual(message.crashWith)
+              expect(rawWorkerError).not.toBeInstanceOf(Error)
+              expect(error.cause).toBe(rawWorkerError)
+              if (message.crashWith === undefined) {
+                expect(Object.hasOwn(error, 'cause')).toBe(true)
+              }
+              if (typeof message.crashWith === 'string') {
+                expect(error.message).toBe(message.crashWith)
+              }
+            } else {
+              expect(error).toBe(rawWorkerError)
+            }
+          } finally {
+            await pool.destroy()
+          }
+        })
+      }
+
+      it('only calls the task execution hook for a live worker owner', async () => {
+        const hookOwners = []
+        const invalidKeys = []
+        class LiveOwnerThreadPool extends FixedThreadPool {
+          afterTaskExecutionHook (workerNodeKey, message) {
+            const workerNode = this.workerNodes[workerNodeKey]
+            if (workerNode == null) {
+              invalidKeys.push(workerNodeKey)
+              return
+            }
+            hookOwners.push(workerNode)
+            super.afterTaskExecutionHook(workerNodeKey, message)
+          }
+        }
+        const pool = new LiveOwnerThreadPool(1, workerFile, {
+          enableTasksQueue: true,
+          tasksQueueOptions: {
+            concurrency: 1,
+            tasksStealingOnBackPressure: false,
+            taskStealing: false,
+          },
+        })
+        pool.emitter.on('error', () => {})
+        let workerNode
+        try {
+          if (!pool.info.ready) await bounded(once(pool.emitter, 'ready'))
+          workerNode = pool.workerNodes[0]
+          const controlData = { control: true }
+          expect(await bounded(pool.execute(controlData))).toStrictEqual(
+            controlData
+          )
+          expect(hookOwners).toHaveLength(1)
+          expect(hookOwners[0]).toBe(workerNode)
+          workerNode.messageChannel.port1.off(
+            'message',
+            pool.workerMessageListener
+          )
+          const status = new Int32Array(new SharedArrayBuffer(4))
+          const exit = new Promise(resolve =>
+            workerNode.worker.once('exit', resolve)
+          )
+          const response = { completed: true }
+          const result = pool.execute({
+            exitAfterResponse: status.buffer,
+            response,
+          })
+          const stranded = pool
+            .execute({ id: 'stranded' })
+            .catch(error => error)
+          expect(workerNode.tasksQueue.size).toBe(1)
+          Atomics.wait(status, 0, 0, 5000)
+          expect(Atomics.load(status, 0)).toBe(1)
+          const outcomes = await bounded(Promise.all([result, stranded]))
+          expect(outcomes[0]).toStrictEqual(response)
+          expect(outcomes[1]).toBeInstanceOf(Error)
+          await bounded(exit)
+          expect(hookOwners).toHaveLength(1)
+          expect(invalidKeys).toStrictEqual([])
+          expect(pool.promiseResponseMap.size).toBe(0)
+        } finally {
+          workerNode?.messageChannel?.port1.close()
+          workerNode?.messageChannel?.port2.close()
+          if (pool.info.started) await bounded(pool.destroy())
+        }
+      })
+
+      for (const [crashAfterResponse, fail] of [
+        [false, false],
+        [false, true],
+        [true, false],
+        [true, true],
+      ]) {
+        it(`preserves a buffered response after exit (task failure: ${fail}, uncaught error: ${crashAfterResponse})`, async () => {
+          const pool = await createPool()
+          const workerNode = pool.workerNodes[0]
+          // Keep real responses buffered until the native exit notification.
+          workerNode.messageChannel.port1.off(
+            'message',
+            pool.workerMessageListener
+          )
+          let destruction
+          if (fail) {
+            pool.emitter.once('taskError', () => {
+              destruction = pool.destroy()
+            })
+          }
+          try {
+            const status = new Int32Array(new SharedArrayBuffer(4))
+            const exit = new Promise(resolve =>
+              workerNode.worker.once('exit', resolve)
+            )
+            const response = { completed: true }
+            const result = pool
+              .execute({
+                crashAfterResponse,
+                exitAfterResponse: status.buffer,
+                fail,
+                response,
+              })
+              .catch(error => error)
+            // Hold the main loop until the worker posts its response and exits.
+            Atomics.wait(status, 0, 0, 5000)
+            expect(Atomics.load(status, 0)).toBe(1)
+            if (fail) {
+              const error = await bounded(result)
+              expect(error).toBeInstanceOf(Error)
+              expect(error.message).toBe('Task failed before worker exit')
+              await bounded(destruction)
+              expect(pool.info.started).toBe(false)
+            } else {
+              expect(await bounded(result)).toStrictEqual(response)
+            }
+            await bounded(exit)
+          } finally {
+            workerNode.messageChannel?.port1.close()
+            workerNode.messageChannel?.port2.close()
+            if (pool.info.started) await pool.destroy()
+          }
+        })
+      }
+    }
+
+    if (workerType === 'cluster') {
+      it('migrates queued work after aborting a disconnected cluster worker task', async () => {
+        const pool = await createPool(2, {
+          enableTasksQueue: true,
+          restartWorkerOnError: false,
+          tasksQueueOptions: {
+            concurrency: 1,
+            tasksStealingOnBackPressure: false,
+            taskStealing: false,
+          },
+        })
+        const source = pool.workerNodes[0]
+        const healthyNode = pool.workerNodes[1]
+        try {
+          await bounded(pool.execute({ warmup: true }))
+          const controller = new AbortController()
+          const sourceStarted = started(source, 'disconnected')
+          const failed = pool
+            .execute(
+              { id: 'disconnected', wait: true },
+              undefined,
+              controller.signal
+            )
+            .catch(error => error)
+          const healthyStarted = started(healthyNode, 'healthy')
+          const healthyData = { id: 'healthy', wait: true }
+          const healthy = pool.execute(healthyData).catch(error => error)
+          await bounded(Promise.all([sourceStarted, healthyStarted]))
+          const queuedData = { id: 'queued-before-disconnect' }
+          const queued = pool.execute(queuedData).catch(error => error)
+          expect(source.tasksQueue.size).toBe(1)
+          const disconnect = once(source.worker, 'disconnect')
+          send(source, { disconnect: true })
+          await bounded(disconnect)
+          expect(source.worker.isDead()).toBe(false)
+          const exit = new Promise(resolve =>
+            source.worker.once('exit', resolve)
+          )
+          controller.abort()
+          await bounded(exit)
+          expect(await bounded(failed)).toBeInstanceOf(Error)
+          send(healthyNode, { release: 'healthy' })
+          expect(await bounded(healthy)).toStrictEqual(healthyData)
+          expect(await bounded(queued)).toStrictEqual(queuedData)
+          expect(source.tasksQueue.size).toBe(0)
+          expect(pool.promiseResponseMap.size).toBe(0)
+        } finally {
+          await cleanupRegressionPool(pool, [source, healthyNode])
+        }
+      })
+    }
+
+    it.each(['healthy-flush', 'detached-migration'])(
+      'defers backPressureEnd until fullEnd queue-disable handoffs finish (%s)',
+      async abortTarget => {
+        const DynamicPoolClass =
+          workerType === 'thread' ? DynamicThreadPool : DynamicClusterPool
+        const pool = new DynamicPoolClass(1, 2, workerFile, {
+          enableTasksQueue: true,
+          restartWorkerOnError: false,
+          tasksQueueOptions: {
+            concurrency: 1,
+            size: 1,
+            tasksStealingOnBackPressure: false,
+            taskStealing: false,
+          },
+          workerChoiceStrategy: WorkerChoiceStrategies.ROUND_ROBIN,
+        })
+        pool.emitter.on('error', () => {})
+        const capturedNodes = new Set(pool.workerNodes)
+        try {
+          if (!pool.info.ready) await bounded(once(pool.emitter, 'ready'))
+          const minimumNode = pool.workerNodes[0]
+          const minimumStarted = started(minimumNode, 'minimum')
+          const minimumData = { id: 'minimum', wait: true }
+          const minimum = pool.execute(minimumData).catch(error => error)
+          await bounded(minimumStarted)
+          const full = once(pool.emitter, 'full')
+          const failed = pool
+            .execute({ id: 'dynamic', wait: true })
+            .catch(error => error)
+          const source = pool.workerNodes[1]
+          capturedNodes.add(source)
+          await bounded(Promise.all([full, started(source, 'dynamic')]))
+          for (const [name, workerNodeKeys] of [
+            ['cursorEcho', [0]],
+            ['migratingEcho', [0, 1]],
+          ]) {
+            await bounded(
+              pool.addTaskFunction(name, {
+                taskFunction: async data =>
+                  data.wait ? await new Promise(() => {}) : data,
+                workerNodeKeys,
+              })
+            )
+          }
+          const controller = new AbortController()
+          const abortReason = new Error(`Abort ${abortTarget} after handoff`)
+          const healthyData = {
+            id: 'healthy-queued',
+            wait: abortTarget === 'healthy-flush',
+          }
+          const detachedData = {
+            id: 'detached-queued',
+            wait: abortTarget === 'detached-migration',
+          }
+          const backPressure = once(pool.emitter, 'backPressure')
+          const healthy = pool
+            .execute(
+              healthyData,
+              'cursorEcho',
+              abortTarget === 'healthy-flush' ? controller.signal : undefined
+            )
+            .catch(error => error)
+          const healthyWrapper = [...pool.promiseResponseMap.values()].at(-1)
+          const detached = pool
+            .execute(
+              detachedData,
+              'migratingEcho',
+              abortTarget === 'detached-migration'
+                ? controller.signal
+                : undefined
+            )
+            .catch(error => error)
+          const detachedWrapper = [...pool.promiseResponseMap.values()].at(-1)
+          await bounded(backPressure)
+          expect(minimumNode.tasksQueue.size).toBe(1)
+          expect(source.tasksQueue.size).toBe(1)
+          let fullEndCalls = 0
+          let endState
+          pool.emitter.once('fullEnd', () => {
+            ++fullEndCalls
+            pool.enableTasksQueue(false)
+          })
+          pool.emitter.once('backPressureEnd', () => {
+            endState = {
+              detachedOwner: detachedWrapper.workerNodeKey,
+              executing: minimumNode.usage.tasks.executing,
+              healthyOwner: healthyWrapper.workerNodeKey,
+              sourceQueue: source.tasksQueue.size,
+            }
+            controller.abort(abortReason)
+          })
+          const exit = new Promise(resolve =>
+            source.worker.once('exit', resolve)
+          )
+          send(source, { crash: true })
+          await bounded(exit)
+          expect(await bounded(failed)).toBeInstanceOf(Error)
+          expect(
+            await bounded(abortTarget === 'healthy-flush' ? healthy : detached)
+          ).toBe(abortReason)
+          expect(
+            await bounded(abortTarget === 'healthy-flush' ? detached : healthy)
+          ).toStrictEqual(
+            abortTarget === 'healthy-flush' ? detachedData : healthyData
+          )
+          expect(endState).toStrictEqual({
+            detachedOwner: 0,
+            executing: 3,
+            healthyOwner: 0,
+            sourceQueue: 0,
+          })
+          expect(fullEndCalls).toBe(1)
+          send(minimumNode, { release: 'minimum' })
+          expect(await bounded(minimum)).toStrictEqual(minimumData)
+          expect(minimumNode.tasksQueue.size).toBe(0)
+          expect(source.tasksQueue.size).toBe(0)
+          expect(pool.promiseResponseMap.size).toBe(0)
+        } finally {
+          await cleanupRegressionPool(pool, capturedNodes)
+        }
+      }
+    )
+
+    it.each([
+      new Error('Detached task cancelled'),
+      'Detached task cancelled',
+      42,
+    ])(
+      'preserves a detached task abort reason from fullEnd (%s)',
+      async abortReason => {
+        const DynamicPoolClass =
+          workerType === 'thread' ? DynamicThreadPool : DynamicClusterPool
+        const executions = []
+        class ObservedDynamicPool extends DynamicPoolClass {
+          beforeTaskExecutionHook (workerNodeKey, task) {
+            executions.push(task.data?.id)
+            super.beforeTaskExecutionHook(workerNodeKey, task)
+          }
+        }
+        const pool = new ObservedDynamicPool(1, 2, workerFile, {
+          enableTasksQueue: true,
+          tasksQueueOptions: {
+            concurrency: 1,
+            tasksStealingOnBackPressure: false,
+            taskStealing: false,
+          },
+          workerChoiceStrategy: WorkerChoiceStrategies.ROUND_ROBIN,
+        })
+        pool.emitter.on('error', () => {})
+        const capturedNodes = new Set(pool.workerNodes)
+        try {
+          if (!pool.info.ready) await bounded(once(pool.emitter, 'ready'))
+          const minimumNode = pool.workerNodes[0]
+          const minimumStarted = started(minimumNode, 'minimum')
+          const minimumData = { id: 'minimum', wait: true }
+          const minimum = pool.execute(minimumData).catch(error => error)
+          await bounded(minimumStarted)
+          const full = once(pool.emitter, 'full')
+          const failed = pool
+            .execute({ id: 'dynamic', wait: true })
+            .catch(error => error)
+          const source = pool.workerNodes[1]
+          capturedNodes.add(source)
+          await bounded(Promise.all([full, started(source, 'dynamic')]))
+          await bounded(
+            pool.addTaskFunction('cursorEcho', {
+              taskFunction: data => data,
+              workerNodeKeys: [0],
+            })
+          )
+          await bounded(
+            pool.addTaskFunction('migratingEcho', {
+              taskFunction: data => data,
+              workerNodeKeys: [0, 1],
+            })
+          )
+          const cursorData = { id: 'cursor' }
+          const cursor = pool
+            .execute(cursorData, 'cursorEcho')
+            .catch(error => error)
+          const controller = new AbortController()
+          const target = pool
+            .execute({ id: 'detached' }, 'migratingEcho', controller.signal)
+            .catch(error => error)
+          expect(source.tasksQueue.size).toBe(1)
+          let fullEndCalls = 0
+          pool.emitter.once('fullEnd', () => {
+            ++fullEndCalls
+            controller.abort(abortReason)
+          })
+          const exit = new Promise(resolve =>
+            source.worker.once('exit', resolve)
+          )
+          send(source, { exitCode: 1 })
+          await bounded(exit)
+          expect(await bounded(failed)).toBeInstanceOf(Error)
+          const aborted = await bounded(target)
+          if (abortReason instanceof Error) {
+            expect(aborted).toBe(abortReason)
+          } else {
+            expect(aborted).toBeInstanceOf(Error)
+            if (typeof abortReason === 'string') {
+              expect(aborted.message).toBe(abortReason)
+            } else {
+              expect(aborted.message).toMatch(
+                /^Task 'migratingEcho' id '[\da-f-]+' aborted$/
+              )
+            }
+          }
+          expect(fullEndCalls).toBe(1)
+          expect(controller.signal.aborted).toBe(true)
+          expect(executions).not.toContain('detached')
+          expect(minimumNode.tasksQueue.size).toBe(1)
+          send(minimumNode, { release: 'minimum' })
+          expect(await bounded(minimum)).toStrictEqual(minimumData)
+          expect(await bounded(cursor)).toStrictEqual(cursorData)
+          expect(source.tasksQueue.size).toBe(0)
+          expect(pool.promiseResponseMap.size).toBe(0)
+        } finally {
+          await cleanupRegressionPool(pool, capturedNodes)
+        }
+      }
+    )
+
+    it('rejects an exited dynamic worker task and accepts subsequent work', async () => {
+      const DynamicPoolClass =
+        workerType === 'thread' ? DynamicThreadPool : DynamicClusterPool
+      const pool = new DynamicPoolClass(0, 1, workerFile)
+      pool.emitter.on('error', () => {})
+      try {
+        expect(
+          await bounded(pool.execute({ exitCode: 1 }).catch(error => error))
+        ).toBeInstanceOf(Error)
+        const data = { recovered: true }
+        expect(await bounded(pool.execute(data))).toStrictEqual(data)
+      } finally {
+        await pool.destroy()
+      }
+    })
+  })
+}
+
+describe('thread worker exit bootstrap affinity metadata', { retry: 0 }, () => {
+  const bootstrapMessage = (workerNode, predicate) =>
+    new Promise(resolve => {
+      const listener = message => {
+        if (predicate(message)) {
+          workerNode.worker.off('message', listener)
+          resolve(message)
+        }
+      }
+      workerNode.worker.on('message', listener)
+    })
+
+  const metadataReady = workerNode => {
+    if (workerNode.info.taskFunctionsProperties?.length > 0) {
+      return Promise.resolve()
+    }
+    return new Promise(resolve => {
+      const listener = message => {
+        if (message.ready === true && message.taskFunctionsProperties != null) {
+          workerNode.messageChannel.port1.off('message', listener)
+          resolve()
+        }
+      }
+      workerNode.messageChannel.port1.on('message', listener)
+    })
+  }
+
+  it.each([
+    { mode: 'named pinned rejection', reject: true },
+    { defaultPinned: true, mode: 'default pinned rejection', reject: true },
+    { mode: 'legal pinned migration after compaction', sourceIndex: 0 },
+    { mode: 'unrestricted migration', unrestricted: true },
+    { live: true, mode: 'live unrestricted metadata precedence' },
+    {
+      defaultPinned: true,
+      live: true,
+      mode: 'live default mapping precedence',
+    },
+  ])(
+    'preserves bootstrap affinity metadata: $mode',
+    async ({
+      defaultPinned = false,
+      live = false,
+      reject = false,
+      sourceIndex = 1,
+      unrestricted = false,
+    }) => {
+      const status = new Int32Array(new SharedArrayBuffer(8))
+      const targetIndex = 1 - sourceIndex
+      const roles = [{}, {}]
+      roles[sourceIndex] = {
+        defaultPinned,
+        workerNodeKeys: [sourceIndex],
+      }
+      const pool = new DynamicThreadPool(
+        0,
+        2,
+        './tests/worker-files/thread/bootstrapAffinityWorker.mjs',
+        {
+          enableTasksQueue: true,
+          restartWorkerOnError: false,
+          tasksQueueOptions: {
+            concurrency: 1,
+            tasksStealingOnBackPressure: false,
+            taskStealing: false,
+          },
+          workerChoiceStrategy: WorkerChoiceStrategies.ROUND_ROBIN,
+          workerOptions: {
+            workerData: {
+              blockedRole: targetIndex,
+              bootstrapStatus: status.buffer,
+              roles,
+            },
+          },
+        }
+      )
+      pool.emitter.on('error', () => {})
+      const capturedNodes = []
+      const held = []
+      const releaseBootstrap = () => {
+        Atomics.store(status, 1, 1)
+        Atomics.notify(status, 1)
+      }
+      try {
+        held.push(
+          pool
+            .execute({ id: 'held-0', wait: true }, 'echo')
+            .catch(error => error)
+        )
+        capturedNodes.push(pool.workerNodes[0])
+        const heldStarted = [started(capturedNodes[0], 'held-0')]
+        const firstClaim = await bounded(
+          bootstrapMessage(
+            capturedNodes[0],
+            message => message.bootstrapRole === 0
+          )
+        )
+        expect(firstClaim.blocked).toBe(targetIndex === 0)
+        // The first native claim must arrive before the second worker can claim.
+        if (sourceIndex === 0) {
+          await bounded(metadataReady(capturedNodes[0]))
+        }
+        held.push(
+          pool
+            .execute({ id: 'held-1', wait: true }, 'echo')
+            .catch(error => error)
+        )
+        capturedNodes.push(pool.workerNodes[1])
+        heldStarted.push(started(capturedNodes[1], 'held-1'))
+        const secondClaim = await bounded(
+          bootstrapMessage(
+            capturedNodes[1],
+            message => message.bootstrapRole === 1
+          )
+        )
+        expect(secondClaim.blocked).toBe(targetIndex === 1)
+        const source = capturedNodes[sourceIndex]
+        const target = capturedNodes[targetIndex]
+        const targetStarted = heldStarted[targetIndex]
+        await bounded(
+          Promise.all([metadataReady(source), heldStarted[sourceIndex]])
+        )
+        expect(
+          source.info.taskFunctionsProperties.find(
+            properties => properties.name === 'pinned'
+          ).workerNodeKeys
+        ).toStrictEqual([sourceIndex])
+        if (defaultPinned) {
+          expect(source.info.taskFunctionsProperties[1].name).toBe('pinned')
+        }
+        expect(target.info.taskFunctionsProperties ?? []).toHaveLength(0)
+        expect(target.info.ready).toBe(true)
+        let filler
+        if (unrestricted) {
+          filler = pool.execute({ id: 'filler' }, 'echo').catch(error => error)
+          expect(target.tasksQueue.size).toBe(1)
+        }
+        const queuedData = { id: 'queued' }
+        const queued = pool
+          .execute(
+            queuedData,
+            defaultPinned ? undefined : unrestricted ? 'echo' : 'pinned'
+          )
+          .catch(error => error)
+        const queuedTaskId = [...pool.promiseResponseMap.keys()].at(-1)
+        expect(source.tasksQueue.size).toBe(1)
+        if (live) {
+          const ready = metadataReady(target)
+          releaseBootstrap()
+          await bounded(Promise.all([ready, targetStarted]))
+          expect(
+            target.info.taskFunctionsProperties.find(
+              properties => properties.name === 'pinned'
+            ).workerNodeKeys
+          ).toBeUndefined()
+          if (defaultPinned) {
+            expect(target.info.taskFunctionsProperties[1].name).toBe('echo')
+          }
+        }
+        const exit = new Promise(resolve => source.worker.once('exit', resolve))
+        send(source, { exitCode: 0 })
+        await bounded(exit)
+        expect(await bounded(held[sourceIndex])).toBeInstanceOf(Error)
+        expect(pool.workerNodes).toStrictEqual([target])
+        expect(source.tasksQueue.size).toBe(0)
+        if (reject) {
+          // Fail before unblocking or cleanup can execute a forbidden migration.
+          expect(target.info.taskFunctionsProperties ?? []).toHaveLength(0)
+          expect(target.tasksQueue.size).toBe(0)
+          expect(pool.promiseResponseMap.has(queuedTaskId)).toBe(false)
+          expect(await bounded(queued)).toBeInstanceOf(Error)
+        } else {
+          expect(target.tasksQueue.size).toBe(unrestricted ? 2 : 1)
+        }
+        releaseBootstrap()
+        await bounded(targetStarted)
+        send(target, { release: `held-${targetIndex}` })
+        expect(await bounded(held[targetIndex])).toMatchObject({
+          id: `held-${targetIndex}`,
+          workerId: target.info.id,
+        })
+        if (!reject) {
+          expect(await bounded(queued)).toStrictEqual({
+            ...queuedData,
+            taskFunction: defaultPinned || unrestricted ? 'echo' : 'pinned',
+            workerId: target.info.id,
+          })
+        }
+        if (filler != null) {
+          expect(await bounded(filler)).toMatchObject({
+            id: 'filler',
+            workerId: target.info.id,
+          })
+        }
+        expect(pool.promiseResponseMap.size).toBe(0)
+      } finally {
+        releaseBootstrap()
+        await cleanupRegressionPool(pool, capturedNodes)
+      }
+    }
+  )
+})
index 07249b844d67b00c7f4ce3ce37db1db5fece7659..2c2a491ac854993a5ba4fbe57533844d4ca15556 100644 (file)
@@ -1,4 +1,5 @@
-import { Worker as ClusterWorker } from 'node:cluster'
+import cluster, { Worker as ClusterWorker } from 'node:cluster'
+import { once } from 'node:events'
 import { MessageChannel, Worker as ThreadWorker } from 'node:worker_threads'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
 
@@ -40,6 +41,94 @@ describe('Worker node test suite', () => {
     await clusterWorkerNode.terminate()
   })
 
+  it('terminates a thread worker node after its native worker has exited', async () => {
+    const workerNode = new WorkerNode(
+      WorkerTypes.thread,
+      './tests/worker-files/thread/testWorker.mjs',
+      {
+        tasksQueueBackPressureSize: 12,
+        tasksQueueBucketSize: 6,
+        tasksQueuePriority: true,
+      }
+    )
+    let timeout
+    try {
+      await once(workerNode.worker, 'online')
+      await workerNode.worker.terminate()
+      await expect(
+        Promise.race([
+          workerNode.terminate(),
+          new Promise((resolve, reject) => {
+            timeout = setTimeout(
+              () =>
+                reject(new Error('Worker node termination remained pending')),
+              5000
+            )
+          }),
+        ])
+      ).resolves.toBeUndefined()
+    } finally {
+      clearTimeout(timeout)
+      await workerNode.worker.terminate()
+      workerNode.messageChannel?.port1.close()
+      workerNode.messageChannel?.port2.close()
+    }
+  })
+
+  it('terminates an already disconnected cluster worker node and can terminate it again after exit', async () => {
+    const clusterSettings = { ...cluster.settings }
+    cluster.setupPrimary({
+      exec: './tests/worker-files/cluster/crashWorker.cjs',
+    })
+    let workerNode
+    try {
+      workerNode = new WorkerNode(
+        WorkerTypes.cluster,
+        './tests/worker-files/cluster/crashWorker.cjs',
+        {
+          tasksQueueBackPressureSize: 12,
+          tasksQueueBucketSize: 6,
+          tasksQueuePriority: true,
+        }
+      )
+    } finally {
+      cluster.setupPrimary(clusterSettings)
+    }
+    let timeout
+    const boundedWait = async (promise, stage) => {
+      try {
+        await Promise.race([
+          promise,
+          new Promise((resolve, reject) => {
+            timeout = setTimeout(
+              () => reject(new Error(`Worker node ${stage} remained pending`)),
+              5000
+            )
+          }),
+        ])
+      } finally {
+        clearTimeout(timeout)
+      }
+    }
+    try {
+      await boundedWait(once(workerNode.worker, 'online'), 'online')
+      const disconnect = once(workerNode.worker, 'disconnect')
+      workerNode.worker.send({ disconnect: true })
+      await boundedWait(disconnect, 'disconnect')
+      expect(workerNode.worker.isConnected()).toBe(false)
+      expect(workerNode.worker.isDead()).toBe(false)
+      await boundedWait(workerNode.terminate(), 'disconnected termination')
+      expect(workerNode.worker.isDead()).toBe(true)
+      await boundedWait(workerNode.terminate(), 'post-exit termination')
+    } finally {
+      if (!workerNode.worker.isDead()) {
+        const exit = once(workerNode.worker, 'exit')
+        workerNode.worker.process.kill()
+        await boundedWait(exit, 'native cleanup exit')
+      }
+    }
+  })
+
   it('Worker node instantiation', () => {
     expect(() => new WorkerNode()).toThrow(
       new TypeError('Cannot construct a worker node without a worker type')
diff --git a/tests/worker-files/cluster/crashWorker.cjs b/tests/worker-files/cluster/crashWorker.cjs
new file mode 100644 (file)
index 0000000..56e6e4b
--- /dev/null
@@ -0,0 +1,40 @@
+'use strict'
+const cluster = require('node:cluster')
+
+const { ClusterWorker } = require('../../../lib/index.cjs')
+
+const pendingTasks = new Map()
+
+process.on('message', message => {
+  if (message.disconnect === true) {
+    // Keep the disconnected process alive until its owner terminates it.
+    setInterval(() => {}, 1000)
+    cluster.worker.disconnect()
+  }
+  if (message.crash === true) {
+    throw new Error('Worker crashed')
+  }
+  if (message.exitCode != null) {
+    // eslint-disable-next-line n/no-process-exit
+    process.exit(message.exitCode)
+  }
+  if (message.release != null) {
+    pendingTasks.get(message.release)?.()
+    pendingTasks.delete(message.release)
+  }
+})
+
+module.exports = new ClusterWorker(async data => {
+  if (data.exitCode != null) {
+    // Exit without an error event to exercise lost task responses.
+    // eslint-disable-next-line n/no-process-exit
+    process.exit(data.exitCode)
+  }
+  if (data.wait === true) {
+    return await new Promise(resolve => {
+      pendingTasks.set(data.id, () => resolve(data))
+      process.send({ started: data.id, workerId: cluster.worker.id })
+    })
+  }
+  return data
+})
diff --git a/tests/worker-files/thread/bootstrapAffinityWorker.mjs b/tests/worker-files/thread/bootstrapAffinityWorker.mjs
new file mode 100644 (file)
index 0000000..d1c263b
--- /dev/null
@@ -0,0 +1,48 @@
+import { parentPort, threadId, workerData } from 'node:worker_threads'
+
+import { ThreadWorker } from '../../../lib/index.mjs'
+
+const status = new Int32Array(workerData.bootstrapStatus)
+const role = Atomics.add(status, 0, 1)
+const configuration = workerData.roles[role]
+const blocked = role === workerData.blockedRole
+parentPort.postMessage({ blocked, bootstrapRole: role, workerId: threadId })
+if (blocked) Atomics.wait(status, 1, 0)
+
+const pendingTasks = new Map()
+parentPort.on('message', message => {
+  if (message.exitCode != null) {
+    // eslint-disable-next-line n/no-process-exit
+    process.exit(message.exitCode)
+  }
+  if (message.release != null) {
+    pendingTasks.get(message.release)?.()
+    pendingTasks.delete(message.release)
+  }
+})
+
+const run = async (data, taskFunction) => {
+  if (data.wait === true) {
+    await new Promise(resolve => {
+      pendingTasks.set(data.id, resolve)
+      parentPort.postMessage({ started: data.id, workerId: threadId })
+    })
+  }
+  return { ...data, taskFunction, workerId: threadId }
+}
+const echo = async data => await run(data, 'echo')
+const pinned = {
+  taskFunction: async data => await run(data, 'pinned'),
+  ...(configuration.workerNodeKeys != null && {
+    workerNodeKeys: configuration.workerNodeKeys,
+  }),
+}
+
+export default new ThreadWorker(
+  configuration.defaultPinned
+    ? Object.fromEntries([
+      ['pinned', pinned],
+      ['echo', echo],
+    ])
+    : { echo, pinned }
+)
diff --git a/tests/worker-files/thread/crashWorker.mjs b/tests/worker-files/thread/crashWorker.mjs
new file mode 100644 (file)
index 0000000..24848a4
--- /dev/null
@@ -0,0 +1,53 @@
+import { parentPort, threadId } from 'node:worker_threads'
+
+import { ThreadWorker } from '../../../lib/index.mjs'
+
+const pendingTasks = new Map()
+
+parentPort.on('message', message => {
+  if (Object.hasOwn(message, 'crashWith')) {
+    throw message.crashWith
+  }
+  if (message.crash === true) {
+    throw new Error('Worker crashed')
+  }
+  if (message.exitCode != null) {
+    // eslint-disable-next-line n/no-process-exit
+    process.exit(message.exitCode)
+  }
+  if (message.release != null) {
+    pendingTasks.get(message.release)?.()
+    pendingTasks.delete(message.release)
+  }
+})
+
+export default new ThreadWorker(async data => {
+  if (data.exitCode != null) {
+    // Exit without an error event to exercise lost task responses.
+    // eslint-disable-next-line n/no-process-exit
+    process.exit(data.exitCode)
+  }
+  if (data.exitAfterResponse != null) {
+    const status = new Int32Array(data.exitAfterResponse)
+    setImmediate(() => {
+      Atomics.store(status, 0, 1)
+      Atomics.notify(status, 0)
+      if (data.crashAfterResponse === true) {
+        throw new Error('Worker crashed after task response')
+      }
+      // eslint-disable-next-line n/no-process-exit
+      process.exit(0)
+    })
+    if (data.fail === true) {
+      throw new Error('Task failed before worker exit')
+    }
+    return data.response
+  }
+  if (data.wait === true) {
+    return await new Promise(resolve => {
+      pendingTasks.set(data.id, () => resolve(data))
+      parentPort.postMessage({ started: data.id, workerId: threadId })
+    })
+  }
+  return data
+})