From 586a93f61081244d10f77e850a4ec711da8778bc Mon Sep 17 00:00:00 2001 From: John Doe Date: Mon, 12 Jan 2026 02:31:40 +0100 Subject: [PATCH 1/8] 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(); + }); +}); From 71efc3449d0d4eade5111a238e287b913adf1798 Mon Sep 17 00:00:00 2001 From: John Doe Date: Mon, 12 Jan 2026 18:45:51 +0100 Subject: [PATCH 2/8] refactor: add int tests and refactor for reuse --- .../utils/src/lib/exit-process.int.test.ts | 118 +++++++++++ packages/utils/src/lib/exit-process.ts | 99 ++++++--- .../utils/src/lib/exit-process.unit.test.ts | 194 +++++++++++++++--- packages/utils/src/lib/logger.ts | 14 +- 4 files changed, 359 insertions(+), 66 deletions(-) create mode 100644 packages/utils/src/lib/exit-process.int.test.ts diff --git a/packages/utils/src/lib/exit-process.int.test.ts b/packages/utils/src/lib/exit-process.int.test.ts new file mode 100644 index 000000000..c02946a96 --- /dev/null +++ b/packages/utils/src/lib/exit-process.int.test.ts @@ -0,0 +1,118 @@ +import process from 'node:process'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { installExitHandlers } from './exit-process.js'; + +describe('installExitHandlers', () => { + const onError = 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({ onError, 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 onError with error and kind for uncaughtException', () => { + expect(() => installExitHandlers({ onError })).not.toThrow(); + + const testError = new Error('Test uncaught exception'); + + (process as any).emit('uncaughtException', testError); + + expect(onError).toHaveBeenCalledWith(testError, 'uncaughtException'); + expect(onError).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('should call onError with reason and kind for unhandledRejection', () => { + expect(() => installExitHandlers({ onError })).not.toThrow(); + + const testReason = 'Test unhandled rejection'; + + (process as any).emit('unhandledRejection', testReason); + + expect(onError).toHaveBeenCalledWith(testReason, 'unhandledRejection'); + expect(onError).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(130, { + kind: 'signal', + signal: 'SIGINT', + }); + expect(onError).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(143, { + kind: 'signal', + signal: 'SIGTERM', + }); + expect(onError).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(131, { + kind: 'signal', + signal: 'SIGQUIT', + }); + expect(onError).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(undefined, { kind: 'exit' }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/utils/src/lib/exit-process.ts b/packages/utils/src/lib/exit-process.ts index 611e0da3c..0f2a84588 100644 --- a/packages/utils/src/lib/exit-process.ts +++ b/packages/utils/src/lib/exit-process.ts @@ -1,43 +1,88 @@ +import os from 'node:os'; 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 */ +const isWindows = os.platform() === 'win32'; +// POSIX shells convention: exit status = 128 + signal number +// https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html#:~:text=When%20a%20command%20terminates%20on%20a%20fatal%20signal%20whose%20number%20is%20N%2C%20Bash%20uses%20the%20value%20128%2BN%20as%20the%20exit%20status. +const UNIX_SIGNAL_EXIT_CODE_OFFSET = 128; +const unixSignalExitCode = (signalNumber: number) => + UNIX_SIGNAL_EXIT_CODE_OFFSET + signalNumber; + +const SIGINT_CODE = 2; + +export const SIGNAL_EXIT_CODES = (): Record => { + const isWindowsRuntime = os.platform() === 'win32'; + return { + SIGINT: isWindowsRuntime ? SIGINT_CODE : unixSignalExitCode(SIGINT_CODE), + SIGTERM: unixSignalExitCode(15), + SIGQUIT: unixSignalExitCode(3), + }; +}; + +export const DEFAULT_FATAL_EXIT_CODE = 1; + +export type SignalName = 'SIGINT' | 'SIGTERM' | 'SIGQUIT'; 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 + +export type CloseReason = + | { kind: 'signal'; signal: SignalName } + | { kind: 'fatal'; fatal: FatalKind } + | { kind: 'exit' }; + +export type ExitHandlerOptions = { + onClose?: (code: number, reason: CloseReason) => void; + onError?: (err: unknown, kind: FatalKind) => void; + fatalExit?: boolean; + signalExit?: boolean; + fatalExitCode?: number; +}; + +export function installExitHandlers(options: ExitHandlerOptions = {}): void { + let closedReason: CloseReason | undefined; + const { + onClose, + onError, + fatalExit, + signalExit, + fatalExitCode = DEFAULT_FATAL_EXIT_CODE, + } = options; + + const close = (code: number, reason: CloseReason) => { + if (closedReason) return; + closedReason = reason; + onClose?.(code, reason); + }; + process.on('uncaughtException', err => { - options.onFatal?.(err, 'uncaughtException'); + onError?.(err, 'uncaughtException'); + if (fatalExit) + close(fatalExitCode, { + kind: 'fatal', + fatal: 'uncaughtException', + }); }); process.on('unhandledRejection', reason => { - options.onFatal?.(reason, 'unhandledRejection'); + onError?.(reason, 'unhandledRejection'); + if (fatalExit) + close(fatalExitCode, { + kind: 'fatal', + fatal: 'unhandledRejection', + }); }); - // Graceful shutdown signals - SIGNALS.forEach(([signal]) => { + (['SIGINT', 'SIGTERM', 'SIGQUIT'] as const).forEach(signal => { process.on(signal, () => { - options.onClose?.(); + close(SIGNAL_EXIT_CODES()[signal], { kind: 'signal', signal }); + if (signalExit) { + process.exit(SIGNAL_EXIT_CODES()[signal]); + } }); }); - // Normal exit - process.on('exit', () => { - options.onClose?.(); + process.on('exit', code => { + if (closedReason) return; + close(code, { kind: 'exit' }); }); } diff --git a/packages/utils/src/lib/exit-process.unit.test.ts b/packages/utils/src/lib/exit-process.unit.test.ts index 92cf65b50..bd35d72b9 100644 --- a/packages/utils/src/lib/exit-process.unit.test.ts +++ b/packages/utils/src/lib/exit-process.unit.test.ts @@ -1,9 +1,9 @@ import process from 'node:process'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { installExitHandlers } from './exit-process.js'; +import { SIGNAL_EXIT_CODES, installExitHandlers } from './exit-process.js'; describe('exit-process tests', () => { - const onFatal = vi.fn(); + const onError = vi.fn(); const onClose = vi.fn(); const processOnSpy = vi.spyOn(process, 'on'); const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(vi.fn()); @@ -26,7 +26,7 @@ describe('exit-process tests', () => { }); it('should install event listeners for all expected events', () => { - expect(() => installExitHandlers({ onFatal, onClose })).not.toThrow(); + expect(() => installExitHandlers({ onError, onClose })).not.toThrow(); expect(processOnSpy).toHaveBeenCalledWith( 'uncaughtException', @@ -42,68 +42,208 @@ describe('exit-process tests', () => { expect(processOnSpy).toHaveBeenCalledWith('exit', expect.any(Function)); }); - it('should call onFatal with error and kind for uncaughtException', () => { - expect(() => installExitHandlers({ onFatal })).not.toThrow(); + it('should call onError with error and kind for uncaughtException', () => { + expect(() => installExitHandlers({ onError })).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(onError).toHaveBeenCalledWith(testError, 'uncaughtException'); + expect(onError).toHaveBeenCalledTimes(1); expect(onClose).not.toHaveBeenCalled(); }); - it('should call onFatal with reason and kind for unhandledRejection', () => { - expect(() => installExitHandlers({ onFatal })).not.toThrow(); + it('should call onError with reason and kind for unhandledRejection', () => { + expect(() => installExitHandlers({ onError })).not.toThrow(); const testReason = 'Test unhandled rejection'; (process as any).emit('unhandledRejection', testReason); - expect(onFatal).toHaveBeenCalledWith(testReason, 'unhandledRejection'); - expect(onFatal).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(testReason, 'unhandledRejection'); + expect(onError).toHaveBeenCalledTimes(1); expect(onClose).not.toHaveBeenCalled(); }); - it('should call onClose and exit with code 0 for SIGINT', () => { - expect(() => installExitHandlers({ onClose })).not.toThrow(); + it('should call onClose with correct code and reason for SIGINT', () => { + expect(() => + installExitHandlers({ onClose, signalExit: true }), + ).not.toThrow(); (process as any).emit('SIGINT'); expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(); - expect(onFatal).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, { + kind: 'signal', + signal: 'SIGINT', + }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT); }); - it('should call onClose and exit with code 0 for SIGTERM', () => { - expect(() => installExitHandlers({ onClose })).not.toThrow(); + it('should call onClose with correct code and reason for SIGTERM', () => { + expect(() => + installExitHandlers({ onClose, signalExit: true }), + ).not.toThrow(); (process as any).emit('SIGTERM'); expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(); - expect(onFatal).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM, { + kind: 'signal', + signal: 'SIGTERM', + }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM); }); - it('should call onClose and exit with code 0 for SIGQUIT', () => { - expect(() => installExitHandlers({ onClose })).not.toThrow(); + it('should call onClose with correct code and reason for SIGQUIT', () => { + expect(() => + installExitHandlers({ onClose, signalExit: true }), + ).not.toThrow(); (process as any).emit('SIGQUIT'); expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(); - expect(onFatal).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGQUIT, { + kind: 'signal', + signal: 'SIGQUIT', + }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGQUIT); }); - it('should call onClose for normal exit', () => { + it('should not exit process when signalExit is false', () => { + expect(() => + installExitHandlers({ onClose, signalExit: false }), + ).not.toThrow(); + + (process as any).emit('SIGINT'); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, { + kind: 'signal', + signal: 'SIGINT', + }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should not exit process when signalExit is not set', () => { expect(() => installExitHandlers({ onClose })).not.toThrow(); - (process as any).emit('exit'); + (process as any).emit('SIGTERM'); expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(); - expect(onFatal).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM, { + kind: 'signal', + signal: 'SIGTERM', + }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should call onClose with exit code and reason for normal exit', () => { + expect(() => installExitHandlers({ onClose })).not.toThrow(); + + const exitCode = 42; + (process as any).emit('exit', exitCode); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith(exitCode, { kind: 'exit' }); + expect(onError).not.toHaveBeenCalled(); expect(processExitSpy).not.toHaveBeenCalled(); }); + + it('should call onClose with fatal reason when fatalExit is true', () => { + expect(() => + installExitHandlers({ onError, onClose, fatalExit: true }), + ).not.toThrow(); + + const testError = new Error('Test uncaught exception'); + + (process as any).emit('uncaughtException', testError); + + expect(onError).toHaveBeenCalledWith(testError, 'uncaughtException'); + expect(onError).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith(1, { + kind: 'fatal', + fatal: 'uncaughtException', + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should use custom fatalExitCode when fatalExit is true', () => { + expect(() => + installExitHandlers({ + onError, + onClose, + fatalExit: true, + fatalExitCode: 42, + }), + ).not.toThrow(); + + const testError = new Error('Test uncaught exception'); + + (process as any).emit('uncaughtException', testError); + + expect(onError).toHaveBeenCalledWith(testError, 'uncaughtException'); + expect(onError).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith(42, { + kind: 'fatal', + fatal: 'uncaughtException', + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should call onClose with fatal reason for unhandledRejection when fatalExit is true', () => { + expect(() => + installExitHandlers({ onError, onClose, fatalExit: true }), + ).not.toThrow(); + + const testReason = 'Test unhandled rejection'; + + (process as any).emit('unhandledRejection', testReason); + + expect(onError).toHaveBeenCalledWith(testReason, 'unhandledRejection'); + expect(onError).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith(1, { + kind: 'fatal', + fatal: 'unhandledRejection', + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should have correct SIGINT exit code based on platform', () => { + const os = require('node:os'); + const isWindows = os.platform() === 'win32'; + const SIGINT_CODE = 2; + const UNIX_SIGNAL_EXIT_CODE_OFFSET = 128; + + const expectedSigintCode = isWindows + ? SIGINT_CODE + : UNIX_SIGNAL_EXIT_CODE_OFFSET + SIGINT_CODE; + + if (isWindows) { + expect(expectedSigintCode).toBe(2); + } else { + expect(expectedSigintCode).toBe(130); + expect(SIGNAL_EXIT_CODES().SIGINT).toBe(130); + } + }); + + it('should calculate Windows exit codes correctly when platform is mocked to Windows', () => { + const osSpy = vi + .spyOn(require('node:os'), 'platform') + .mockReturnValue('win32'); + + const exitCodes = SIGNAL_EXIT_CODES(); + + expect(exitCodes.SIGINT).toBe(2); // SIGINT_CODE = 2 on Windows + expect(exitCodes.SIGTERM).toBe(143); // 128 + 15 = 143 + expect(exitCodes.SIGQUIT).toBe(131); // 128 + 3 = 131 + + osSpy.mockRestore(); + }); }); diff --git a/packages/utils/src/lib/logger.ts b/packages/utils/src/lib/logger.ts index b607c5e79..0051bfabb 100644 --- a/packages/utils/src/lib/logger.ts +++ b/packages/utils/src/lib/logger.ts @@ -1,11 +1,11 @@ /* eslint-disable max-lines, no-console, @typescript-eslint/class-methods-use-this */ import ansis, { type AnsiColors } from 'ansis'; -import os from 'node:os'; import ora, { type Ora } from 'ora'; import { formatCommandStatus } from './command.js'; import { dateToUnixTimestamp } from './dates.js'; import { isEnvVarEnabled } from './env.js'; import { stringifyError } from './errors.js'; +import { SIGNAL_EXIT_CODES } from './exit-process.js'; import { formatDuration, indentLines, transformLines } from './formatting.js'; import { settlePromise } from './promises.js'; @@ -28,12 +28,6 @@ export type DebugLogOptions = LogOptions & { const HEX_RADIX = 16; -const SIGINT_CODE = 2; -// https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html#:~:text=When%20a%20command%20terminates%20on%20a%20fatal%20signal%20whose%20number%20is%20N%2C%20Bash%20uses%20the%20value%20128%2BN%20as%20the%20exit%20status. -const SIGNALS_CODE_OFFSET_UNIX = 128; -const SIGINT_EXIT_CODE_UNIX = SIGNALS_CODE_OFFSET_UNIX + SIGINT_CODE; -const SIGINT_EXIT_CODE_WINDOWS = SIGINT_CODE; - /** * Rich logging implementation for Code PushUp CLI, plugins, etc. * @@ -77,11 +71,7 @@ export class Logger { this.newline(); this.error(ansis.bold('Cancelled by SIGINT')); // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit - process.exit( - os.platform() === 'win32' - ? SIGINT_EXIT_CODE_WINDOWS - : SIGINT_EXIT_CODE_UNIX, - ); + process.exit(SIGNAL_EXIT_CODES().SIGINT); }; /** From 846729033769865e9e914613843a5b473433fe14 Mon Sep 17 00:00:00 2001 From: John Doe Date: Mon, 12 Jan 2026 18:47:49 +0100 Subject: [PATCH 3/8] refactor: add no double close test --- .../utils/src/lib/exit-process.unit.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/utils/src/lib/exit-process.unit.test.ts b/packages/utils/src/lib/exit-process.unit.test.ts index bd35d72b9..4e89cca58 100644 --- a/packages/utils/src/lib/exit-process.unit.test.ts +++ b/packages/utils/src/lib/exit-process.unit.test.ts @@ -246,4 +246,22 @@ describe('exit-process tests', () => { osSpy.mockRestore(); }); + + it('should call onClose only once even when close is called multiple times', () => { + expect(() => + installExitHandlers({ onClose, signalExit: true }), + ).not.toThrow(); + + (process as any).emit('SIGINT'); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, { + kind: 'signal', + signal: 'SIGINT', + }); + onClose.mockClear(); + (process as any).emit('SIGTERM'); + expect(onClose).not.toHaveBeenCalled(); + (process as any).emit('exit', 0); + expect(onClose).not.toHaveBeenCalled(); + }); }); From 177355e8fecb6ff2728c64457883002bfdc0a685 Mon Sep 17 00:00:00 2001 From: John Doe Date: Mon, 12 Jan 2026 18:59:57 +0100 Subject: [PATCH 4/8] refactor: fix int test --- packages/utils/src/lib/exit-process.int.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/utils/src/lib/exit-process.int.test.ts b/packages/utils/src/lib/exit-process.int.test.ts index c02946a96..728d69296 100644 --- a/packages/utils/src/lib/exit-process.int.test.ts +++ b/packages/utils/src/lib/exit-process.int.test.ts @@ -1,6 +1,6 @@ import process from 'node:process'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { installExitHandlers } from './exit-process.js'; +import { SIGNAL_EXIT_CODES, installExitHandlers } from './exit-process.js'; describe('installExitHandlers', () => { const onError = vi.fn(); @@ -72,7 +72,7 @@ describe('installExitHandlers', () => { (process as any).emit('SIGINT'); expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(130, { + expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, { kind: 'signal', signal: 'SIGINT', }); @@ -85,7 +85,7 @@ describe('installExitHandlers', () => { (process as any).emit('SIGTERM'); expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(143, { + expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM, { kind: 'signal', signal: 'SIGTERM', }); @@ -98,7 +98,7 @@ describe('installExitHandlers', () => { (process as any).emit('SIGQUIT'); expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(131, { + expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGQUIT, { kind: 'signal', signal: 'SIGQUIT', }); From 69502846bf71f97f4269e504adcdb4bd3f1cf94f Mon Sep 17 00:00:00 2001 From: John Doe Date: Mon, 12 Jan 2026 19:05:20 +0100 Subject: [PATCH 5/8] refactor: fix lint --- packages/utils/src/lib/exit-process.ts | 24 +++++++++----- .../utils/src/lib/exit-process.unit.test.ts | 33 ++++++++----------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/packages/utils/src/lib/exit-process.ts b/packages/utils/src/lib/exit-process.ts index 0f2a84588..06c872735 100644 --- a/packages/utils/src/lib/exit-process.ts +++ b/packages/utils/src/lib/exit-process.ts @@ -1,8 +1,6 @@ import os from 'node:os'; import process from 'node:process'; -const isWindows = os.platform() === 'win32'; - // POSIX shells convention: exit status = 128 + signal number // https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html#:~:text=When%20a%20command%20terminates%20on%20a%20fatal%20signal%20whose%20number%20is%20N%2C%20Bash%20uses%20the%20value%20128%2BN%20as%20the%20exit%20status. const UNIX_SIGNAL_EXIT_CODE_OFFSET = 128; @@ -10,13 +8,15 @@ const unixSignalExitCode = (signalNumber: number) => UNIX_SIGNAL_EXIT_CODE_OFFSET + signalNumber; const SIGINT_CODE = 2; +const SIGTERM_CODE = 15; +const SIGQUIT_CODE = 3; export const SIGNAL_EXIT_CODES = (): Record => { const isWindowsRuntime = os.platform() === 'win32'; return { SIGINT: isWindowsRuntime ? SIGINT_CODE : unixSignalExitCode(SIGINT_CODE), - SIGTERM: unixSignalExitCode(15), - SIGQUIT: unixSignalExitCode(3), + SIGTERM: unixSignalExitCode(SIGTERM_CODE), + SIGQUIT: unixSignalExitCode(SIGQUIT_CODE), }; }; @@ -39,6 +39,7 @@ export type ExitHandlerOptions = { }; export function installExitHandlers(options: ExitHandlerOptions = {}): void { + // eslint-disable-next-line functional/no-let let closedReason: CloseReason | undefined; const { onClose, @@ -49,40 +50,47 @@ export function installExitHandlers(options: ExitHandlerOptions = {}): void { } = options; const close = (code: number, reason: CloseReason) => { - if (closedReason) return; + if (closedReason) { + return; + } closedReason = reason; onClose?.(code, reason); }; process.on('uncaughtException', err => { onError?.(err, 'uncaughtException'); - if (fatalExit) + if (fatalExit) { close(fatalExitCode, { kind: 'fatal', fatal: 'uncaughtException', }); + } }); process.on('unhandledRejection', reason => { onError?.(reason, 'unhandledRejection'); - if (fatalExit) + if (fatalExit) { close(fatalExitCode, { kind: 'fatal', fatal: 'unhandledRejection', }); + } }); (['SIGINT', 'SIGTERM', 'SIGQUIT'] as const).forEach(signal => { process.on(signal, () => { close(SIGNAL_EXIT_CODES()[signal], { kind: 'signal', signal }); if (signalExit) { + // eslint-disable-next-line n/no-process-exit process.exit(SIGNAL_EXIT_CODES()[signal]); } }); }); process.on('exit', code => { - if (closedReason) return; + if (closedReason) { + return; + } close(code, { kind: 'exit' }); }); } diff --git a/packages/utils/src/lib/exit-process.unit.test.ts b/packages/utils/src/lib/exit-process.unit.test.ts index 4e89cca58..05c0060c8 100644 --- a/packages/utils/src/lib/exit-process.unit.test.ts +++ b/packages/utils/src/lib/exit-process.unit.test.ts @@ -1,3 +1,4 @@ +import os from 'node:os'; import process from 'node:process'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SIGNAL_EXIT_CODES, installExitHandlers } from './exit-process.js'; @@ -215,28 +216,22 @@ describe('exit-process tests', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it('should have correct SIGINT exit code based on platform', () => { - const os = require('node:os'); - const isWindows = os.platform() === 'win32'; - const SIGINT_CODE = 2; - const UNIX_SIGNAL_EXIT_CODE_OFFSET = 128; - - const expectedSigintCode = isWindows - ? SIGINT_CODE - : UNIX_SIGNAL_EXIT_CODE_OFFSET + SIGINT_CODE; - - if (isWindows) { - expect(expectedSigintCode).toBe(2); - } else { - expect(expectedSigintCode).toBe(130); - expect(SIGNAL_EXIT_CODES().SIGINT).toBe(130); - } + it('should have correct SIGINT exit code on Windows', () => { + const osSpy = vi.spyOn(os, 'platform').mockReturnValue('win32'); + const exitCodes = SIGNAL_EXIT_CODES(); + expect(exitCodes.SIGINT).toBe(2); + osSpy.mockRestore(); + }); + + it('should have correct SIGINT exit code on Unix-like systems', () => { + const osSpy = vi.spyOn(os, 'platform').mockReturnValue('linux'); + const exitCodes = SIGNAL_EXIT_CODES(); + expect(exitCodes.SIGINT).toBe(130); + osSpy.mockRestore(); }); it('should calculate Windows exit codes correctly when platform is mocked to Windows', () => { - const osSpy = vi - .spyOn(require('node:os'), 'platform') - .mockReturnValue('win32'); + const osSpy = vi.spyOn(os, 'platform').mockReturnValue('win32'); const exitCodes = SIGNAL_EXIT_CODES(); From fb34061071064c5657b506b3c87aed583d0370eb Mon Sep 17 00:00:00 2001 From: John Doe Date: Tue, 13 Jan 2026 20:12:42 +0100 Subject: [PATCH 6/8] refactor: add better flush handling --- .../src/lib/performance-observer.int.test.ts | 45 ++++++++++ .../utils/src/lib/performance-observer.ts | 76 ++++++++++------ .../src/lib/performance-observer.unit.test.ts | 86 +++++++++++++++++++ 3 files changed, 179 insertions(+), 28 deletions(-) diff --git a/packages/utils/src/lib/performance-observer.int.test.ts b/packages/utils/src/lib/performance-observer.int.test.ts index 8209dc71f..2c1721ebb 100644 --- a/packages/utils/src/lib/performance-observer.int.test.ts +++ b/packages/utils/src/lib/performance-observer.int.test.ts @@ -179,4 +179,49 @@ describe('PerformanceObserverSink', () => { expect(sink.getWrittenItems()).toHaveLength(2); }); + + it('cursor logic prevents duplicate processing of performance entries', () => { + const observer = new PerformanceObserverSink(options); + observer.subscribe(); + + performance.mark('first-mark'); + performance.mark('second-mark'); + expect(encode).not.toHaveBeenCalled(); + observer.flush(); + expect(sink.getWrittenItems()).toStrictEqual([ + 'first-mark:mark', + 'second-mark:mark', + ]); + + expect(encode).toHaveBeenCalledTimes(2); + expect(encode).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ name: 'first-mark' }), + ); + expect(encode).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ name: 'second-mark' }), + ); + + performance.mark('third-mark'); + performance.measure('first-measure'); + + observer.flush(); + expect(sink.getWrittenItems()).toStrictEqual([ + 'first-mark:mark', + 'second-mark:mark', + 'third-mark:mark', + 'first-measure:measure', + ]); + + expect(encode).toHaveBeenCalledTimes(4); + expect(encode).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ name: 'third-mark' }), + ); + expect(encode).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ name: 'first-measure' }), + ); + }); }); diff --git a/packages/utils/src/lib/performance-observer.ts b/packages/utils/src/lib/performance-observer.ts index 6b360d0da..e3f72baaf 100644 --- a/packages/utils/src/lib/performance-observer.ts +++ b/packages/utils/src/lib/performance-observer.ts @@ -1,5 +1,4 @@ import { - type EntryType, type PerformanceEntry, PerformanceObserver, type PerformanceObserverEntryList, @@ -7,6 +6,8 @@ import { } from 'node:perf_hooks'; import type { Buffered, Encoder, Observer, Sink } from './sink-source.types.js'; +const OBSERVED_TYPES = ['mark', 'measure'] as const; +type ObservedEntryType = 'mark' | 'measure'; export const DEFAULT_FLUSH_THRESHOLD = 20; export type PerformanceObserverOptions = { @@ -24,16 +25,21 @@ export class PerformanceObserverSink #flushThreshold: number; #sink: Sink; #observer: PerformanceObserver | undefined; - #observedTypes: EntryType[] = ['mark', 'measure']; - #getEntries = (list: PerformanceObserverEntryList) => - this.#observedTypes.flatMap(t => list.getEntriesByType(t)); - #observedCount: number = 0; + + #pendingCount = 0; + + // "cursor" per type: how many we already wrote from the global buffer + #written: Map; constructor(options: PerformanceObserverOptions) { - this.#encode = options.encode; - this.#sink = options.sink; - this.#buffered = options.buffered ?? false; - this.#flushThreshold = options.flushThreshold ?? DEFAULT_FLUSH_THRESHOLD; + const { encode, sink, buffered, flushThreshold } = options; + this.#encode = encode; + this.#written = new Map( + OBSERVED_TYPES.map(t => [t, 0]), + ); + this.#sink = sink; + this.#buffered = buffered ?? false; + this.#flushThreshold = flushThreshold ?? DEFAULT_FLUSH_THRESHOLD; } encode(entry: PerformanceEntry): T[] { @@ -45,37 +51,51 @@ export class PerformanceObserverSink return; } - this.#observer = new PerformanceObserver(list => { - const entries = this.#getEntries(list); - this.#observedCount += entries.length; - if (this.#observedCount >= this.#flushThreshold) { - this.flush(entries); - } - }); + // The only used to trigger the flush it is not processing the entries just counting them + this.#observer = new PerformanceObserver( + (list: PerformanceObserverEntryList) => { + const batchCount = OBSERVED_TYPES.reduce( + (n, t) => n + list.getEntriesByType(t).length, + 0, + ); + + this.#pendingCount += batchCount; + if (this.#pendingCount >= this.#flushThreshold) { + this.flush(); + } + }, + ); this.#observer.observe({ - entryTypes: this.#observedTypes, + entryTypes: OBSERVED_TYPES, buffered: this.#buffered, }); } - flush(entriesToProcess?: PerformanceEntry[]): void { + flush(): void { if (!this.#observer) { return; } - const entries = entriesToProcess || this.#getEntries(performance); - entries.forEach(entry => { - const encoded = this.encode(entry); - encoded.forEach(item => { - this.#sink.write(item); - }); - }); + OBSERVED_TYPES.forEach(t => { + const written = this.#written.get(t) ?? 0; + const fresh = performance.getEntriesByType(t).slice(written); + + try { + fresh + .flatMap(entry => this.encode(entry)) + .forEach(item => this.#sink.write(item)); - // In real PerformanceObserver, entries remain in the global buffer - // They are only cleared when explicitly requested via performance.clearMarks/clearMeasures + this.#written.set(t, written + fresh.length); + } catch (error) { + throw new Error( + 'PerformanceObserverSink failed to write items to sink.', + { cause: error }, + ); + } + }); - this.#observedCount = 0; + this.#pendingCount = 0; } unsubscribe(): void { diff --git a/packages/utils/src/lib/performance-observer.unit.test.ts b/packages/utils/src/lib/performance-observer.unit.test.ts index 1e2e18287..a73be955a 100644 --- a/packages/utils/src/lib/performance-observer.unit.test.ts +++ b/packages/utils/src/lib/performance-observer.unit.test.ts @@ -41,6 +41,40 @@ describe('PerformanceObserverSink', () => { expect(MockPerformanceObserver.instances).toHaveLength(0); }); + it('creates instance with default flushThreshold when not provided', () => { + expect( + () => + new PerformanceObserverSink({ + sink, + encode, + }), + ).not.toThrow(); + expect(MockPerformanceObserver.instances).toHaveLength(0); + // Instance creation covers the default flushThreshold assignment + }); + + it('automatically flushes when pendingCount reaches flushThreshold', () => { + const observer = new PerformanceObserverSink({ + sink, + encode, + flushThreshold: 2, // Set threshold to 2 + }); + observer.subscribe(); + + const mockObserver = MockPerformanceObserver.lastInstance(); + + // Emit 1 entry - should not trigger flush yet (pendingCount = 1 < 2) + mockObserver?.emitMark('first-mark'); + expect(sink.getWrittenItems()).toStrictEqual([]); + + // Emit 1 more entry - should trigger flush (pendingCount = 2 >= 2) + mockObserver?.emitMark('second-mark'); + expect(sink.getWrittenItems()).toStrictEqual([ + 'first-mark:mark', + 'second-mark:mark', + ]); + }); + it('creates instance with all options without starting to observe', () => { expect( () => @@ -219,4 +253,56 @@ describe('PerformanceObserverSink', () => { expect(perfObserver?.disconnect).toHaveBeenCalledTimes(1); expect(MockPerformanceObserver.instances).toHaveLength(0); }); + + it('flush wraps sink write errors with descriptive error message', () => { + const failingSink = { + write: vi.fn(() => { + throw new Error('Sink write failed'); + }), + }; + + const observer = new PerformanceObserverSink({ + sink: failingSink as any, + encode, + flushThreshold: 1, + }); + + observer.subscribe(); + + performance.mark('test-mark'); + + expect(() => observer.flush()).toThrow( + expect.objectContaining({ + message: 'PerformanceObserverSink failed to write items to sink.', + cause: expect.objectContaining({ + message: 'Sink write failed', + }), + }), + ); + }); + + it('flush wraps encode errors with descriptive error message', () => { + const failingEncode = vi.fn(() => { + throw new Error('Encode failed'); + }); + + const observer = new PerformanceObserverSink({ + sink, + encode: failingEncode, + flushThreshold: 1, + }); + + observer.subscribe(); + + performance.mark('test-mark'); + + expect(() => observer.flush()).toThrow( + expect.objectContaining({ + message: 'PerformanceObserverSink failed to write items to sink.', + cause: expect.objectContaining({ + message: 'Encode failed', + }), + }), + ); + }); }); From 896fb75e6bb7ef3ba6486c71399ce8433b81baeb Mon Sep 17 00:00:00 2001 From: John Doe Date: Tue, 13 Jan 2026 20:13:30 +0100 Subject: [PATCH 7/8] refactor: rename options --- .../utils/src/lib/exit-process.int.test.ts | 40 ++++---- packages/utils/src/lib/exit-process.ts | 20 ++-- .../utils/src/lib/exit-process.unit.test.ts | 96 +++++++++---------- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/packages/utils/src/lib/exit-process.int.test.ts b/packages/utils/src/lib/exit-process.int.test.ts index 728d69296..9fc39f335 100644 --- a/packages/utils/src/lib/exit-process.int.test.ts +++ b/packages/utils/src/lib/exit-process.int.test.ts @@ -4,7 +4,7 @@ import { SIGNAL_EXIT_CODES, installExitHandlers } from './exit-process.js'; describe('installExitHandlers', () => { const onError = vi.fn(); - const onClose = vi.fn(); + const onExit = vi.fn(); const processOnSpy = vi.spyOn(process, 'on'); const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(vi.fn()); @@ -26,7 +26,7 @@ describe('installExitHandlers', () => { }); it('should install event listeners for all expected events', () => { - expect(() => installExitHandlers({ onError, onClose })).not.toThrow(); + expect(() => installExitHandlers({ onError, onExit })).not.toThrow(); expect(processOnSpy).toHaveBeenCalledWith( 'uncaughtException', @@ -51,7 +51,7 @@ describe('installExitHandlers', () => { expect(onError).toHaveBeenCalledWith(testError, 'uncaughtException'); expect(onError).toHaveBeenCalledTimes(1); - expect(onClose).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); }); it('should call onError with reason and kind for unhandledRejection', () => { @@ -63,55 +63,55 @@ describe('installExitHandlers', () => { expect(onError).toHaveBeenCalledWith(testReason, 'unhandledRejection'); expect(onError).toHaveBeenCalledTimes(1); - expect(onClose).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); }); - it('should call onClose and exit with code 0 for SIGINT', () => { - expect(() => installExitHandlers({ onClose })).not.toThrow(); + it('should call onExit and exit with code 0 for SIGINT', () => { + expect(() => installExitHandlers({ onExit })).not.toThrow(); (process as any).emit('SIGINT'); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, { + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, { kind: 'signal', signal: 'SIGINT', }); expect(onError).not.toHaveBeenCalled(); }); - it('should call onClose and exit with code 0 for SIGTERM', () => { - expect(() => installExitHandlers({ onClose })).not.toThrow(); + it('should call onExit and exit with code 0 for SIGTERM', () => { + expect(() => installExitHandlers({ onExit })).not.toThrow(); (process as any).emit('SIGTERM'); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM, { + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM, { kind: 'signal', signal: 'SIGTERM', }); expect(onError).not.toHaveBeenCalled(); }); - it('should call onClose and exit with code 0 for SIGQUIT', () => { - expect(() => installExitHandlers({ onClose })).not.toThrow(); + it('should call onExit and exit with code 0 for SIGQUIT', () => { + expect(() => installExitHandlers({ onExit })).not.toThrow(); (process as any).emit('SIGQUIT'); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGQUIT, { + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGQUIT, { kind: 'signal', signal: 'SIGQUIT', }); expect(onError).not.toHaveBeenCalled(); }); - it('should call onClose for normal exit', () => { - expect(() => installExitHandlers({ onClose })).not.toThrow(); + it('should call onExit for normal exit', () => { + expect(() => installExitHandlers({ onExit })).not.toThrow(); (process as any).emit('exit'); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(undefined, { kind: 'exit' }); + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(undefined, { kind: 'exit' }); expect(onError).not.toHaveBeenCalled(); expect(processExitSpy).not.toHaveBeenCalled(); }); diff --git a/packages/utils/src/lib/exit-process.ts b/packages/utils/src/lib/exit-process.ts index 06c872735..62cee4977 100644 --- a/packages/utils/src/lib/exit-process.ts +++ b/packages/utils/src/lib/exit-process.ts @@ -31,10 +31,10 @@ export type CloseReason = | { kind: 'exit' }; export type ExitHandlerOptions = { - onClose?: (code: number, reason: CloseReason) => void; + onExit?: (code: number, reason: CloseReason) => void; onError?: (err: unknown, kind: FatalKind) => void; - fatalExit?: boolean; - signalExit?: boolean; + exitOnFatal?: boolean; + exitOnSignal?: boolean; fatalExitCode?: number; }; @@ -42,10 +42,10 @@ export function installExitHandlers(options: ExitHandlerOptions = {}): void { // eslint-disable-next-line functional/no-let let closedReason: CloseReason | undefined; const { - onClose, + onExit, onError, - fatalExit, - signalExit, + exitOnFatal, + exitOnSignal, fatalExitCode = DEFAULT_FATAL_EXIT_CODE, } = options; @@ -54,12 +54,12 @@ export function installExitHandlers(options: ExitHandlerOptions = {}): void { return; } closedReason = reason; - onClose?.(code, reason); + onExit?.(code, reason); }; process.on('uncaughtException', err => { onError?.(err, 'uncaughtException'); - if (fatalExit) { + if (exitOnFatal) { close(fatalExitCode, { kind: 'fatal', fatal: 'uncaughtException', @@ -69,7 +69,7 @@ export function installExitHandlers(options: ExitHandlerOptions = {}): void { process.on('unhandledRejection', reason => { onError?.(reason, 'unhandledRejection'); - if (fatalExit) { + if (exitOnFatal) { close(fatalExitCode, { kind: 'fatal', fatal: 'unhandledRejection', @@ -80,7 +80,7 @@ export function installExitHandlers(options: ExitHandlerOptions = {}): void { (['SIGINT', 'SIGTERM', 'SIGQUIT'] as const).forEach(signal => { process.on(signal, () => { close(SIGNAL_EXIT_CODES()[signal], { kind: 'signal', signal }); - if (signalExit) { + if (exitOnSignal) { // eslint-disable-next-line n/no-process-exit process.exit(SIGNAL_EXIT_CODES()[signal]); } diff --git a/packages/utils/src/lib/exit-process.unit.test.ts b/packages/utils/src/lib/exit-process.unit.test.ts index 05c0060c8..d9437a51c 100644 --- a/packages/utils/src/lib/exit-process.unit.test.ts +++ b/packages/utils/src/lib/exit-process.unit.test.ts @@ -5,7 +5,7 @@ import { SIGNAL_EXIT_CODES, installExitHandlers } from './exit-process.js'; describe('exit-process tests', () => { const onError = vi.fn(); - const onClose = vi.fn(); + const onExit = vi.fn(); const processOnSpy = vi.spyOn(process, 'on'); const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(vi.fn()); @@ -27,7 +27,7 @@ describe('exit-process tests', () => { }); it('should install event listeners for all expected events', () => { - expect(() => installExitHandlers({ onError, onClose })).not.toThrow(); + expect(() => installExitHandlers({ onError, onExit })).not.toThrow(); expect(processOnSpy).toHaveBeenCalledWith( 'uncaughtException', @@ -52,7 +52,7 @@ describe('exit-process tests', () => { expect(onError).toHaveBeenCalledWith(testError, 'uncaughtException'); expect(onError).toHaveBeenCalledTimes(1); - expect(onClose).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); }); it('should call onError with reason and kind for unhandledRejection', () => { @@ -64,18 +64,18 @@ describe('exit-process tests', () => { expect(onError).toHaveBeenCalledWith(testReason, 'unhandledRejection'); expect(onError).toHaveBeenCalledTimes(1); - expect(onClose).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); }); - it('should call onClose with correct code and reason for SIGINT', () => { + it('should call onExit with correct code and reason for SIGINT', () => { expect(() => - installExitHandlers({ onClose, signalExit: true }), + installExitHandlers({ onExit, exitOnSignal: true }), ).not.toThrow(); (process as any).emit('SIGINT'); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, { + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, { kind: 'signal', signal: 'SIGINT', }); @@ -83,15 +83,15 @@ describe('exit-process tests', () => { expect(processExitSpy).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT); }); - it('should call onClose with correct code and reason for SIGTERM', () => { + it('should call onExit with correct code and reason for SIGTERM', () => { expect(() => - installExitHandlers({ onClose, signalExit: true }), + installExitHandlers({ onExit, exitOnSignal: true }), ).not.toThrow(); (process as any).emit('SIGTERM'); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM, { + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM, { kind: 'signal', signal: 'SIGTERM', }); @@ -99,15 +99,15 @@ describe('exit-process tests', () => { expect(processExitSpy).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM); }); - it('should call onClose with correct code and reason for SIGQUIT', () => { + it('should call onExit with correct code and reason for SIGQUIT', () => { expect(() => - installExitHandlers({ onClose, signalExit: true }), + installExitHandlers({ onExit, exitOnSignal: true }), ).not.toThrow(); (process as any).emit('SIGQUIT'); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGQUIT, { + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGQUIT, { kind: 'signal', signal: 'SIGQUIT', }); @@ -115,15 +115,15 @@ describe('exit-process tests', () => { expect(processExitSpy).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGQUIT); }); - it('should not exit process when signalExit is false', () => { + it('should not exit process when exitOnSignal is false', () => { expect(() => - installExitHandlers({ onClose, signalExit: false }), + installExitHandlers({ onExit, exitOnSignal: false }), ).not.toThrow(); (process as any).emit('SIGINT'); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, { + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, { kind: 'signal', signal: 'SIGINT', }); @@ -131,13 +131,13 @@ describe('exit-process tests', () => { expect(processExitSpy).not.toHaveBeenCalled(); }); - it('should not exit process when signalExit is not set', () => { - expect(() => installExitHandlers({ onClose })).not.toThrow(); + it('should not exit process when exitOnSignal is not set', () => { + expect(() => installExitHandlers({ onExit })).not.toThrow(); (process as any).emit('SIGTERM'); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM, { + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM, { kind: 'signal', signal: 'SIGTERM', }); @@ -145,21 +145,21 @@ describe('exit-process tests', () => { expect(processExitSpy).not.toHaveBeenCalled(); }); - it('should call onClose with exit code and reason for normal exit', () => { - expect(() => installExitHandlers({ onClose })).not.toThrow(); + it('should call onExit with exit code and reason for normal exit', () => { + expect(() => installExitHandlers({ onExit })).not.toThrow(); const exitCode = 42; (process as any).emit('exit', exitCode); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(exitCode, { kind: 'exit' }); + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(exitCode, { kind: 'exit' }); expect(onError).not.toHaveBeenCalled(); expect(processExitSpy).not.toHaveBeenCalled(); }); - it('should call onClose with fatal reason when fatalExit is true', () => { + it('should call onExit with fatal reason when exitOnFatal is true', () => { expect(() => - installExitHandlers({ onError, onClose, fatalExit: true }), + installExitHandlers({ onError, onExit, exitOnFatal: true }), ).not.toThrow(); const testError = new Error('Test uncaught exception'); @@ -168,19 +168,19 @@ describe('exit-process tests', () => { expect(onError).toHaveBeenCalledWith(testError, 'uncaughtException'); expect(onError).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(1, { + expect(onExit).toHaveBeenCalledWith(1, { kind: 'fatal', fatal: 'uncaughtException', }); - expect(onClose).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledTimes(1); }); - it('should use custom fatalExitCode when fatalExit is true', () => { + it('should use custom fatalExitCode when exitOnFatal is true', () => { expect(() => installExitHandlers({ onError, - onClose, - fatalExit: true, + onExit, + exitOnFatal: true, fatalExitCode: 42, }), ).not.toThrow(); @@ -191,16 +191,16 @@ describe('exit-process tests', () => { expect(onError).toHaveBeenCalledWith(testError, 'uncaughtException'); expect(onError).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(42, { + expect(onExit).toHaveBeenCalledWith(42, { kind: 'fatal', fatal: 'uncaughtException', }); - expect(onClose).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledTimes(1); }); - it('should call onClose with fatal reason for unhandledRejection when fatalExit is true', () => { + it('should call onExit with fatal reason for unhandledRejection when exitOnFatal is true', () => { expect(() => - installExitHandlers({ onError, onClose, fatalExit: true }), + installExitHandlers({ onError, onExit, exitOnFatal: true }), ).not.toThrow(); const testReason = 'Test unhandled rejection'; @@ -209,11 +209,11 @@ describe('exit-process tests', () => { expect(onError).toHaveBeenCalledWith(testReason, 'unhandledRejection'); expect(onError).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(1, { + expect(onExit).toHaveBeenCalledWith(1, { kind: 'fatal', fatal: 'unhandledRejection', }); - expect(onClose).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledTimes(1); }); it('should have correct SIGINT exit code on Windows', () => { @@ -242,21 +242,21 @@ describe('exit-process tests', () => { osSpy.mockRestore(); }); - it('should call onClose only once even when close is called multiple times', () => { + it('should call onExit only once even when close is called multiple times', () => { expect(() => - installExitHandlers({ onClose, signalExit: true }), + installExitHandlers({ onExit, exitOnSignal: true }), ).not.toThrow(); (process as any).emit('SIGINT'); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, { + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, { kind: 'signal', signal: 'SIGINT', }); - onClose.mockClear(); + onExit.mockClear(); (process as any).emit('SIGTERM'); - expect(onClose).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); (process as any).emit('exit', 0); - expect(onClose).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); }); }); From c84ea01b04e3298ad01e6278903c2644888c98c3 Mon Sep 17 00:00:00 2001 From: John Doe Date: Tue, 13 Jan 2026 20:17:37 +0100 Subject: [PATCH 8/8] refactor: add test --- packages/utils/src/lib/exit-process.int.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/utils/src/lib/exit-process.int.test.ts b/packages/utils/src/lib/exit-process.int.test.ts index 9fc39f335..7f3d0850a 100644 --- a/packages/utils/src/lib/exit-process.int.test.ts +++ b/packages/utils/src/lib/exit-process.int.test.ts @@ -105,13 +105,24 @@ describe('installExitHandlers', () => { expect(onError).not.toHaveBeenCalled(); }); - it('should call onExit for normal exit', () => { + it('should call onExit for successful process termination with exit code 0', () => { expect(() => installExitHandlers({ onExit })).not.toThrow(); - (process as any).emit('exit'); + (process as any).emit('exit', 0); expect(onExit).toHaveBeenCalledTimes(1); - expect(onExit).toHaveBeenCalledWith(undefined, { kind: 'exit' }); + expect(onExit).toHaveBeenCalledWith(0, { kind: 'exit' }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should call onExit for failed process termination with exit code 1', () => { + expect(() => installExitHandlers({ onExit })).not.toThrow(); + + (process as any).emit('exit', 1); + + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(1, { kind: 'exit' }); expect(onError).not.toHaveBeenCalled(); expect(processExitSpy).not.toHaveBeenCalled(); });