Skip to content

Commit 0daf9f1

Browse files
authored
feat: add process exit handler (#1212)
1 parent 7d3681c commit 0daf9f1

File tree

7 files changed

+668
-40
lines changed

7 files changed

+668
-40
lines changed
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import process from 'node:process';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { SIGNAL_EXIT_CODES, installExitHandlers } from './exit-process.js';
4+
5+
describe('installExitHandlers', () => {
6+
const onError = vi.fn();
7+
const onExit = 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, onExit })).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(onExit).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(onExit).not.toHaveBeenCalled();
67+
});
68+
69+
it('should call onExit and exit with code 0 for SIGINT', () => {
70+
expect(() => installExitHandlers({ onExit })).not.toThrow();
71+
72+
(process as any).emit('SIGINT');
73+
74+
expect(onExit).toHaveBeenCalledTimes(1);
75+
expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGINT, {
76+
kind: 'signal',
77+
signal: 'SIGINT',
78+
});
79+
expect(onError).not.toHaveBeenCalled();
80+
});
81+
82+
it('should call onExit and exit with code 0 for SIGTERM', () => {
83+
expect(() => installExitHandlers({ onExit })).not.toThrow();
84+
85+
(process as any).emit('SIGTERM');
86+
87+
expect(onExit).toHaveBeenCalledTimes(1);
88+
expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGTERM, {
89+
kind: 'signal',
90+
signal: 'SIGTERM',
91+
});
92+
expect(onError).not.toHaveBeenCalled();
93+
});
94+
95+
it('should call onExit and exit with code 0 for SIGQUIT', () => {
96+
expect(() => installExitHandlers({ onExit })).not.toThrow();
97+
98+
(process as any).emit('SIGQUIT');
99+
100+
expect(onExit).toHaveBeenCalledTimes(1);
101+
expect(onExit).toHaveBeenCalledWith(SIGNAL_EXIT_CODES().SIGQUIT, {
102+
kind: 'signal',
103+
signal: 'SIGQUIT',
104+
});
105+
expect(onError).not.toHaveBeenCalled();
106+
});
107+
108+
it('should call onExit for successful process termination with exit code 0', () => {
109+
expect(() => installExitHandlers({ onExit })).not.toThrow();
110+
111+
(process as any).emit('exit', 0);
112+
113+
expect(onExit).toHaveBeenCalledTimes(1);
114+
expect(onExit).toHaveBeenCalledWith(0, { kind: 'exit' });
115+
expect(onError).not.toHaveBeenCalled();
116+
expect(processExitSpy).not.toHaveBeenCalled();
117+
});
118+
119+
it('should call onExit for failed process termination with exit code 1', () => {
120+
expect(() => installExitHandlers({ onExit })).not.toThrow();
121+
122+
(process as any).emit('exit', 1);
123+
124+
expect(onExit).toHaveBeenCalledTimes(1);
125+
expect(onExit).toHaveBeenCalledWith(1, { kind: 'exit' });
126+
expect(onError).not.toHaveBeenCalled();
127+
expect(processExitSpy).not.toHaveBeenCalled();
128+
});
129+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import os from 'node:os';
2+
import process from 'node:process';
3+
4+
// POSIX shells convention: exit status = 128 + signal number
5+
// 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.
6+
const UNIX_SIGNAL_EXIT_CODE_OFFSET = 128;
7+
const unixSignalExitCode = (signalNumber: number) =>
8+
UNIX_SIGNAL_EXIT_CODE_OFFSET + signalNumber;
9+
10+
const SIGINT_CODE = 2;
11+
const SIGTERM_CODE = 15;
12+
const SIGQUIT_CODE = 3;
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(SIGTERM_CODE),
19+
SIGQUIT: unixSignalExitCode(SIGQUIT_CODE),
20+
};
21+
};
22+
23+
export const DEFAULT_FATAL_EXIT_CODE = 1;
24+
25+
export type SignalName = 'SIGINT' | 'SIGTERM' | 'SIGQUIT';
26+
export type FatalKind = 'uncaughtException' | 'unhandledRejection';
27+
28+
export type CloseReason =
29+
| { kind: 'signal'; signal: SignalName }
30+
| { kind: 'fatal'; fatal: FatalKind }
31+
| { kind: 'exit' };
32+
33+
export type ExitHandlerOptions = {
34+
onExit?: (code: number, reason: CloseReason) => void;
35+
onError?: (err: unknown, kind: FatalKind) => void;
36+
exitOnFatal?: boolean;
37+
exitOnSignal?: boolean;
38+
fatalExitCode?: number;
39+
};
40+
41+
export function installExitHandlers(options: ExitHandlerOptions = {}): void {
42+
// eslint-disable-next-line functional/no-let
43+
let closedReason: CloseReason | undefined;
44+
const {
45+
onExit,
46+
onError,
47+
exitOnFatal,
48+
exitOnSignal,
49+
fatalExitCode = DEFAULT_FATAL_EXIT_CODE,
50+
} = options;
51+
52+
const close = (code: number, reason: CloseReason) => {
53+
if (closedReason) {
54+
return;
55+
}
56+
closedReason = reason;
57+
onExit?.(code, reason);
58+
};
59+
60+
process.on('uncaughtException', err => {
61+
onError?.(err, 'uncaughtException');
62+
if (exitOnFatal) {
63+
close(fatalExitCode, {
64+
kind: 'fatal',
65+
fatal: 'uncaughtException',
66+
});
67+
}
68+
});
69+
70+
process.on('unhandledRejection', reason => {
71+
onError?.(reason, 'unhandledRejection');
72+
if (exitOnFatal) {
73+
close(fatalExitCode, {
74+
kind: 'fatal',
75+
fatal: 'unhandledRejection',
76+
});
77+
}
78+
});
79+
80+
(['SIGINT', 'SIGTERM', 'SIGQUIT'] as const).forEach(signal => {
81+
process.on(signal, () => {
82+
close(SIGNAL_EXIT_CODES()[signal], { kind: 'signal', signal });
83+
if (exitOnSignal) {
84+
// eslint-disable-next-line n/no-process-exit
85+
process.exit(SIGNAL_EXIT_CODES()[signal]);
86+
}
87+
});
88+
});
89+
90+
process.on('exit', code => {
91+
if (closedReason) {
92+
return;
93+
}
94+
close(code, { kind: 'exit' });
95+
});
96+
}

0 commit comments

Comments
 (0)