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