Skip to content

Commit 2a24dec

Browse files
authored
fix(core): surface fatal-error stack on worker IPC drop (#1306)
1 parent 34e3290 commit 2a24dec

1 file changed

Lines changed: 34 additions & 5 deletions

File tree

  • packages/core/src/runtime/worker

packages/core/src/runtime/worker/index.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
4270
const 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
};
5281
process.on('uncaughtException', fatalExit);
5382
process.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

Comments
 (0)