2026-04-18 19:32:04 +09:00
/// <reference types="bun-types" />
2026-03-31 16:59:04 -07:00
import { afterEach , beforeEach , describe , expect , mock , spyOn , test } from "bun:test"
2026-02-22 11:59:06 +09:00
import {
2026-03-31 16:59:04 -07:00
_resetForTesting ,
2026-02-22 11:59:06 +09:00
registerManagerForCleanup ,
unregisterManagerForCleanup ,
} from "./process-cleanup"
2026-04-18 19:32:04 +09:00
import { flushMicrotasks , getNewListener } from "./process-cleanup.test-helpers"
2026-02-22 11:59:06 +09:00
2026-03-31 16:59:04 -07:00
type CleanupManager = {
shutdown : ( ) = > void | Promise < void >
}
2026-02-22 11:59:06 +09:00
2026-03-31 16:59:04 -07:00
describe ( "#given process cleanup registration" , ( ) = > {
const registeredManagers : CleanupManager [ ] = [ ]
beforeEach ( ( ) = > {
2026-04-18 19:57:26 +09:00
process . exitCode = 0
2026-03-31 16:59:04 -07:00
registeredManagers . length = 0
_resetForTesting ( )
2026-02-22 11:59:06 +09:00
} )
afterEach ( ( ) = > {
for ( const manager of [ . . . registeredManagers ] ) {
unregisterManagerForCleanup ( manager )
}
2026-03-31 16:59:04 -07:00
2026-04-18 19:57:26 +09:00
process . exitCode = 0
2026-03-31 16:59:04 -07:00
_resetForTesting ( )
2026-02-22 11:59:06 +09:00
} )
2026-03-31 16:59:04 -07:00
describe ( "#given the first cleanup manager" , ( ) = > {
test ( "#when registerManagerForCleanup runs #then signal handlers are registered" , ( ) = > {
const sigintListenersBefore = process . listeners ( "SIGINT" )
const sigtermListenersBefore = process . listeners ( "SIGTERM" )
const beforeExitListenersBefore = process . listeners ( "beforeExit" )
const exitListenersBefore = process . listeners ( "exit" )
const manager = { shutdown : mock ( ( ) = > { } ) }
2026-02-22 11:59:06 +09:00
registeredManagers . push ( manager )
registerManagerForCleanup ( manager )
2026-03-31 16:59:04 -07:00
expect ( process . listeners ( "SIGINT" ) ) . toHaveLength ( sigintListenersBefore . length + 1 )
expect ( process . listeners ( "SIGTERM" ) ) . toHaveLength ( sigtermListenersBefore . length + 1 )
expect ( process . listeners ( "beforeExit" ) ) . toHaveLength ( beforeExitListenersBefore . length + 1 )
expect ( process . listeners ( "exit" ) ) . toHaveLength ( exitListenersBefore . length + 1 )
if ( process . platform === "win32" ) {
expect ( process . listeners ( "SIGBREAK" ) . length ) . toBeGreaterThan ( 0 )
}
2026-02-22 11:59:06 +09:00
} )
2026-03-31 16:59:04 -07:00
test ( "#when the exit listener runs #then the registered manager shuts down" , ( ) = > {
const exitListenersBefore = process . listeners ( "exit" )
const shutdown = mock ( ( ) = > { } )
const manager = { shutdown }
2026-02-22 11:59:06 +09:00
registeredManagers . push ( manager )
registerManagerForCleanup ( manager )
2026-03-31 16:59:04 -07:00
const exitListener = getNewListener ( "exit" , exitListenersBefore )
exitListener ( )
2026-02-22 11:59:06 +09:00
2026-03-31 16:59:04 -07:00
expect ( shutdown ) . toHaveBeenCalledTimes ( 1 )
2026-02-22 11:59:06 +09:00
} )
2026-03-31 16:59:04 -07:00
test ( "#when cleanup finishes after SIGINT #then the fallback exit timer is cleared" , async ( ) = > {
const sigintListenersBefore = process . listeners ( "SIGINT" )
2026-04-18 19:32:04 +09:00
const setTimeoutSpy = spyOn ( globalThis , "setTimeout" )
2026-03-31 16:59:04 -07:00
const clearTimeoutSpy = spyOn ( globalThis , "clearTimeout" )
try {
const manager = {
shutdown : mock ( async ( ) = > {
await Promise . resolve ( )
} ) ,
}
registeredManagers . push ( manager )
registerManagerForCleanup ( manager )
const sigintListener = getNewListener ( "SIGINT" , sigintListenersBefore )
sigintListener ( )
await flushMicrotasks ( )
expect ( setTimeoutSpy ) . toHaveBeenCalledTimes ( 1 )
2026-04-18 19:32:04 +09:00
expect ( clearTimeoutSpy ) . toHaveBeenCalledTimes ( 1 )
2026-03-31 16:59:04 -07:00
} finally {
setTimeoutSpy . mockRestore ( )
clearTimeoutSpy . mockRestore ( )
}
} )
} )
describe ( "#given multiple cleanup managers" , ( ) = > {
test ( "#when the exit listener runs #then every registered manager shuts down" , ( ) = > {
const exitListenersBefore = process . listeners ( "exit" )
const shutdownOne = mock ( ( ) = > { } )
const shutdownTwo = mock ( ( ) = > { } )
const shutdownThree = mock ( ( ) = > { } )
const managers = [
{ shutdown : shutdownOne } ,
{ shutdown : shutdownTwo } ,
{ shutdown : shutdownThree } ,
]
registeredManagers . push ( . . . managers )
for ( const manager of managers ) {
registerManagerForCleanup ( manager )
}
const exitListener = getNewListener ( "exit" , exitListenersBefore )
exitListener ( )
expect ( shutdownOne ) . toHaveBeenCalledTimes ( 1 )
expect ( shutdownTwo ) . toHaveBeenCalledTimes ( 1 )
expect ( shutdownThree ) . toHaveBeenCalledTimes ( 1 )
2026-02-22 11:59:06 +09:00
} )
2026-03-31 16:59:04 -07:00
test ( "#when another manager registers #then signal handlers are not duplicated" , ( ) = > {
const managerOne = { shutdown : mock ( ( ) = > { } ) }
const managerTwo = { shutdown : mock ( ( ) = > { } ) }
registeredManagers . push ( managerOne , managerTwo )
2026-02-22 11:59:06 +09:00
2026-03-31 16:59:04 -07:00
registerManagerForCleanup ( managerOne )
const sigintListenersAfterFirstRegistration = process . listeners ( "SIGINT" ) . length
2026-02-22 11:59:06 +09:00
2026-03-31 16:59:04 -07:00
registerManagerForCleanup ( managerTwo )
2026-02-22 11:59:06 +09:00
2026-03-31 16:59:04 -07:00
expect ( process . listeners ( "SIGINT" ) ) . toHaveLength ( sigintListenersAfterFirstRegistration )
2026-02-22 11:59:06 +09:00
} )
2026-04-18 19:32:04 +09:00
test ( "#given two managers registered #when uncaughtException fires #then both shutdowns called" , async ( ) = > {
2026-05-01 16:04:03 +08:00
const exitSpy = spyOn ( process , "exit" ) . mockImplementation ( ( ( ) = > undefined ) as never )
2026-04-18 19:32:04 +09:00
const shutdownOne = mock ( ( ) = > { } )
const shutdownTwo = mock ( ( ) = > { } )
const managerOne = { shutdown : shutdownOne }
const managerTwo = { shutdown : shutdownTwo }
registeredManagers . push ( managerOne , managerTwo )
try {
registerManagerForCleanup ( managerOne )
registerManagerForCleanup ( managerTwo )
process . emit ( "uncaughtException" , new Error ( "boom" ) )
await flushMicrotasks ( )
expect ( shutdownOne ) . toHaveBeenCalledTimes ( 1 )
expect ( shutdownTwo ) . toHaveBeenCalledTimes ( 1 )
expect ( process . exitCode ) . toBe ( 1 )
2026-05-01 16:04:03 +08:00
expect ( exitSpy ) . toHaveBeenCalledWith ( 1 )
2026-04-18 19:32:04 +09:00
} finally {
exitSpy . mockRestore ( )
}
} )
2026-02-22 11:59:06 +09:00
} )
2026-03-31 16:59:04 -07:00
describe ( "#given cleanup managers are unregistered" , ( ) = > {
test ( "#when the last manager unregisters #then signal handlers are removed" , ( ) = > {
const sigintListenersBefore = process . listeners ( "SIGINT" )
const sigtermListenersBefore = process . listeners ( "SIGTERM" )
const beforeExitListenersBefore = process . listeners ( "beforeExit" )
const exitListenersBefore = process . listeners ( "exit" )
const manager = { shutdown : mock ( ( ) = > { } ) }
2026-02-22 11:59:06 +09:00
registeredManagers . push ( manager )
registerManagerForCleanup ( manager )
unregisterManagerForCleanup ( manager )
registeredManagers . length = 0
2026-03-31 16:59:04 -07:00
expect ( process . listeners ( "SIGINT" ) ) . toHaveLength ( sigintListenersBefore . length )
expect ( process . listeners ( "SIGTERM" ) ) . toHaveLength ( sigtermListenersBefore . length )
expect ( process . listeners ( "beforeExit" ) ) . toHaveLength ( beforeExitListenersBefore . length )
expect ( process . listeners ( "exit" ) ) . toHaveLength ( exitListenersBefore . length )
2026-02-22 11:59:06 +09:00
} )
2026-03-31 16:59:04 -07:00
test ( "#when one manager remains registered #then cleanup handlers stay active for it" , ( ) = > {
const exitListenersBefore = process . listeners ( "exit" )
const remainingManagerShutdown = mock ( ( ) = > { } )
const removedManagerShutdown = mock ( ( ) = > { } )
const remainingManager = { shutdown : remainingManagerShutdown }
const removedManager = { shutdown : removedManagerShutdown }
registeredManagers . push ( remainingManager , removedManager )
2026-02-22 11:59:06 +09:00
2026-03-31 16:59:04 -07:00
registerManagerForCleanup ( remainingManager )
registerManagerForCleanup ( removedManager )
unregisterManagerForCleanup ( removedManager )
2026-02-22 11:59:06 +09:00
2026-03-31 16:59:04 -07:00
const exitListener = getNewListener ( "exit" , exitListenersBefore )
exitListener ( )
2026-02-22 11:59:06 +09:00
2026-03-31 16:59:04 -07:00
expect ( remainingManagerShutdown ) . toHaveBeenCalledTimes ( 1 )
expect ( removedManagerShutdown ) . not . toHaveBeenCalled ( )
2026-02-22 11:59:06 +09:00
} )
2026-04-18 19:32:04 +09:00
test ( "#given uncaughtException handler registered #when manager is unregistered via unregisterManagerForCleanup #then subsequent events do not invoke that manager" , ( ) = > {
const uncaughtExceptionListenersBefore = process . listeners ( "uncaughtException" )
const shutdown = mock ( ( ) = > { } )
const manager = { shutdown }
registeredManagers . push ( manager )
registerManagerForCleanup ( manager )
expect ( process . listeners ( "uncaughtException" ) ) . toHaveLength (
uncaughtExceptionListenersBefore . length + 1 ,
)
unregisterManagerForCleanup ( manager )
registeredManagers . length = 0
process . emit ( "uncaughtException" , new Error ( "boom" ) )
expect ( shutdown ) . not . toHaveBeenCalled ( )
} )
} )
describe ( "#given uncaught exception and rejection cleanup" , ( ) = > {
2026-05-01 16:04:03 +08:00
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 ( ( ( ) = > undefined ) as never )
2026-04-18 19:32:04 +09:00
const shutdown = mock ( ( ) = > { } )
const manager = { shutdown }
registeredManagers . push ( manager )
try {
registerManagerForCleanup ( manager )
process . emit ( "uncaughtException" , new Error ( "boom" ) )
await flushMicrotasks ( )
expect ( shutdown ) . toHaveBeenCalledTimes ( 1 )
expect ( process . exitCode ) . toBe ( 1 )
2026-05-01 16:04:03 +08:00
expect ( exitSpy ) . toHaveBeenCalledWith ( 1 )
2026-04-18 19:32:04 +09:00
} finally {
exitSpy . mockRestore ( )
}
} )
2026-05-01 16:04:03 +08:00
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 ( ( ( ) = > undefined ) as never )
2026-04-18 19:32:04 +09:00
const shutdown = mock ( ( ) = > { } )
const manager = { shutdown }
registeredManagers . push ( manager )
try {
registerManagerForCleanup ( manager )
process . emit ( "unhandledRejection" , new Error ( "boom" ) , Promise . resolve ( ) )
await flushMicrotasks ( )
expect ( shutdown ) . toHaveBeenCalledTimes ( 1 )
expect ( process . exitCode ) . toBe ( 1 )
2026-05-01 16:04:03 +08:00
expect ( exitSpy ) . toHaveBeenCalledWith ( 1 )
2026-04-18 19:32:04 +09:00
} finally {
exitSpy . mockRestore ( )
}
} )
test ( "#given _resetForTesting() called #when event fires #then no cleanup runs" , ( ) = > {
const uncaughtExceptionListenersBefore = process . listeners ( "uncaughtException" )
const shutdown = mock ( ( ) = > { } )
const manager = { shutdown }
registerManagerForCleanup ( manager )
expect ( process . listeners ( "uncaughtException" ) ) . toHaveLength (
uncaughtExceptionListenersBefore . length + 1 ,
)
_resetForTesting ( )
process . emit ( "uncaughtException" , new Error ( "boom" ) )
expect ( shutdown ) . not . toHaveBeenCalled ( )
expect ( process . listeners ( "uncaughtException" ) ) . toHaveLength (
uncaughtExceptionListenersBefore . length ,
)
} )
2026-04-30 14:47:09 +08:00
test ( "#given cleanup itself throws re-entrant uncaughtException #when event fires repeatedly #then listener body runs only once AND no further log calls occur" , async ( ) = > {
// Regression guard for log explosion (157 GB in minutes) observed when
// shutdown() code path itself emits uncaughtException (e.g. EPIPE while
// closing a broken pipe). Before the fix, every re-entry logged another
// line and re-ran cleanup, producing an unbounded loop that filled disk.
const reentrantShutdown = mock ( ( ) = > {
process . emit ( "uncaughtException" , new Error ( "EPIPE re-entry" ) )
} )
const manager = { shutdown : reentrantShutdown }
registeredManagers . push ( manager )
registerManagerForCleanup ( manager )
process . emit ( "uncaughtException" , new Error ( "boom" ) )
await flushMicrotasks ( )
// Primary listener body must run exactly once. Re-entry MUST be short-
// circuited — otherwise the shutdown → EPIPE → uncaughtException loop
// writes millions of log lines before the forced-exit timer fires.
expect ( reentrantShutdown . mock . calls . length ) . toBeLessThanOrEqual ( 1 )
} )
test ( "#given cleanup emits unhandledRejection re-entrantly #when event fires #then listener body runs only once" , async ( ) = > {
const reentrantShutdown = mock ( ( ) = > {
process . emit ( "unhandledRejection" , new Error ( "re-entry" ) , Promise . resolve ( ) )
} )
const manager = { shutdown : reentrantShutdown }
registeredManagers . push ( manager )
registerManagerForCleanup ( manager )
process . emit ( "unhandledRejection" , new Error ( "boom" ) , Promise . resolve ( ) )
await flushMicrotasks ( )
expect ( reentrantShutdown . mock . calls . length ) . toBeLessThanOrEqual ( 1 )
} )
2026-02-22 11:59:06 +09:00
} )
} )