Skip to content

Commit feafd78

Browse files
authored
fix(cli): interrupt running commands cleanly on Ctrl+C (#1370)
* fix(cli): interrupt running commands cleanly on Ctrl+C The launcher (bin/cli.js) spawns the real CLI as a child with inherited stdio. Previously it was torn down by SIGINT before that child finished, so the child's final status printed after the shell prompt had already returned (and the child could be left running in the background). Wait briefly for the child to handle the interrupt itself and mirror its exit, so output stays ordered. The wait is bounded: if the child outlives a short grace period, or on a second Ctrl+C, SIGKILL it and exit with the conventional 128+signum code. The grace timer is unref'd, so a child that exits on its own is mirrored with no added latency. * fix(cli): mirror child signal death as 128+signum, not re-raise Addresses review feedback: re-raising the child's signal on the launcher raced against `await spawnPromise` resolving and could leave the default `process.exitCode` of 1, so Ctrl+C could report exit 1 instead of 130. Exit explicitly with the conventional 128+signum code (resolved from os.constants.signals) in the on('exit') signal branch instead.
1 parent 5675a51 commit feafd78

1 file changed

Lines changed: 33 additions & 1 deletion

File tree

bin/cli.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
void (async () => {
55
const Module = require('node:module')
6+
const os = require('node:os')
67
const path = require('node:path')
78
const rootPath = path.join(__dirname, '..')
89
Module.enableCompileCache?.(path.join(rootPath, '.cache'))
@@ -38,10 +39,41 @@ void (async () => {
3839
},
3940
)
4041

42+
// The child shares our process group and handles the signal itself; wait briefly for it
43+
// to exit (so its final output isn't printed after the prompt returns) and mirror its
44+
// exit below. SIGKILL and leave if it outlasts the grace, or on a second signal.
45+
const SHUTDOWN_GRACE_MS = 3_000
46+
const hardAbort = signalName => {
47+
const child = spawnPromise.process
48+
if (child.exitCode === null && child.signalCode === null) {
49+
child.kill('SIGKILL')
50+
}
51+
// eslint-disable-next-line n/no-process-exit
52+
process.exit(signalName === 'SIGTERM' ? 143 : 130)
53+
}
54+
let sawSignal = false
55+
const onSignal = signalName => {
56+
if (sawSignal) {
57+
hardAbort(signalName)
58+
return
59+
}
60+
sawSignal = true
61+
setTimeout(() => hardAbort(signalName), SHUTDOWN_GRACE_MS).unref?.()
62+
}
63+
const onSigint = () => onSignal('SIGINT')
64+
const onSigterm = () => onSignal('SIGTERM')
65+
process.on('SIGINT', onSigint)
66+
process.on('SIGTERM', onSigterm)
67+
4168
// See https://nodejs.org/api/child_process.html#event-exit.
4269
spawnPromise.process.on('exit', (code, signalName) => {
4370
if (signalName) {
44-
process.kill(process.pid, signalName)
71+
// Mirror a signal death as the conventional 128 + signum exit code. Exit explicitly
72+
// rather than re-raising the signal: with our handlers installed the re-raise would
73+
// race `await spawnPromise` resolving and could leave the default exitCode of 1.
74+
const signum = os.constants.signals[signalName] ?? 0
75+
// eslint-disable-next-line n/no-process-exit
76+
process.exit(128 + signum)
4577
} else if (typeof code === 'number') {
4678
// eslint-disable-next-line n/no-process-exit
4779
process.exit(code)

0 commit comments

Comments
 (0)