Merge pull request #3639 from auyua9/fix/fatal-cleanup-exit

fix(background): exit after fatal cleanup
This commit is contained in:
YeonGyu-Kim
2026-05-05 04:15:50 +09:00
committed by GitHub
2 changed files with 17 additions and 16 deletions
@@ -135,9 +135,7 @@ describe("#given process cleanup registration", () => {
}) })
test("#given two managers registered #when uncaughtException fires #then both shutdowns called", async () => { test("#given two managers registered #when uncaughtException fires #then both shutdowns called", async () => {
const exitSpy = spyOn(process, "exit").mockImplementation((code?: number): never => { const exitSpy = spyOn(process, "exit").mockImplementation((() => undefined) as never)
throw new Error(`Unexpected process.exit(${String(code)})`)
})
const shutdownOne = mock(() => {}) const shutdownOne = mock(() => {})
const shutdownTwo = mock(() => {}) const shutdownTwo = mock(() => {})
const managerOne = { shutdown: shutdownOne } const managerOne = { shutdown: shutdownOne }
@@ -154,7 +152,7 @@ describe("#given process cleanup registration", () => {
expect(shutdownOne).toHaveBeenCalledTimes(1) expect(shutdownOne).toHaveBeenCalledTimes(1)
expect(shutdownTwo).toHaveBeenCalledTimes(1) expect(shutdownTwo).toHaveBeenCalledTimes(1)
expect(process.exitCode).toBe(1) expect(process.exitCode).toBe(1)
expect(exitSpy).not.toHaveBeenCalled() expect(exitSpy).toHaveBeenCalledWith(1)
} finally { } finally {
exitSpy.mockRestore() exitSpy.mockRestore()
} }
@@ -219,10 +217,8 @@ describe("#given process cleanup registration", () => {
}) })
describe("#given uncaught exception and rejection cleanup", () => { describe("#given uncaught exception and rejection cleanup", () => {
test("#given manager registered AND process emits uncaughtException #when event fires #then manager.shutdown() called AND process.exitCode set to 1", async () => { test("#given manager registered AND process emits uncaughtException #when event fires #then manager shuts down before process exits", async () => {
const exitSpy = spyOn(process, "exit").mockImplementation((code?: number): never => { const exitSpy = spyOn(process, "exit").mockImplementation((() => undefined) as never)
throw new Error(`Unexpected process.exit(${String(code)})`)
})
const shutdown = mock(() => {}) const shutdown = mock(() => {})
const manager = { shutdown } const manager = { shutdown }
registeredManagers.push(manager) registeredManagers.push(manager)
@@ -235,16 +231,14 @@ describe("#given process cleanup registration", () => {
expect(shutdown).toHaveBeenCalledTimes(1) expect(shutdown).toHaveBeenCalledTimes(1)
expect(process.exitCode).toBe(1) expect(process.exitCode).toBe(1)
expect(exitSpy).not.toHaveBeenCalled() expect(exitSpy).toHaveBeenCalledWith(1)
} finally { } finally {
exitSpy.mockRestore() exitSpy.mockRestore()
} }
}) })
test("#given manager registered AND process emits unhandledRejection #when event fires #then manager.shutdown() called AND process.exitCode set to 1", async () => { test("#given manager registered AND process emits unhandledRejection #when event fires #then manager shuts down before process exits", async () => {
const exitSpy = spyOn(process, "exit").mockImplementation((code?: number): never => { const exitSpy = spyOn(process, "exit").mockImplementation((() => undefined) as never)
throw new Error(`Unexpected process.exit(${String(code)})`)
})
const shutdown = mock(() => {}) const shutdown = mock(() => {})
const manager = { shutdown } const manager = { shutdown }
registeredManagers.push(manager) registeredManagers.push(manager)
@@ -257,7 +251,7 @@ describe("#given process cleanup registration", () => {
expect(shutdown).toHaveBeenCalledTimes(1) expect(shutdown).toHaveBeenCalledTimes(1)
expect(process.exitCode).toBe(1) expect(process.exitCode).toBe(1)
expect(exitSpy).not.toHaveBeenCalled() expect(exitSpy).toHaveBeenCalledWith(1)
} finally { } finally {
exitSpy.mockRestore() exitSpy.mockRestore()
} }
@@ -3,11 +3,18 @@ import { log } from "../../shared"
type ProcessCleanupSignal = NodeJS.Signals | "beforeExit" | "exit" type ProcessCleanupSignal = NodeJS.Signals | "beforeExit" | "exit"
type ProcessCleanupErrorEvent = "uncaughtException" | "unhandledRejection" type ProcessCleanupErrorEvent = "uncaughtException" | "unhandledRejection"
function scheduleForcedExit(cleanupResult: void | Promise<void>, exitCode: number): void { function scheduleForcedExit(
cleanupResult: void | Promise<void>,
exitCode: number,
exitAfterCleanup = false,
): void {
process.exitCode = exitCode process.exitCode = exitCode
const exitTimeout = setTimeout(() => process.exit(), 6000) const exitTimeout = setTimeout(() => process.exit(), 6000)
void Promise.resolve(cleanupResult).finally(() => { void Promise.resolve(cleanupResult).finally(() => {
clearTimeout(exitTimeout) clearTimeout(exitTimeout)
if (exitAfterCleanup) {
process.exit(exitCode)
}
}) })
} }
@@ -32,7 +39,7 @@ function registerErrorEvent(
): (error: unknown) => void { ): (error: unknown) => void {
const listener = (error: unknown) => { const listener = (error: unknown) => {
log(`[background-agent] ${signal} received during shutdown cleanup:`, error) log(`[background-agent] ${signal} received during shutdown cleanup:`, error)
scheduleForcedExit(handler(error), 1) scheduleForcedExit(handler(error), 1, true)
} }
process.on(signal, listener) process.on(signal, listener)
return listener return listener