diff --git a/src/features/background-agent/process-cleanup.test.ts b/src/features/background-agent/process-cleanup.test.ts index 6b79d594e..19b46fcda 100644 --- a/src/features/background-agent/process-cleanup.test.ts +++ b/src/features/background-agent/process-cleanup.test.ts @@ -275,5 +275,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) + }) }) }) diff --git a/src/features/background-agent/process-cleanup.ts b/src/features/background-agent/process-cleanup.ts index be4338073..f9d84dd6a 100644 --- a/src/features/background-agent/process-cleanup.ts +++ b/src/features/background-agent/process-cleanup.ts @@ -38,6 +38,12 @@ function registerErrorEvent( handler: (error: unknown) => void | Promise ): (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, true) }