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..728d69296 --- /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 { SIGNAL_EXIT_CODES, 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(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(); + + (process as any).emit('SIGTERM'); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).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(); + + (process as any).emit('SIGQUIT'); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).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(); + + (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 new file mode 100644 index 000000000..06c872735 --- /dev/null +++ b/packages/utils/src/lib/exit-process.ts @@ -0,0 +1,96 @@ +import os from 'node:os'; +import process from 'node:process'; + +// 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; +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(SIGTERM_CODE), + SIGQUIT: unixSignalExitCode(SIGQUIT_CODE), + }; +}; + +export const DEFAULT_FATAL_EXIT_CODE = 1; + +export type SignalName = 'SIGINT' | 'SIGTERM' | 'SIGQUIT'; +export type FatalKind = 'uncaughtException' | 'unhandledRejection'; + +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 { + // eslint-disable-next-line functional/no-let + 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 => { + onError?.(err, 'uncaughtException'); + if (fatalExit) { + close(fatalExitCode, { + kind: 'fatal', + fatal: 'uncaughtException', + }); + } + }); + + process.on('unhandledRejection', reason => { + onError?.(reason, 'unhandledRejection'); + 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; + } + 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 new file mode 100644 index 000000000..05c0060c8 --- /dev/null +++ b/packages/utils/src/lib/exit-process.unit.test.ts @@ -0,0 +1,262 @@ +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'; + +describe('exit-process tests', () => { + 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 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(SIGNAL_EXIT_CODES().SIGINT, { + kind: 'signal', + signal: 'SIGINT', + }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT); + }); + + 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(SIGNAL_EXIT_CODES().SIGTERM, { + kind: 'signal', + signal: 'SIGTERM', + }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM); + }); + + 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(SIGNAL_EXIT_CODES().SIGQUIT, { + kind: 'signal', + signal: 'SIGQUIT', + }); + expect(onError).not.toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGQUIT); + }); + + 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('SIGTERM'); + + expect(onClose).toHaveBeenCalledTimes(1); + 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 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(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(); + }); + + 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(); + }); +}); 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); }; /**