From 9af8f4b9a3cbed69665b6441c95d108fd5200e70 Mon Sep 17 00:00:00 2001 From: trafgals Date: Wed, 22 Jul 2026 07:26:17 +1000 Subject: [PATCH] feat: add allowWorkerIdleExit option to suppress idle-worker error events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a worker exits while it has no in-flight task, tinypool historically emits an `error` event on the public interface. This is useful for catching uncaught exceptions that fire after the task's synchronous return — but it also fires for workers that die from external causes (e.g. a child process crashing, a workerd subprocess segfaulting during teardown — see https://github.com/cloudflare/workerd/issues/6763), which is a class of noise that the consuming pool often already has visibility into. Add an opt-in `allowWorkerIdleExit: boolean` option (default `false`). When `true`, the pool does not surface an `error` event for workers that exit while idle. Workers that exit mid-task still report the error to the in-flight task's callback, preserving the existing behavior for genuine task failures. A test pairs both modes against the same fixture so the regression for the default behavior is locked in alongside the new opt-in path. --- src/index.ts | 23 +++++++++++- test/allow-worker-idle-exit.test.ts | 58 +++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 test/allow-worker-idle-exit.test.ts diff --git a/src/index.ts b/src/index.ts index 883c89a..751c530 100644 --- a/src/index.ts +++ b/src/index.ts @@ -155,6 +155,18 @@ interface Options { isolateWorkers?: boolean teardown?: string serialization?: SerializationType + /** + * When `true`, the pool does not surface an `error` event for a worker + * that exits while it has no in-flight task. This is useful for pools + * that already track worker health via their own channel (e.g. cloud + * workerd subprocesses that may segfault during teardown — see + * https://github.com/cloudflare/workerd/issues/6763) and do not want + * a post-task uncaught exception to fail the whole pool run. + * + * Defaults to `false` to preserve the historic behavior of surfacing + * post-task uncaught exceptions via the pool's `error` event. + */ + allowWorkerIdleExit?: boolean } interface FilledOptions extends Options { @@ -168,6 +180,7 @@ interface FilledOptions extends Options { concurrentTasksPerWorker: number useAtomics: boolean taskQueue: TaskQueue + allowWorkerIdleExit: boolean } const kDefaultOptions: FilledOptions = { @@ -182,6 +195,7 @@ const kDefaultOptions: FilledOptions = { useAtomics: true, taskQueue: new ArrayTaskQueue(), trackUnmanagedFds: true, + allowWorkerIdleExit: false, } interface RunOptions { @@ -837,7 +851,14 @@ class ThreadPool { for (const taskInfo of taskInfos) { taskInfo.done(err, null) } - } else { + } else if (!this.options.allowWorkerIdleExit) { + // Worker exited while idle (no in-flight task). By default this is + // surfaced as a pool-level `error` event so post-task uncaught + // exceptions aren't silently swallowed. Pools that track worker + // health via their own channel (e.g. cloudflare/vitest-pool-workers + // running workerd, which can segfault on shutdown — see + // https://github.com/cloudflare/workerd/issues/6763) can opt out + // via `allowWorkerIdleExit: true`. this.publicInterface.emit('error', err) } }) diff --git a/test/allow-worker-idle-exit.test.ts b/test/allow-worker-idle-exit.test.ts new file mode 100644 index 0000000..f156831 --- /dev/null +++ b/test/allow-worker-idle-exit.test.ts @@ -0,0 +1,58 @@ +import { dirname, resolve } from 'node:path' +import { Tinypool } from 'tinypool' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +test('uncaught exception after task yields error event (default)', async () => { + const pool = new Tinypool({ + filename: resolve(__dirname, 'fixtures/eval.js'), + maxThreads: 1, + useAtomics: false, + }) + + let errorEmitted: Error | undefined + pool.on('error', (err) => { + errorEmitted = err as Error + }) + + const taskResult = pool.run(` + setTimeout(() => { throw new Error("not_caught") }, 200); + 42 + `) + expect(await taskResult).toBe(42) + + // Wait long enough for the async throw + worker exit to propagate. + await new Promise((r) => setTimeout(r, 600)) + + expect(errorEmitted?.message).toEqual('not_caught') + + await pool.destroy() +}) + +test('uncaught exception after task does NOT yield error event when allowWorkerIdleExit is true', async () => { + const pool = new Tinypool({ + filename: resolve(__dirname, 'fixtures/eval.js'), + maxThreads: 1, + useAtomics: false, + allowWorkerIdleExit: true, + }) + + const errors: unknown[] = [] + pool.on('error', (err) => { + errors.push(err) + }) + + const taskResult = pool.run(` + setTimeout(() => { throw new Error("ignored_when_idle") }, 200); + 42 + `) + expect(await taskResult).toBe(42) + + // Wait long enough for the async throw + worker exit to propagate. + await new Promise((r) => setTimeout(r, 600)) + + expect(errors).toEqual([]) + + await pool.destroy() +}) \ No newline at end of file