32fab88d68
scheduleForcedExit() sets process.exitCode which taints the bun test runner's own exit code for the entire suite. This caused CI to fail even though all tests passed individually. Fix: - Add __disableScheduledForcedExitForTesting / __enableScheduledForcedExitForTesting seams to skip scheduleForcedExit() during tests - beforeEach disables forced exit; afterEach re-enables - The 'fallback exit timer' test explicitly re-enables to verify setTimeout/clearTimeout - Remove process.exitCode and exitSpy assertions that required forced exit to be active (shutdown call counts are sufficient to verify behavior)
154 lines
4.8 KiB
TypeScript
154 lines
4.8 KiB
TypeScript
import { log } from "../../shared"
|
|
|
|
type ProcessCleanupSignal = NodeJS.Signals | "beforeExit" | "exit"
|
|
type ProcessCleanupErrorEvent = "uncaughtException" | "unhandledRejection"
|
|
|
|
/** @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
|
|
}
|
|
|
|
function scheduleForcedExit(
|
|
cleanupResult: void | Promise<void>,
|
|
exitCode: number,
|
|
exitAfterCleanup = false,
|
|
): void {
|
|
if (!_scheduleForcedExitEnabled) return
|
|
process.exitCode = exitCode
|
|
const exitTimeout = setTimeout(() => process.exit(), 6000)
|
|
void Promise.resolve(cleanupResult).finally(() => {
|
|
clearTimeout(exitTimeout)
|
|
if (exitAfterCleanup) {
|
|
process.exit(exitCode)
|
|
}
|
|
})
|
|
}
|
|
|
|
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) => {
|
|
// 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)
|
|
log(`[background-agent] ${signal} received during shutdown cleanup:`, error)
|
|
scheduleForcedExit(handler(error), 1, true)
|
|
}
|
|
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
|
|
}
|