fix(background-agent): detach error listener before running body to stop re-entrant log loop

When shutdown() itself emitted uncaughtException (e.g. EPIPE while closing
a broken pipe), the error listener re-entered itself, re-logged, re-ran
cleanup, and threw EPIPE again. The 6 s forced-exit timer could not fire
because every re-entry stalled the event loop with fresh synchronous work.

Users hit this after v3.17.5 and observed 100+ GB of log lines written to
disk within minutes, with one confirmed report of a 157 GB log file filling
the filesystem.

Detaching the listener with process.off() before running log() + handler()
breaks the loop at the first re-emit: the second event has no listener to
invoke, and the first invocation's scheduleForcedExit() proceeds normally.
This commit is contained in:
yizhifengye
2026-04-30 14:47:09 +08:00
parent 48fc7bd459
commit 49c2a40251
2 changed files with 43 additions and 0 deletions
@@ -281,5 +281,42 @@ describe("#given process cleanup registration", () => {
uncaughtExceptionListenersBefore.length,
)
})
test("#given cleanup itself throws re-entrant uncaughtException #when event fires repeatedly #then listener body runs only once AND no further log calls occur", async () => {
// Regression guard for log explosion (157 GB in minutes) observed when
// shutdown() code path itself emits uncaughtException (e.g. EPIPE while
// closing a broken pipe). Before the fix, every re-entry logged another
// line and re-ran cleanup, producing an unbounded loop that filled disk.
const reentrantShutdown = mock(() => {
process.emit("uncaughtException", new Error("EPIPE re-entry"))
})
const manager = { shutdown: reentrantShutdown }
registeredManagers.push(manager)
registerManagerForCleanup(manager)
process.emit("uncaughtException", new Error("boom"))
await flushMicrotasks()
// Primary listener body must run exactly once. Re-entry MUST be short-
// circuited — otherwise the shutdown → EPIPE → uncaughtException loop
// writes millions of log lines before the forced-exit timer fires.
expect(reentrantShutdown.mock.calls.length).toBeLessThanOrEqual(1)
})
test("#given cleanup emits unhandledRejection re-entrantly #when event fires #then listener body runs only once", async () => {
const reentrantShutdown = mock(() => {
process.emit("unhandledRejection", new Error("re-entry"), Promise.resolve())
})
const manager = { shutdown: reentrantShutdown }
registeredManagers.push(manager)
registerManagerForCleanup(manager)
process.emit("unhandledRejection", new Error("boom"), Promise.resolve())
await flushMicrotasks()
expect(reentrantShutdown.mock.calls.length).toBeLessThanOrEqual(1)
})
})
})
@@ -31,6 +31,12 @@ function registerErrorEvent(
handler: (error: unknown) => void | Promise<void>
): (error: unknown) => void {
const listener = (error: unknown) => {
// Detach before running the body so a re-emit from inside log()/handler()
// (e.g. EPIPE while closing a broken pipe during shutdown) cannot recurse.
// Prior behavior: the listener re-entered itself, re-logged, re-ran cleanup,
// and threw EPIPE again — an unbounded loop that filled disks with 100+ GB
// of log lines in minutes before the 6 s forced-exit timer could fire.
process.off(signal, listener)
log(`[background-agent] ${signal} received during shutdown cleanup:`, error)
scheduleForcedExit(handler(error), 1)
}