From 586a93f61081244d10f77e850a4ec711da8778bc Mon Sep 17 00:00:00 2001 From: John Doe Date: Mon, 12 Jan 2026 02:31:40 +0100 Subject: [PATCH] fix: add process exit handler --- packages/utils/src/lib/exit-process.ts | 43 +++++++ .../utils/src/lib/exit-process.unit.test.ts | 109 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 packages/utils/src/lib/exit-process.ts create mode 100644 packages/utils/src/lib/exit-process.unit.test.ts diff --git a/packages/utils/src/lib/exit-process.ts b/packages/utils/src/lib/exit-process.ts new file mode 100644 index 000000000..611e0da3c --- /dev/null +++ b/packages/utils/src/lib/exit-process.ts @@ -0,0 +1,43 @@ +import process from 'node:process'; + +/* eslint-disable @typescript-eslint/no-magic-numbers */ +const SIGNALS = [ + ['SIGINT', 130], + ['SIGTERM', 143], + ['SIGQUIT', 131], +] as const; +/* eslint-enable @typescript-eslint/no-magic-numbers */ + +export type FatalKind = 'uncaughtException' | 'unhandledRejection'; +type ExitHandlerOptions = + | { + onClose?: () => void; + onFatal: (err: unknown, kind?: FatalKind) => void; + } + | { + onClose: () => void; + onFatal?: never; + }; + +export function installExitHandlers(options: ExitHandlerOptions): void { + // Fatal errors + process.on('uncaughtException', err => { + options.onFatal?.(err, 'uncaughtException'); + }); + + process.on('unhandledRejection', reason => { + options.onFatal?.(reason, 'unhandledRejection'); + }); + + // Graceful shutdown signals + SIGNALS.forEach(([signal]) => { + process.on(signal, () => { + options.onClose?.(); + }); + }); + + // Normal exit + process.on('exit', () => { + options.onClose?.(); + }); +} diff --git a/packages/utils/src/lib/exit-process.unit.test.ts b/packages/utils/src/lib/exit-process.unit.test.ts new file mode 100644 index 000000000..92cf65b50 --- /dev/null +++ b/packages/utils/src/lib/exit-process.unit.test.ts @@ -0,0 +1,109 @@ +import process from 'node:process'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { installExitHandlers } from './exit-process.js'; + +describe('exit-process tests', () => { + const onFatal = vi.fn(); + const onClose = vi.fn(); + const processOnSpy = vi.spyOn(process, 'on'); + const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(vi.fn()); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + [ + 'uncaughtException', + 'unhandledRejection', + 'SIGINT', + 'SIGTERM', + 'SIGQUIT', + 'exit', + ].forEach(event => { + process.removeAllListeners(event); + }); + }); + + it('should install event listeners for all expected events', () => { + expect(() => installExitHandlers({ onFatal, onClose })).not.toThrow(); + + expect(processOnSpy).toHaveBeenCalledWith( + 'uncaughtException', + expect.any(Function), + ); + expect(processOnSpy).toHaveBeenCalledWith( + 'unhandledRejection', + expect.any(Function), + ); + expect(processOnSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('SIGQUIT', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('exit', expect.any(Function)); + }); + + it('should call onFatal with error and kind for uncaughtException', () => { + expect(() => installExitHandlers({ onFatal })).not.toThrow(); + + const testError = new Error('Test uncaught exception'); + + (process as any).emit('uncaughtException', testError); + + expect(onFatal).toHaveBeenCalledWith(testError, 'uncaughtException'); + expect(onFatal).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('should call onFatal with reason and kind for unhandledRejection', () => { + expect(() => installExitHandlers({ onFatal })).not.toThrow(); + + const testReason = 'Test unhandled rejection'; + + (process as any).emit('unhandledRejection', testReason); + + expect(onFatal).toHaveBeenCalledWith(testReason, 'unhandledRejection'); + expect(onFatal).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('should call onClose and exit with code 0 for SIGINT', () => { + expect(() => installExitHandlers({ onClose })).not.toThrow(); + + (process as any).emit('SIGINT'); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith(); + expect(onFatal).not.toHaveBeenCalled(); + }); + + it('should call onClose and exit with code 0 for SIGTERM', () => { + expect(() => installExitHandlers({ onClose })).not.toThrow(); + + (process as any).emit('SIGTERM'); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith(); + expect(onFatal).not.toHaveBeenCalled(); + }); + + it('should call onClose and exit with code 0 for SIGQUIT', () => { + expect(() => installExitHandlers({ onClose })).not.toThrow(); + + (process as any).emit('SIGQUIT'); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith(); + expect(onFatal).not.toHaveBeenCalled(); + }); + + it('should call onClose for normal exit', () => { + expect(() => installExitHandlers({ onClose })).not.toThrow(); + + (process as any).emit('exit'); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith(); + expect(onFatal).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); +});