fix(background-agent): run manager cleanup on uncaughtException and unhandledRejection

Signal handlers covered SIGINT/SIGTERM/SIGBREAK/beforeExit/exit, but a
synchronous throw or a top-level rejected promise terminated the process
without letting TmuxSessionManager (or any other registered manager) run
its shutdown hook. That reliably left orphan tmux panes after an opencode
crash.

Added registration for uncaughtException and unhandledRejection that fan
out through the existing cleanupAll() path, set process.exitCode = 1,
and arm the same 6 second forced-exit guard we use for signals. Test
helpers hold process-level spies so the new tests do not leak listeners
between runs.
This commit is contained in:
YeonGyu-Kim
2026-04-18 19:32:04 +09:00
parent 21554be870
commit b9d2acdcf9
3 changed files with 183 additions and 48 deletions
@@ -1,33 +1,51 @@
import { log } from "../../shared"
type ProcessCleanupEvent = NodeJS.Signals | "beforeExit" | "exit"
type ProcessCleanupSignal = NodeJS.Signals | "beforeExit" | "exit"
type ProcessCleanupErrorEvent = "uncaughtException" | "unhandledRejection"
function scheduleForcedExit(cleanupResult: void | Promise<void>, exitCode: number): void {
process.exitCode = exitCode
const exitTimeout = setTimeout(() => process.exit(), 6000)
void Promise.resolve(cleanupResult).finally(() => {
clearTimeout(exitTimeout)
})
}
function registerProcessSignal(
signal: ProcessCleanupEvent,
signal: ProcessCleanupSignal,
handler: () => void | Promise<void>,
exitAfter: boolean
): () => void {
const listener = () => {
const cleanupResult = handler()
if (exitAfter) {
process.exitCode = 0
const exitTimeout = setTimeout(() => process.exit(), 6000)
void Promise.resolve(cleanupResult).finally(() => {
clearTimeout(exitTimeout)
})
scheduleForcedExit(cleanupResult, 0)
}
}
process.on(signal, listener)
return listener
}
function registerErrorEvent(
signal: ProcessCleanupErrorEvent,
handler: (error: unknown) => void | Promise<void>
): (error: unknown) => void {
const listener = (error: unknown) => {
log(`[background-agent] ${signal} received during shutdown cleanup:`, error)
scheduleForcedExit(handler(error), 1)
}
process.on(signal, listener)
return listener
}
interface CleanupTarget {
shutdown(): void | Promise<void>
}
const cleanupManagers = new Set<CleanupTarget>()
let cleanupRegistered = false
const cleanupHandlers = new Map<ProcessCleanupEvent, () => void>()
const cleanupSignalHandlers = new Map<ProcessCleanupSignal, () => void>()
const cleanupErrorHandlers = new Map<ProcessCleanupErrorEvent, (error: unknown) => void>()
export function registerManagerForCleanup(manager: CleanupTarget): void {
cleanupManagers.add(manager)
@@ -59,9 +77,9 @@ export function registerManagerForCleanup(manager: CleanupTarget): void {
return cleanupPromise
}
const registerSignal = (signal: ProcessCleanupEvent, exitAfter: boolean): void => {
const registerSignal = (signal: ProcessCleanupSignal, exitAfter: boolean): void => {
const listener = registerProcessSignal(signal, cleanupAll, exitAfter)
cleanupHandlers.set(signal, listener)
cleanupSignalHandlers.set(signal, listener)
}
registerSignal("SIGINT", true)
@@ -71,6 +89,8 @@ export function registerManagerForCleanup(manager: CleanupTarget): void {
}
registerSignal("beforeExit", false)
registerSignal("exit", false)
cleanupErrorHandlers.set("uncaughtException", registerErrorEvent("uncaughtException", cleanupAll))
cleanupErrorHandlers.set("unhandledRejection", registerErrorEvent("unhandledRejection", cleanupAll))
}
export function unregisterManagerForCleanup(manager: CleanupTarget): void {
@@ -78,10 +98,14 @@ export function unregisterManagerForCleanup(manager: CleanupTarget): void {
if (cleanupManagers.size > 0) return
for (const [signal, listener] of cleanupHandlers.entries()) {
for (const [signal, listener] of cleanupSignalHandlers.entries()) {
process.off(signal, listener)
}
cleanupHandlers.clear()
for (const [signal, listener] of cleanupErrorHandlers.entries()) {
process.off(signal, listener)
}
cleanupSignalHandlers.clear()
cleanupErrorHandlers.clear()
cleanupRegistered = false
}
@@ -90,9 +114,13 @@ export function _resetForTesting(): void {
for (const manager of [...cleanupManagers]) {
cleanupManagers.delete(manager)
}
for (const [signal, listener] of cleanupHandlers.entries()) {
for (const [signal, listener] of cleanupSignalHandlers.entries()) {
process.off(signal, listener)
}
cleanupHandlers.clear()
for (const [signal, listener] of cleanupErrorHandlers.entries()) {
process.off(signal, listener)
}
cleanupSignalHandlers.clear()
cleanupErrorHandlers.clear()
cleanupRegistered = false
}