b9d2acdcf9
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.
127 lines
3.9 KiB
TypeScript
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
|
|
}
|