Skip to content

Commit 71efc34

Browse files
author
John Doe
committed
refactor: add int tests and refactor for reuse
1 parent 586a93f commit 71efc34

File tree

4 files changed

+359
-66
lines changed

4 files changed

+359
-66
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import process from 'node:process';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { installExitHandlers } from './exit-process.js';
4+
5+
describe('installExitHandlers', () => {
6+
const onError = vi.fn();
7+
const onClose = vi.fn();
8+
const processOnSpy = vi.spyOn(process, 'on');
9+
const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(vi.fn());
10+
11+
beforeEach(() => {
12+
vi.clearAllMocks();
13+
});
14+
15+
afterEach(() => {
16+
[
17+
'uncaughtException',
18+
'unhandledRejection',
19+
'SIGINT',
20+
'SIGTERM',
21+
'SIGQUIT',
22+
'exit',
23+
].forEach(event => {
24+
process.removeAllListeners(event);
25+
});
26+
});
27+
28+
it('should install event listeners for all expected events', () => {
29+
expect(() => installExitHandlers({ onError, onClose })).not.toThrow();
30+
31+
expect(processOnSpy).toHaveBeenCalledWith(
32+
'uncaughtException',
33+
expect.any(Function),
34+
);
35+
expect(processOnSpy).toHaveBeenCalledWith(
36+
'unhandledRejection',
37+
expect.any(Function),
38+
);
39+
expect(processOnSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function));
40+
expect(processOnSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
41+
expect(processOnSpy).toHaveBeenCalledWith('SIGQUIT', expect.any(Function));
42+
expect(processOnSpy).toHaveBeenCalledWith('exit', expect.any(Function));
43+
});
44+
45+
it('should call onError with error and kind for uncaughtException', () => {
46+
expect(() => installExitHandlers({ onError })).not.toThrow();
47+
48+
const testError = new Error('Test uncaught exception');
49+
50+
(process as any).emit('uncaughtException', testError);
51+
52+
expect(onError).toHaveBeenCalledWith(testError, 'uncaughtException');
53+
expect(onError).toHaveBeenCalledTimes(1);
54+
expect(onClose).not.toHaveBeenCalled();
55+
});
56+
57+
it('should call onError with reason and kind for unhandledRejection', () => {
58+
expect(() => installExitHandlers({ onError })).not.toThrow();
59+
60+
const testReason = 'Test unhandled rejection';
61+
62+
(process as any).emit('unhandledRejection', testReason);
63+
64+
expect(onError).toHaveBeenCalledWith(testReason, 'unhandledRejection');
65+
expect(onError).toHaveBeenCalledTimes(1);
66+
expect(onClose).not.toHaveBeenCalled();
67+
});
68+
69+
it('should call onClose and exit with code 0 for SIGINT', () => {
70+
expect(() => installExitHandlers({ onClose })).not.toThrow();
71+
72+
(process as any).emit('SIGINT');
73+
74+
expect(onClose).toHaveBeenCalledTimes(1);
75+
expect(onClose).toHaveBeenCalledWith(130, {
76+
kind: 'signal',
77+
signal: 'SIGINT',
78+
});
79+
expect(onError).not.toHaveBeenCalled();
80+
});
81+
82+
it('should call onClose and exit with code 0 for SIGTERM', () => {
83+
expect(() => installExitHandlers({ onClose })).not.toThrow();
84+
85+
(process as any).emit('SIGTERM');
86+
87+
expect(onClose).toHaveBeenCalledTimes(1);
88+
expect(onClose).toHaveBeenCalledWith(143, {
89+
kind: 'signal',
90+
signal: 'SIGTERM',
91+
});
92+
expect(onError).not.toHaveBeenCalled();
93+
});
94+
95+
it('should call onClose and exit with code 0 for SIGQUIT', () => {
96+
expect(() => installExitHandlers({ onClose })).not.toThrow();
97+
98+
(process as any).emit('SIGQUIT');
99+
100+
expect(onClose).toHaveBeenCalledTimes(1);
101+
expect(onClose).toHaveBeenCalledWith(131, {
102+
kind: 'signal',
103+
signal: 'SIGQUIT',
104+
});
105+
expect(onError).not.toHaveBeenCalled();
106+
});
107+
108+
it('should call onClose for normal exit', () => {
109+
expect(() => installExitHandlers({ onClose })).not.toThrow();
110+
111+
(process as any).emit('exit');
112+
113+
expect(onClose).toHaveBeenCalledTimes(1);
114+
expect(onClose).toHaveBeenCalledWith(undefined, { kind: 'exit' });
115+
expect(onError).not.toHaveBeenCalled();
116+
expect(processExitSpy).not.toHaveBeenCalled();
117+
});
118+
});
Lines changed: 72 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,88 @@
1+
import os from 'node:os';
12
import process from 'node:process';
23

3-
/* eslint-disable @typescript-eslint/no-magic-numbers */
4-
const SIGNALS = [
5-
['SIGINT', 130],
6-
['SIGTERM', 143],
7-
['SIGQUIT', 131],
8-
] as const;
9-
/* eslint-enable @typescript-eslint/no-magic-numbers */
4+
const isWindows = os.platform() === 'win32';
105

6+
// POSIX shells convention: exit status = 128 + signal number
7+
// 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.
8+
const UNIX_SIGNAL_EXIT_CODE_OFFSET = 128;
9+
const unixSignalExitCode = (signalNumber: number) =>
10+
UNIX_SIGNAL_EXIT_CODE_OFFSET + signalNumber;
11+
12+
const SIGINT_CODE = 2;
13+
14+
export const SIGNAL_EXIT_CODES = (): Record<SignalName, number> => {
15+
const isWindowsRuntime = os.platform() === 'win32';
16+
return {
17+
SIGINT: isWindowsRuntime ? SIGINT_CODE : unixSignalExitCode(SIGINT_CODE),
18+
SIGTERM: unixSignalExitCode(15),
19+
SIGQUIT: unixSignalExitCode(3),
20+
};
21+
};
22+
23+
export const DEFAULT_FATAL_EXIT_CODE = 1;
24+
25+
export type SignalName = 'SIGINT' | 'SIGTERM' | 'SIGQUIT';
1126
export type FatalKind = 'uncaughtException' | 'unhandledRejection';
12-
type ExitHandlerOptions =
13-
| {
14-
onClose?: () => void;
15-
onFatal: (err: unknown, kind?: FatalKind) => void;
16-
}
17-
| {
18-
onClose: () => void;
19-
onFatal?: never;
20-
};
21-
22-
export function installExitHandlers(options: ExitHandlerOptions): void {
23-
// Fatal errors
27+
28+
export type CloseReason =
29+
| { kind: 'signal'; signal: SignalName }
30+
| { kind: 'fatal'; fatal: FatalKind }
31+
| { kind: 'exit' };
32+
33+
export type ExitHandlerOptions = {
34+
onClose?: (code: number, reason: CloseReason) => void;
35+
onError?: (err: unknown, kind: FatalKind) => void;
36+
fatalExit?: boolean;
37+
signalExit?: boolean;
38+
fatalExitCode?: number;
39+
};
40+
41+
export function installExitHandlers(options: ExitHandlerOptions = {}): void {
42+
let closedReason: CloseReason | undefined;
43+
const {
44+
onClose,
45+
onError,
46+
fatalExit,
47+
signalExit,
48+
fatalExitCode = DEFAULT_FATAL_EXIT_CODE,
49+
} = options;
50+
51+
const close = (code: number, reason: CloseReason) => {
52+
if (closedReason) return;
53+
closedReason = reason;
54+
onClose?.(code, reason);
55+
};
56+
2457
process.on('uncaughtException', err => {
25-
options.onFatal?.(err, 'uncaughtException');
58+
onError?.(err, 'uncaughtException');
59+
if (fatalExit)
60+
close(fatalExitCode, {
61+
kind: 'fatal',
62+
fatal: 'uncaughtException',
63+
});
2664
});
2765

2866
process.on('unhandledRejection', reason => {
29-
options.onFatal?.(reason, 'unhandledRejection');
67+
onError?.(reason, 'unhandledRejection');
68+
if (fatalExit)
69+
close(fatalExitCode, {
70+
kind: 'fatal',
71+
fatal: 'unhandledRejection',
72+
});
3073
});
3174

32-
// Graceful shutdown signals
33-
SIGNALS.forEach(([signal]) => {
75+
(['SIGINT', 'SIGTERM', 'SIGQUIT'] as const).forEach(signal => {
3476
process.on(signal, () => {
35-
options.onClose?.();
77+
close(SIGNAL_EXIT_CODES()[signal], { kind: 'signal', signal });
78+
if (signalExit) {
79+
process.exit(SIGNAL_EXIT_CODES()[signal]);
80+
}
3681
});
3782
});
3883

39-
// Normal exit
40-
process.on('exit', () => {
41-
options.onClose?.();
84+
process.on('exit', code => {
85+
if (closedReason) return;
86+
close(code, { kind: 'exit' });
4287
});
4388
}

0 commit comments

Comments
 (0)