Skip to content

Commit 638f24b

Browse files
committed
allow handler to capture signal exits
1 parent e377ebe commit 638f24b

File tree

7 files changed

+238
-11
lines changed

7 files changed

+238
-11
lines changed

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 4.1
4+
5+
- Add the ability to capture signal exits by returning `true`
6+
from the `onExit` listener.
7+
38
## 4.0
49

510
- Rewritten in TypeScript

README.md

+23
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,29 @@ If the global `process` object is not suitable for this purpose
4040
- `alwaysLast`: Run this handler after any other signal or exit
4141
handlers. This causes `process.emit` to be monkeypatched.
4242

43+
### Capturing Signal Exits
44+
45+
If the handler returns an exact boolean `true`, and the exit is a
46+
due to signal, then the signal will be considered handled, and
47+
will _not_ trigger a synthetic `process.kill(process.pid,
48+
signal)` after firing the `onExit` handlers.
49+
50+
In this case, it your responsibility as the caller to exit with a
51+
signal (for example, by calling `process.kill()`) if you wish to
52+
preserve the same exit status that would otherwise have occurred.
53+
If you do not, then the process will likely exit gracefully with
54+
status 0 at some point, assuming that no other terminating signal
55+
or other exit trigger occurs.
56+
57+
Prior to calling handlers, the `onExit` machinery is unloaded, so
58+
any subsequent exits or signals will not be handled, even if the
59+
signal is captured and the exit is thus prevented.
60+
61+
Note that numeric code exits may indicate that the process is
62+
already committed to exiting, for example due to a fatal
63+
exception or unhandled promise rejection, and so there is no way to
64+
prevent it safely.
65+
4366
### Browser Fallback
4467

4568
The `'signal-exit/browser'` module is the same fallback shim that

src/index.ts

+24-10
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,27 @@ const ObjectDefineProperty = Object.defineProperty.bind(Object)
2828

2929
/**
3030
* A function that takes an exit code and signal as arguments
31+
*
32+
* In the case of signal exits *only*, a return value of true
33+
* will indicate that the signal is being handled, and we should
34+
* not synthetically exit with the signal we received. Regardless
35+
* of the handler return value, the handler is unloaded when an
36+
* otherwise fatal signal is received, so you get exactly 1 shot
37+
* at it, unless you add another onExit handler at that point.
38+
*
39+
* In the case of numeric code exits, we may already have committed
40+
* to exiting the process, for example via a fatal exception or
41+
* unhandled promise rejection, so it is impossible to stop safely.
3142
*/
3243
export type Handler = (
3344
code: number | null | undefined,
3445
signal: NodeJS.Signals | null
35-
) => any
46+
) => true | void
3647
type ExitEvent = 'afterExit' | 'exit'
3748
type Emitted = { [k in ExitEvent]: boolean }
3849
type Listeners = { [k in ExitEvent]: Handler[] }
3950

40-
// teeny tiny ee
51+
// teeny special purpose ee
4152
class Emitter {
4253
emitted: Emitted = {
4354
afterExit: false,
@@ -87,14 +98,19 @@ class Emitter {
8798
ev: ExitEvent,
8899
code: number | null | undefined,
89100
signal: NodeJS.Signals | null
90-
) {
101+
): boolean {
91102
if (this.emitted[ev]) {
92-
return
103+
return false
93104
}
94105
this.emitted[ev] = true
106+
let ret: boolean = false
95107
for (const fn of this.listeners[ev]) {
96-
fn(code, signal)
108+
ret = fn(code, signal) === true || ret
109+
}
110+
if (ev === 'exit') {
111+
ret = this.emit('afterExit', code, signal) || ret
97112
}
113+
return ret
98114
}
99115
}
100116

@@ -172,10 +188,10 @@ class SignalExit extends SignalExitBase {
172188
/* c8 ignore stop */
173189
if (listeners.length === count) {
174190
this.unload()
175-
this.#emitter.emit('exit', null, sig)
176-
this.#emitter.emit('afterExit', null, sig)
191+
const ret = this.#emitter.emit('exit', null, sig)
177192
/* c8 ignore start */
178-
process.kill(process.pid, sig === 'SIGHUP' ? this.#hupSig : sig)
193+
const s = sig === 'SIGHUP' ? this.#hupSig : sig
194+
if (!ret) process.kill(process.pid, s)
179195
/* c8 ignore stop */
180196
}
181197
}
@@ -269,7 +285,6 @@ class SignalExit extends SignalExitBase {
269285
/* c8 ignore stop */
270286

271287
this.#emitter.emit('exit', this.#process.exitCode, null)
272-
this.#emitter.emit('afterExit', this.#process.exitCode, null)
273288
return this.#originalProcessReallyExit.call(
274289
this.#process,
275290
this.#process.exitCode
@@ -287,7 +302,6 @@ class SignalExit extends SignalExitBase {
287302
const ret = og.call(this.#process, ev, ...args)
288303
/* c8 ignore start */
289304
this.#emitter.emit('exit', this.#process.exitCode, null)
290-
this.#emitter.emit('afterExit', this.#process.exitCode, null)
291305
/* c8 ignore stop */
292306
return ret
293307
} else {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/* IMPORTANT
2+
* This snapshot file is auto-generated, but designed for humans.
3+
* It should be checked into source control and tracked carefully.
4+
* Re-generate by setting TAP_SNAPSHOT=1 and running tests.
5+
* Make sure to inspect the output below. Do not ignore changes!
6+
*/
7+
'use strict'
8+
exports[`test/signal-capture.ts TAP exit 0 > must match snapshot 1`] = `
9+
exit handler 0 null
10+
afterExit handler 0 null
11+
12+
`
13+
14+
exports[`test/signal-capture.ts TAP exit 1 > must match snapshot 1`] = `
15+
exit handler 1 null
16+
afterExit handler 1 null
17+
18+
`
19+
20+
exports[`test/signal-capture.ts TAP graceful exit > must match snapshot 1`] = `
21+
exit handler 0 null
22+
afterExit handler 0 null
23+
24+
`
25+
26+
exports[`test/signal-capture.ts TAP signal, capture afterExit > must match snapshot 1`] = `
27+
exit handler null SIGHUP
28+
afterExit handler null SIGHUP
29+
afterExit captured signal
30+
31+
`
32+
33+
exports[`test/signal-capture.ts TAP signal, capture both > must match snapshot 1`] = `
34+
exit handler null SIGHUP
35+
afterExit handler null SIGHUP
36+
exit captured signal
37+
afterExit captured signal
38+
39+
`
40+
41+
exports[`test/signal-capture.ts TAP signal, capture exit > must match snapshot 1`] = `
42+
exit handler null SIGHUP
43+
afterExit handler null SIGHUP
44+
exit captured signal
45+
46+
`
47+
48+
exports[`test/signal-capture.ts TAP signal, no capture > must match snapshot 1`] = `
49+
exit handler null SIGHUP
50+
afterExit handler null SIGHUP
51+
52+
`

test/fixtures/signal-capture.js

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
const { onExit } = require('../../dist/cjs/index.js')
2+
3+
const codeOrSignal = process.argv[2] || 'null'
4+
const [code, signal] = !isNaN(+codeOrSignal)
5+
? [+codeOrSignal, null]
6+
: codeOrSignal.startsWith('SIG')
7+
? [null, codeOrSignal]
8+
: [null, null]
9+
10+
const capture = process.argv[3] || 'none'
11+
const captureExit = capture === 'captureExit' || capture === 'capture'
12+
const captureAfterExit =
13+
capture === 'captureAfterExit' || capture === 'capture'
14+
15+
onExit((code, signal) => {
16+
console.log('exit handler', code, signal)
17+
if (signal && captureExit) {
18+
setTimeout(() => console.log('exit captured signal'))
19+
return true
20+
}
21+
})
22+
23+
onExit(
24+
(code, signal) => {
25+
console.log('afterExit handler', code, signal)
26+
if (signal && captureAfterExit) {
27+
setTimeout(() => console.log('afterExit captured signal'))
28+
return true
29+
}
30+
},
31+
{ alwaysLast: true }
32+
)
33+
34+
if (typeof code === 'number') {
35+
process.exit(code)
36+
} else if (typeof signal === 'string') {
37+
process.kill(process.pid, signal)
38+
setTimeout(() => {}, 200)
39+
}

test/multi-exit.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ opts.forEach(function (opt) {
5454
res.actualSignal = null
5555
}
5656
res.stderr = stderr.trim().split('\n')
57-
t.same(res, expect[opt])
57+
if (!t.same(res, expect[opt], opt)) {
58+
console.error([opt, { ...(err || {})}, res, expect[opt]])
59+
}
5860
t.end()
5961
})
6062
})

test/signal-capture.ts

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
2+
import { resolve } from 'node:path'
3+
import t from 'tap'
4+
5+
const f = resolve(__dirname, 'fixtures/signal-capture.js')
6+
7+
const skip = process.platform === 'win32' ? 'skip on windows' : false
8+
9+
type Result = {
10+
stdout: string
11+
stderr: string
12+
code: null | number
13+
signal: null | NodeJS.Signals
14+
}
15+
16+
type PromiseWithProc<T> = Promise<T> & {
17+
proc: ChildProcessWithoutNullStreams
18+
}
19+
const run = (
20+
exit: number | NodeJS.Signals | 'null' = 'null',
21+
capture: 'capture' | 'captureExit' | 'captureAfterExit' | 'none' = 'none'
22+
): PromiseWithProc<Result> => {
23+
const args: string[] = [f, String(exit), capture]
24+
const proc = spawn(process.execPath, args)
25+
const { stdout, stderr } = proc
26+
const out: Buffer[] = []
27+
const err: Buffer[] = []
28+
stdout.on('data', c => out.push(c))
29+
stderr.on('data', c => err.push(c))
30+
return Object.assign(
31+
new Promise<Result>(r => {
32+
proc.on('close', (code, signal) => {
33+
r({
34+
code,
35+
signal,
36+
stdout: Buffer.concat(out).toString(),
37+
stderr: Buffer.concat(err).toString(),
38+
})
39+
})
40+
}),
41+
{ proc }
42+
)
43+
}
44+
45+
t.test('graceful exit', async t => {
46+
const r = await run()
47+
t.match(r, { code: 0, signal: null })
48+
t.equal(r.stderr, '')
49+
t.matchSnapshot(r.stdout)
50+
})
51+
52+
t.test('exit 0', async t => {
53+
const r = await run(0)
54+
t.match(r, { code: 0, signal: null })
55+
t.equal(r.stderr, '')
56+
t.matchSnapshot(r.stdout)
57+
})
58+
59+
t.test('exit 1', async t => {
60+
const r = await run(1)
61+
t.match(r, { code: 1, signal: null })
62+
t.equal(r.stderr, '')
63+
t.matchSnapshot(r.stdout)
64+
})
65+
66+
t.test('signal, no capture', { skip }, async t => {
67+
const r = await run('SIGHUP')
68+
t.match(r, { code: null, signal: 'SIGHUP' })
69+
t.equal(r.stderr, '')
70+
t.matchSnapshot(r.stdout)
71+
})
72+
73+
t.test('signal, capture exit', { skip }, async t => {
74+
const r = await run('SIGHUP', 'captureExit')
75+
t.match(r, { code: 0, signal: null })
76+
t.equal(r.stderr, '')
77+
t.matchSnapshot(r.stdout)
78+
})
79+
80+
t.test('signal, capture afterExit', { skip }, async t => {
81+
const r = await run('SIGHUP', 'captureAfterExit')
82+
t.match(r, { code: 0, signal: null })
83+
t.equal(r.stderr, '')
84+
t.matchSnapshot(r.stdout)
85+
})
86+
87+
t.test('signal, capture both', { skip }, async t => {
88+
const r = await run('SIGHUP', 'capture')
89+
t.match(r, { code: 0, signal: null })
90+
t.equal(r.stderr, '')
91+
t.matchSnapshot(r.stdout)
92+
})

0 commit comments

Comments
 (0)