@@ -29,15 +29,43 @@ const sendFatalError = (err: unknown): void => {
2929 } ) ;
3030} ;
3131
32+ /**
33+ * Hand control back to Node's default uncaught-exception path. Best-effort
34+ * IPC delivery happens first, then **all** uncaughtException / unhandledRejection
35+ * listeners are cleared and the error is re-thrown on the next tick so Node's
36+ * built-in handler is what actually terminates the process — printing the
37+ * stack to stderr (forks pool: piped to the host's stderr; threads pool:
38+ * surfaced via `worker.on('error')`).
39+ *
40+ * Why clear *all* listeners, not just `fatalExit`: `runInPool` installs its
41+ * own per-task uncaughtException handler that silently absorbs errors into
42+ * `unhandledErrors` (see `runInPool.ts` ~ line 230). That handler is removed
43+ * only when the *next* `preparePool` runs, so for `isolate: true` it stays
44+ * installed forever after the first task. If we leave it attached, the
45+ * re-thrown error gets absorbed and Node's default never runs — the worker
46+ * neither prints a stack nor exits, and `PoolRunner.stopTimer` eventually
47+ * SIGTERMs it 60s later with no diagnostic info.
48+ *
49+ * Why not just `process.exit(1)`: `process.send` is async and a synchronous
50+ * exit drops any envelope still queued in the IPC pipe (verified to lose
51+ * 100% of envelopes ≥ ~100KB on macOS). Without a fallback the host sees
52+ * only `Worker exited unexpectedly (code=1, signal=null)` with no stack.
53+ */
54+ const handOffToNodeDefault = ( err : unknown ) : void => {
55+ process . removeAllListeners ( 'uncaughtException' ) ;
56+ process . removeAllListeners ( 'unhandledRejection' ) ;
57+ process . nextTick ( ( ) => {
58+ throw err ;
59+ } ) ;
60+ } ;
61+
3262/**
3363 * Last-resort handlers. The runtime's `runInPool` registers its own
3464 * uncaught/unhandled handlers that capture errors thrown WHILE a test is
3565 * running and feed them into the test result. These bottom-of-the-stack
3666 * handlers fire only when no task is active (e.g., during worker bootstrap,
3767 * teardown after the result has been flushed, or async leak after the
38- * test completes), and surface a structured `fatal_error` to the host
39- * before exiting. This is the structured replacement for
40- * `patches/tinypool@2.1.0.patch`.
68+ * test completes).
4169 */
4270const fatalExit = ( err : unknown ) : void => {
4371 if ( dyingFromFatal ) return ;
@@ -46,8 +74,9 @@ const fatalExit = (err: unknown): void => {
4674 // into the test result.
4775 return ;
4876 }
77+ dyingFromFatal = true ;
4978 sendFatalError ( err ) ;
50- setImmediate ( ( ) => process . exit ( 1 ) ) ;
79+ handOffToNodeDefault ( err ) ;
5180} ;
5281process . on ( 'uncaughtException' , fatalExit ) ;
5382process . on ( 'unhandledRejection' , fatalExit ) ;
@@ -96,7 +125,7 @@ const runTask = async (
96125 dyingFromFatal = true ;
97126 sendFatalError ( err ) ;
98127 currentTaskId = undefined ;
99- setImmediate ( ( ) => process . exit ( 1 ) ) ;
128+ handOffToNodeDefault ( err ) ;
100129 return ;
101130 }
102131
0 commit comments