Files
oh-my-opencode/src/features/background-agent/process-cleanup.test.ts
T
YeonGyu-Kim 45452a039c Merge pull request #3731 from yizhifengye/fix/process-cleanup-infinite-loop-epipe
fix(background-agent): detach error listener before running body to s…
2026-05-05 04:24:01 +09:00

317 lines
12 KiB
TypeScript

/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import {
_resetForTesting,
registerManagerForCleanup,
unregisterManagerForCleanup,
} from "./process-cleanup"
import { flushMicrotasks, getNewListener } from "./process-cleanup.test-helpers"
type CleanupManager = {
shutdown: () => void | Promise<void>
}
describe("#given process cleanup registration", () => {
const registeredManagers: CleanupManager[] = []
beforeEach(() => {
process.exitCode = 0
registeredManagers.length = 0
_resetForTesting()
})
afterEach(() => {
for (const manager of [...registeredManagers]) {
unregisterManagerForCleanup(manager)
}
process.exitCode = 0
_resetForTesting()
})
describe("#given the first cleanup manager", () => {
test("#when registerManagerForCleanup runs #then signal handlers are registered", () => {
const sigintListenersBefore = process.listeners("SIGINT")
const sigtermListenersBefore = process.listeners("SIGTERM")
const beforeExitListenersBefore = process.listeners("beforeExit")
const exitListenersBefore = process.listeners("exit")
const manager = { shutdown: mock(() => {}) }
registeredManagers.push(manager)
registerManagerForCleanup(manager)
expect(process.listeners("SIGINT")).toHaveLength(sigintListenersBefore.length + 1)
expect(process.listeners("SIGTERM")).toHaveLength(sigtermListenersBefore.length + 1)
expect(process.listeners("beforeExit")).toHaveLength(beforeExitListenersBefore.length + 1)
expect(process.listeners("exit")).toHaveLength(exitListenersBefore.length + 1)
if (process.platform === "win32") {
expect(process.listeners("SIGBREAK").length).toBeGreaterThan(0)
}
})
test("#when the exit listener runs #then the registered manager shuts down", () => {
const exitListenersBefore = process.listeners("exit")
const shutdown = mock(() => {})
const manager = { shutdown }
registeredManagers.push(manager)
registerManagerForCleanup(manager)
const exitListener = getNewListener("exit", exitListenersBefore)
exitListener()
expect(shutdown).toHaveBeenCalledTimes(1)
})
test("#when cleanup finishes after SIGINT #then the fallback exit timer is cleared", async () => {
const sigintListenersBefore = process.listeners("SIGINT")
const setTimeoutSpy = spyOn(globalThis, "setTimeout")
const clearTimeoutSpy = spyOn(globalThis, "clearTimeout")
try {
const manager = {
shutdown: mock(async () => {
await Promise.resolve()
}),
}
registeredManagers.push(manager)
registerManagerForCleanup(manager)
const sigintListener = getNewListener("SIGINT", sigintListenersBefore)
sigintListener()
await flushMicrotasks()
expect(setTimeoutSpy).toHaveBeenCalledTimes(1)
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1)
} finally {
setTimeoutSpy.mockRestore()
clearTimeoutSpy.mockRestore()
}
})
})
describe("#given multiple cleanup managers", () => {
test("#when the exit listener runs #then every registered manager shuts down", () => {
const exitListenersBefore = process.listeners("exit")
const shutdownOne = mock(() => {})
const shutdownTwo = mock(() => {})
const shutdownThree = mock(() => {})
const managers = [
{ shutdown: shutdownOne },
{ shutdown: shutdownTwo },
{ shutdown: shutdownThree },
]
registeredManagers.push(...managers)
for (const manager of managers) {
registerManagerForCleanup(manager)
}
const exitListener = getNewListener("exit", exitListenersBefore)
exitListener()
expect(shutdownOne).toHaveBeenCalledTimes(1)
expect(shutdownTwo).toHaveBeenCalledTimes(1)
expect(shutdownThree).toHaveBeenCalledTimes(1)
})
test("#when another manager registers #then signal handlers are not duplicated", () => {
const managerOne = { shutdown: mock(() => {}) }
const managerTwo = { shutdown: mock(() => {}) }
registeredManagers.push(managerOne, managerTwo)
registerManagerForCleanup(managerOne)
const sigintListenersAfterFirstRegistration = process.listeners("SIGINT").length
registerManagerForCleanup(managerTwo)
expect(process.listeners("SIGINT")).toHaveLength(sigintListenersAfterFirstRegistration)
})
test("#given two managers registered #when uncaughtException fires #then both shutdowns called", async () => {
const exitSpy = spyOn(process, "exit").mockImplementation((() => undefined) as never)
const shutdownOne = mock(() => {})
const shutdownTwo = mock(() => {})
const managerOne = { shutdown: shutdownOne }
const managerTwo = { shutdown: shutdownTwo }
registeredManagers.push(managerOne, managerTwo)
try {
registerManagerForCleanup(managerOne)
registerManagerForCleanup(managerTwo)
process.emit("uncaughtException", new Error("boom"))
await flushMicrotasks()
expect(shutdownOne).toHaveBeenCalledTimes(1)
expect(shutdownTwo).toHaveBeenCalledTimes(1)
expect(process.exitCode).toBe(1)
expect(exitSpy).toHaveBeenCalledWith(1)
} finally {
exitSpy.mockRestore()
}
})
})
describe("#given cleanup managers are unregistered", () => {
test("#when the last manager unregisters #then signal handlers are removed", () => {
const sigintListenersBefore = process.listeners("SIGINT")
const sigtermListenersBefore = process.listeners("SIGTERM")
const beforeExitListenersBefore = process.listeners("beforeExit")
const exitListenersBefore = process.listeners("exit")
const manager = { shutdown: mock(() => {}) }
registeredManagers.push(manager)
registerManagerForCleanup(manager)
unregisterManagerForCleanup(manager)
registeredManagers.length = 0
expect(process.listeners("SIGINT")).toHaveLength(sigintListenersBefore.length)
expect(process.listeners("SIGTERM")).toHaveLength(sigtermListenersBefore.length)
expect(process.listeners("beforeExit")).toHaveLength(beforeExitListenersBefore.length)
expect(process.listeners("exit")).toHaveLength(exitListenersBefore.length)
})
test("#when one manager remains registered #then cleanup handlers stay active for it", () => {
const exitListenersBefore = process.listeners("exit")
const remainingManagerShutdown = mock(() => {})
const removedManagerShutdown = mock(() => {})
const remainingManager = { shutdown: remainingManagerShutdown }
const removedManager = { shutdown: removedManagerShutdown }
registeredManagers.push(remainingManager, removedManager)
registerManagerForCleanup(remainingManager)
registerManagerForCleanup(removedManager)
unregisterManagerForCleanup(removedManager)
const exitListener = getNewListener("exit", exitListenersBefore)
exitListener()
expect(remainingManagerShutdown).toHaveBeenCalledTimes(1)
expect(removedManagerShutdown).not.toHaveBeenCalled()
})
test("#given uncaughtException handler registered #when manager is unregistered via unregisterManagerForCleanup #then subsequent events do not invoke that manager", () => {
const uncaughtExceptionListenersBefore = process.listeners("uncaughtException")
const shutdown = mock(() => {})
const manager = { shutdown }
registeredManagers.push(manager)
registerManagerForCleanup(manager)
expect(process.listeners("uncaughtException")).toHaveLength(
uncaughtExceptionListenersBefore.length + 1,
)
unregisterManagerForCleanup(manager)
registeredManagers.length = 0
process.emit("uncaughtException", new Error("boom"))
expect(shutdown).not.toHaveBeenCalled()
})
})
describe("#given uncaught exception and rejection cleanup", () => {
test("#given manager registered AND process emits uncaughtException #when event fires #then manager shuts down before process exits", async () => {
const exitSpy = spyOn(process, "exit").mockImplementation((() => undefined) as never)
const shutdown = mock(() => {})
const manager = { shutdown }
registeredManagers.push(manager)
try {
registerManagerForCleanup(manager)
process.emit("uncaughtException", new Error("boom"))
await flushMicrotasks()
expect(shutdown).toHaveBeenCalledTimes(1)
expect(process.exitCode).toBe(1)
expect(exitSpy).toHaveBeenCalledWith(1)
} finally {
exitSpy.mockRestore()
}
})
test("#given manager registered AND process emits unhandledRejection #when event fires #then manager shuts down before process exits", async () => {
const exitSpy = spyOn(process, "exit").mockImplementation((() => undefined) as never)
const shutdown = mock(() => {})
const manager = { shutdown }
registeredManagers.push(manager)
try {
registerManagerForCleanup(manager)
process.emit("unhandledRejection", new Error("boom"), Promise.resolve())
await flushMicrotasks()
expect(shutdown).toHaveBeenCalledTimes(1)
expect(process.exitCode).toBe(1)
expect(exitSpy).toHaveBeenCalledWith(1)
} finally {
exitSpy.mockRestore()
}
})
test("#given _resetForTesting() called #when event fires #then no cleanup runs", () => {
const uncaughtExceptionListenersBefore = process.listeners("uncaughtException")
const shutdown = mock(() => {})
const manager = { shutdown }
registerManagerForCleanup(manager)
expect(process.listeners("uncaughtException")).toHaveLength(
uncaughtExceptionListenersBefore.length + 1,
)
_resetForTesting()
process.emit("uncaughtException", new Error("boom"))
expect(shutdown).not.toHaveBeenCalled()
expect(process.listeners("uncaughtException")).toHaveLength(
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)
})
})
})