Files
oh-my-opencode/src/features/background-agent/process-cleanup.ts
T
YeonGyu-Kim b9d2acdcf9 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.
2026-04-18 19:32:04 +09:00

127 lines
3.9 KiB
TypeScript

import { log } from "../../shared"
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: ProcessCleanupSignal,
handler: () => void | Promise<void>,
exitAfter: boolean
): () => void {
const listener = () => {
const cleanupResult = handler()
if (exitAfter) {
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 cleanupSignalHandlers = new Map<ProcessCleanupSignal, () => void>()
const cleanupErrorHandlers = new Map<ProcessCleanupErrorEvent, (error: unknown) => void>()
export function registerManagerForCleanup(manager: CleanupTarget): void {
cleanupManagers.add(manager)
if (cleanupRegistered) return
cleanupRegistered = true
let cleanupPromise: Promise<void> | undefined
const cleanupAll = (): Promise<void> => {
if (cleanupPromise) return cleanupPromise
const promises: Promise<void>[] = []
for (const m of cleanupManagers) {
try {
promises.push(
Promise.resolve(m.shutdown()).catch((error) => {
log("[background-agent] Error during async shutdown cleanup:", error)
})
)
} catch (error) {
log("[background-agent] Error during shutdown cleanup:", error)
}
}
cleanupPromise = Promise.allSettled(promises).then(() => {})
cleanupPromise.then(() => {
log("[background-agent] All shutdown cleanup completed")
})
return cleanupPromise
}
const registerSignal = (signal: ProcessCleanupSignal, exitAfter: boolean): void => {
const listener = registerProcessSignal(signal, cleanupAll, exitAfter)
cleanupSignalHandlers.set(signal, listener)
}
registerSignal("SIGINT", true)
registerSignal("SIGTERM", true)
if (process.platform === "win32") {
registerSignal("SIGBREAK", true)
}
registerSignal("beforeExit", false)
registerSignal("exit", false)
cleanupErrorHandlers.set("uncaughtException", registerErrorEvent("uncaughtException", cleanupAll))
cleanupErrorHandlers.set("unhandledRejection", registerErrorEvent("unhandledRejection", cleanupAll))
}
export function unregisterManagerForCleanup(manager: CleanupTarget): void {
cleanupManagers.delete(manager)
if (cleanupManagers.size > 0) return
for (const [signal, listener] of cleanupSignalHandlers.entries()) {
process.off(signal, listener)
}
for (const [signal, listener] of cleanupErrorHandlers.entries()) {
process.off(signal, listener)
}
cleanupSignalHandlers.clear()
cleanupErrorHandlers.clear()
cleanupRegistered = false
}
/** @internal - test-only reset for module-level singleton state */
export function _resetForTesting(): void {
for (const manager of [...cleanupManagers]) {
cleanupManagers.delete(manager)
}
for (const [signal, listener] of cleanupSignalHandlers.entries()) {
process.off(signal, listener)
}
for (const [signal, listener] of cleanupErrorHandlers.entries()) {
process.off(signal, listener)
}
cleanupSignalHandlers.clear()
cleanupErrorHandlers.clear()
cleanupRegistered = false
}