From 010d858d6834c36b368213c24183e04238f94a8e Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 18 Jun 2026 13:19:48 +0200 Subject: [PATCH 1/2] 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. --- bin/cli.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/bin/cli.js b/bin/cli.js index f2066d267..5854dec08 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -38,9 +38,39 @@ void (async () => { }, ) + // The child shares our process group and handles the signal itself; wait briefly for it + // to exit (so its final output isn't printed after the prompt returns) and mirror its + // exit below. SIGKILL and leave if it outlasts the grace, or on a second signal. + const SHUTDOWN_GRACE_MS = 3_000 + const hardAbort = signalName => { + const child = spawnPromise.process + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + } + // eslint-disable-next-line n/no-process-exit + process.exit(signalName === 'SIGTERM' ? 143 : 130) + } + let sawSignal = false + const onSignal = signalName => { + if (sawSignal) { + hardAbort(signalName) + return + } + sawSignal = true + setTimeout(() => hardAbort(signalName), SHUTDOWN_GRACE_MS).unref?.() + } + const onSigint = () => onSignal('SIGINT') + const onSigterm = () => onSignal('SIGTERM') + process.on('SIGINT', onSigint) + process.on('SIGTERM', onSigterm) + // See https://nodejs.org/api/child_process.html#event-exit. spawnPromise.process.on('exit', (code, signalName) => { if (signalName) { + // Mirror a signal death. Drop our own handlers first so the re-raise actually + // terminates us instead of being swallowed by onSigint/onSigterm above. + process.removeListener('SIGINT', onSigint) + process.removeListener('SIGTERM', onSigterm) process.kill(process.pid, signalName) } else if (typeof code === 'number') { // eslint-disable-next-line n/no-process-exit From 65534b2f7c4b1949c389a24b9ddb32c451b8014c Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 18 Jun 2026 14:23:10 +0200 Subject: [PATCH 2/2] 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. --- bin/cli.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bin/cli.js b/bin/cli.js index 5854dec08..65a478640 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -3,6 +3,7 @@ void (async () => { const Module = require('node:module') + const os = require('node:os') const path = require('node:path') const rootPath = path.join(__dirname, '..') Module.enableCompileCache?.(path.join(rootPath, '.cache')) @@ -67,11 +68,12 @@ void (async () => { // See https://nodejs.org/api/child_process.html#event-exit. spawnPromise.process.on('exit', (code, signalName) => { if (signalName) { - // Mirror a signal death. Drop our own handlers first so the re-raise actually - // terminates us instead of being swallowed by onSigint/onSigterm above. - process.removeListener('SIGINT', onSigint) - process.removeListener('SIGTERM', onSigterm) - process.kill(process.pid, signalName) + // Mirror a signal death as the conventional 128 + signum exit code. Exit explicitly + // rather than re-raising the signal: with our handlers installed the re-raise would + // race `await spawnPromise` resolving and could leave the default exitCode of 1. + const signum = os.constants.signals[signalName] ?? 0 + // eslint-disable-next-line n/no-process-exit + process.exit(128 + signum) } else if (typeof code === 'number') { // eslint-disable-next-line n/no-process-exit process.exit(code)