From 49c2a40251455cd6c4e7fa598637d69d751ce2ca Mon Sep 17 00:00:00 2001 From: yizhifengye Date: Thu, 30 Apr 2026 14:47:09 +0800 Subject: [PATCH] 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. --- .../background-agent/process-cleanup.test.ts | 37 +++++++++++++++++++ .../background-agent/process-cleanup.ts | 6 +++ 2 files changed, 43 insertions(+) diff --git a/src/features/background-agent/process-cleanup.test.ts b/src/features/background-agent/process-cleanup.test.ts index a9ce35435..938cb67f8 100644 --- a/src/features/background-agent/process-cleanup.test.ts +++ b/src/features/background-agent/process-cleanup.test.ts @@ -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) + }) }) }) diff --git a/src/features/background-agent/process-cleanup.ts b/src/features/background-agent/process-cleanup.ts index 20f8fab00..a061f23fa 100644 --- a/src/features/background-agent/process-cleanup.ts +++ b/src/features/background-agent/process-cleanup.ts @@ -31,6 +31,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) }