2026-02-22 11:58:57 +09:00
|
|
|
import { log } from "../../shared"
|
|
|
|
|
|
2026-04-18 19:32:04 +09:00
|
|
|
type ProcessCleanupSignal = NodeJS.Signals | "beforeExit" | "exit"
|
|
|
|
|
type ProcessCleanupErrorEvent = "uncaughtException" | "unhandledRejection"
|
|
|
|
|
|
2026-05-11 18:43:40 +09:00
|
|
|
/**
|
|
|
|
|
* When set to a truthy value (1/true/yes/on), suppresses the global
|
|
|
|
|
* uncaughtException / unhandledRejection handlers that force-exit the host
|
|
|
|
|
* process. Use this when the plugin is installed but background-agent tasks
|
|
|
|
|
* are not actively in use, to avoid OpenCode dying on transient streaming
|
|
|
|
|
* errors propagated as unhandled rejections (see issue #3856).
|
|
|
|
|
*
|
|
|
|
|
* Signal handlers (SIGINT/SIGTERM/SIGBREAK/beforeExit/exit) remain registered
|
|
|
|
|
* because they are needed for graceful shutdown of any in-flight cleanup
|
|
|
|
|
* targets that were registered before the user noticed the issue.
|
|
|
|
|
*/
|
|
|
|
|
const PROCESS_CLEANUP_DISABLE_ENV = "OMO_DISABLE_PROCESS_CLEANUP"
|
|
|
|
|
const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on"])
|
|
|
|
|
|
|
|
|
|
function isProcessCleanupErrorHandlersDisabled(): boolean {
|
|
|
|
|
const raw = process.env[PROCESS_CLEANUP_DISABLE_ENV]
|
|
|
|
|
if (!raw) return false
|
|
|
|
|
return TRUTHY_ENV_VALUES.has(raw.trim().toLowerCase())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 19:04:40 +09:00
|
|
|
/** @internal test-only seam: prevents process.exitCode from contaminating bun test runner */
|
|
|
|
|
let _scheduleForcedExitEnabled = true
|
|
|
|
|
|
|
|
|
|
/** @internal test-only */
|
|
|
|
|
export function __disableScheduledForcedExitForTesting(): void {
|
|
|
|
|
_scheduleForcedExitEnabled = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @internal test-only */
|
|
|
|
|
export function __enableScheduledForcedExitForTesting(): void {
|
|
|
|
|
_scheduleForcedExitEnabled = true
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 16:04:03 +08:00
|
|
|
function scheduleForcedExit(
|
|
|
|
|
cleanupResult: void | Promise<void>,
|
|
|
|
|
exitCode: number,
|
|
|
|
|
exitAfterCleanup = false,
|
|
|
|
|
): void {
|
2026-05-05 19:04:40 +09:00
|
|
|
if (!_scheduleForcedExitEnabled) return
|
2026-04-18 19:32:04 +09:00
|
|
|
process.exitCode = exitCode
|
|
|
|
|
const exitTimeout = setTimeout(() => process.exit(), 6000)
|
|
|
|
|
void Promise.resolve(cleanupResult).finally(() => {
|
|
|
|
|
clearTimeout(exitTimeout)
|
2026-05-01 16:04:03 +08:00
|
|
|
if (exitAfterCleanup) {
|
|
|
|
|
process.exit(exitCode)
|
|
|
|
|
}
|
2026-04-18 19:32:04 +09:00
|
|
|
})
|
|
|
|
|
}
|
2026-02-22 11:58:57 +09:00
|
|
|
|
|
|
|
|
function registerProcessSignal(
|
2026-04-18 19:32:04 +09:00
|
|
|
signal: ProcessCleanupSignal,
|
2026-03-31 16:59:04 -07:00
|
|
|
handler: () => void | Promise<void>,
|
2026-02-22 11:58:57 +09:00
|
|
|
exitAfter: boolean
|
|
|
|
|
): () => void {
|
|
|
|
|
const listener = () => {
|
2026-03-31 16:59:04 -07:00
|
|
|
const cleanupResult = handler()
|
2026-02-22 11:58:57 +09:00
|
|
|
if (exitAfter) {
|
2026-04-18 19:32:04 +09:00
|
|
|
scheduleForcedExit(cleanupResult, 0)
|
2026-02-22 11:58:57 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
process.on(signal, listener)
|
|
|
|
|
return listener
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 19:32:04 +09:00
|
|
|
function registerErrorEvent(
|
|
|
|
|
signal: ProcessCleanupErrorEvent,
|
|
|
|
|
handler: (error: unknown) => void | Promise<void>
|
|
|
|
|
): (error: unknown) => void {
|
|
|
|
|
const listener = (error: unknown) => {
|
2026-04-30 14:47:09 +08:00
|
|
|
// 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)
|
2026-04-18 19:32:04 +09:00
|
|
|
log(`[background-agent] ${signal} received during shutdown cleanup:`, error)
|
2026-05-01 16:04:03 +08:00
|
|
|
scheduleForcedExit(handler(error), 1, true)
|
2026-04-18 19:32:04 +09:00
|
|
|
}
|
|
|
|
|
process.on(signal, listener)
|
|
|
|
|
return listener
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 11:58:57 +09:00
|
|
|
interface CleanupTarget {
|
2026-03-11 21:44:08 +09:00
|
|
|
shutdown(): void | Promise<void>
|
2026-02-22 11:58:57 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cleanupManagers = new Set<CleanupTarget>()
|
|
|
|
|
let cleanupRegistered = false
|
2026-04-18 19:32:04 +09:00
|
|
|
const cleanupSignalHandlers = new Map<ProcessCleanupSignal, () => void>()
|
|
|
|
|
const cleanupErrorHandlers = new Map<ProcessCleanupErrorEvent, (error: unknown) => void>()
|
2026-02-22 11:58:57 +09:00
|
|
|
|
|
|
|
|
export function registerManagerForCleanup(manager: CleanupTarget): void {
|
|
|
|
|
cleanupManagers.add(manager)
|
|
|
|
|
|
|
|
|
|
if (cleanupRegistered) return
|
|
|
|
|
cleanupRegistered = true
|
|
|
|
|
|
2026-03-27 15:23:48 +09:00
|
|
|
let cleanupPromise: Promise<void> | undefined
|
|
|
|
|
|
2026-03-31 16:59:04 -07:00
|
|
|
const cleanupAll = (): Promise<void> => {
|
|
|
|
|
if (cleanupPromise) return cleanupPromise
|
2026-03-27 15:23:48 +09:00
|
|
|
const promises: Promise<void>[] = []
|
2026-02-22 11:58:57 +09:00
|
|
|
for (const m of cleanupManagers) {
|
|
|
|
|
try {
|
2026-03-27 15:23:48 +09:00
|
|
|
promises.push(
|
|
|
|
|
Promise.resolve(m.shutdown()).catch((error) => {
|
|
|
|
|
log("[background-agent] Error during async shutdown cleanup:", error)
|
|
|
|
|
})
|
|
|
|
|
)
|
2026-02-22 11:58:57 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
log("[background-agent] Error during shutdown cleanup:", error)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-27 15:23:48 +09:00
|
|
|
cleanupPromise = Promise.allSettled(promises).then(() => {})
|
|
|
|
|
cleanupPromise.then(() => {
|
|
|
|
|
log("[background-agent] All shutdown cleanup completed")
|
|
|
|
|
})
|
2026-03-31 16:59:04 -07:00
|
|
|
|
|
|
|
|
return cleanupPromise
|
2026-02-22 11:58:57 +09:00
|
|
|
}
|
|
|
|
|
|
2026-04-18 19:32:04 +09:00
|
|
|
const registerSignal = (signal: ProcessCleanupSignal, exitAfter: boolean): void => {
|
2026-02-22 11:58:57 +09:00
|
|
|
const listener = registerProcessSignal(signal, cleanupAll, exitAfter)
|
2026-04-18 19:32:04 +09:00
|
|
|
cleanupSignalHandlers.set(signal, listener)
|
2026-02-22 11:58:57 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
registerSignal("SIGINT", true)
|
|
|
|
|
registerSignal("SIGTERM", true)
|
|
|
|
|
if (process.platform === "win32") {
|
|
|
|
|
registerSignal("SIGBREAK", true)
|
|
|
|
|
}
|
|
|
|
|
registerSignal("beforeExit", false)
|
|
|
|
|
registerSignal("exit", false)
|
2026-05-11 18:43:40 +09:00
|
|
|
|
|
|
|
|
if (isProcessCleanupErrorHandlersDisabled()) {
|
|
|
|
|
log(
|
|
|
|
|
`[background-agent] ${PROCESS_CLEANUP_DISABLE_ENV} is set; skipping global uncaughtException/unhandledRejection handler registration. `
|
|
|
|
|
+ "Signal handlers (SIGINT/SIGTERM/beforeExit/exit) remain active.",
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 19:32:04 +09:00
|
|
|
cleanupErrorHandlers.set("uncaughtException", registerErrorEvent("uncaughtException", cleanupAll))
|
|
|
|
|
cleanupErrorHandlers.set("unhandledRejection", registerErrorEvent("unhandledRejection", cleanupAll))
|
2026-02-22 11:58:57 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function unregisterManagerForCleanup(manager: CleanupTarget): void {
|
|
|
|
|
cleanupManagers.delete(manager)
|
|
|
|
|
|
|
|
|
|
if (cleanupManagers.size > 0) return
|
|
|
|
|
|
2026-04-18 19:32:04 +09:00
|
|
|
for (const [signal, listener] of cleanupSignalHandlers.entries()) {
|
|
|
|
|
process.off(signal, listener)
|
|
|
|
|
}
|
|
|
|
|
for (const [signal, listener] of cleanupErrorHandlers.entries()) {
|
2026-02-22 11:58:57 +09:00
|
|
|
process.off(signal, listener)
|
|
|
|
|
}
|
2026-04-18 19:32:04 +09:00
|
|
|
cleanupSignalHandlers.clear()
|
|
|
|
|
cleanupErrorHandlers.clear()
|
2026-02-22 11:58:57 +09:00
|
|
|
cleanupRegistered = false
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 01:27:51 +09:00
|
|
|
/** @internal - test-only reset for module-level singleton state */
|
2026-02-22 11:58:57 +09:00
|
|
|
export function _resetForTesting(): void {
|
|
|
|
|
for (const manager of [...cleanupManagers]) {
|
|
|
|
|
cleanupManagers.delete(manager)
|
|
|
|
|
}
|
2026-04-18 19:32:04 +09:00
|
|
|
for (const [signal, listener] of cleanupSignalHandlers.entries()) {
|
|
|
|
|
process.off(signal, listener)
|
|
|
|
|
}
|
|
|
|
|
for (const [signal, listener] of cleanupErrorHandlers.entries()) {
|
2026-02-22 11:58:57 +09:00
|
|
|
process.off(signal, listener)
|
|
|
|
|
}
|
2026-04-18 19:32:04 +09:00
|
|
|
cleanupSignalHandlers.clear()
|
|
|
|
|
cleanupErrorHandlers.clear()
|
2026-02-22 11:58:57 +09:00
|
|
|
cleanupRegistered = false
|
|
|
|
|
}
|