From f0f4197d2a06461779394ce133de0a1f3d335d4c Mon Sep 17 00:00:00 2001 From: Pintu Kumar <64580363+pintu544@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:59:58 +0530 Subject: [PATCH] fix: skip per-command exit logs after a shutdown signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the user initiates a shutdown with SIGINT/SIGTERM/SIGHUP, the per-command 'exited with code ...' lines are noise: the shutdown was requested, so of course the commands exited. Worse, when the shell reclaims the terminal before teardown finishes, those late lines land on the fresh prompt and visually corrupt it. KillOnSignal now exposes an onSignal hook; LogExit takes an isShuttingDown predicate wired to it in lib/index.ts. Exit codes and shutdown behavior are unchanged — only the log lines are skipped, and only for closes that happen after the signal was caught. Fixes https://github.com/open-cli-tools/concurrently/issues/255 --- bin/index.spec.ts | 5 ++++- lib/flow-control/kill-on-signal.spec.ts | 9 +++++++++ lib/flow-control/kill-on-signal.ts | 9 +++++++++ lib/flow-control/log-exit.spec.ts | 19 +++++++++++++++++++ lib/flow-control/log-exit.ts | 21 ++++++++++++++++++++- lib/index.ts | 13 +++++++++++-- 6 files changed, 72 insertions(+), 4 deletions(-) diff --git a/bin/index.spec.ts b/bin/index.spec.ts index 461270c3..37c259c4 100644 --- a/bin/index.spec.ts +++ b/bin/index.spec.ts @@ -198,7 +198,10 @@ describe('exiting conditions', () => { const exit = await child.exit; expect(exit.code).toBe(0); - expect(lines).toContainEqual( + // After a user-initiated shutdown, the per-command exit log is suppressed: + // those late lines can otherwise land on the shell prompt after the + // terminal is reclaimed. See https://github.com/open-cli-tools/concurrently/issues/255 + expect(lines).not.toContainEqual( expect.stringMatching( createKillMessage( '[0] node __fixtures__/read-echo.js', diff --git a/lib/flow-control/kill-on-signal.spec.ts b/lib/flow-control/kill-on-signal.spec.ts index f2a672ad..c79dec70 100644 --- a/lib/flow-control/kill-on-signal.spec.ts +++ b/lib/flow-control/kill-on-signal.spec.ts @@ -72,6 +72,15 @@ describe.each(['SIGINT', 'SIGTERM', 'SIGHUP'])('on %s', (signal) => { expect(abortController.signal.aborted).toBe(true); }); + it('calls onSignal callback when provided', () => { + const onSignal = vi.fn(); + const controllerWithCallback = new KillOnSignal({ process, abortController, onSignal }); + controllerWithCallback.handle(commands); + process.emit(signal, signal); + + expect(onSignal).toHaveBeenCalledWith(signal); + }); + it('removes event listener on finish', () => { const { onFinish } = controller.handle(commands); onFinish(); diff --git a/lib/flow-control/kill-on-signal.ts b/lib/flow-control/kill-on-signal.ts index 48ecbfd7..6e4b8cdf 100644 --- a/lib/flow-control/kill-on-signal.ts +++ b/lib/flow-control/kill-on-signal.ts @@ -14,22 +14,31 @@ const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const; export class KillOnSignal implements FlowController { private readonly process: EventEmitter; private readonly abortController?: AbortController; + private readonly onSignal?: (signal: NodeJS.Signals) => void; constructor({ process, abortController, + onSignal, }: { process: EventEmitter; abortController?: AbortController; + /** + * Called whenever a signal is caught, before commands are killed. + * Useful for letting other parts of the app know a shutdown is in progress. + */ + onSignal?: (signal: NodeJS.Signals) => void; }) { this.process = process; this.abortController = abortController; + this.onSignal = onSignal; } handle(commands: Command[]) { let caughtSignal: NodeJS.Signals; const signalListener = (signal: NodeJS.Signals) => { caughtSignal = signal; + this.onSignal?.(signal); this.abortController?.abort(); commands.forEach((command) => command.kill(signal)); }; diff --git a/lib/flow-control/log-exit.spec.ts b/lib/flow-control/log-exit.spec.ts index 3837ff10..c8f9fb3c 100644 --- a/lib/flow-control/log-exit.spec.ts +++ b/lib/flow-control/log-exit.spec.ts @@ -35,3 +35,22 @@ it('logs the close event of each command', () => { commands[1], ); }); + +it('does not log close events while shutting down', () => { + const shuttingDownController = new LogExit({ logger, isShuttingDown: () => true }); + shuttingDownController.handle(commands); + + commands[0].close.next(createFakeCloseEvent({ exitCode: 0 })); + commands[1].close.next(createFakeCloseEvent({ exitCode: 'SIGINT' })); + + expect(logger.logCommandEvent).not.toHaveBeenCalled(); +}); + +it('logs close events when not shutting down', () => { + const shuttingDownController = new LogExit({ logger, isShuttingDown: () => false }); + shuttingDownController.handle(commands); + + commands[0].close.next(createFakeCloseEvent({ exitCode: 0 })); + + expect(logger.logCommandEvent).toHaveBeenCalledTimes(1); +}); diff --git a/lib/flow-control/log-exit.ts b/lib/flow-control/log-exit.ts index 18a603d3..d32428f4 100644 --- a/lib/flow-control/log-exit.ts +++ b/lib/flow-control/log-exit.ts @@ -7,14 +7,33 @@ import { FlowController } from './flow-controller.js'; */ export class LogExit implements FlowController { private readonly logger: Logger; + private readonly isShuttingDown?: () => boolean; - constructor({ logger }: { logger: Logger }) { + constructor({ + logger, + isShuttingDown, + }: { + logger: Logger; + /** + * When set, close events are not logged while it returns true. + * Used to silence the expected per-command exit noise after the user + * initiated a shutdown with a signal (e.g. Ctrl-C): those late log lines + * can otherwise land on the shell prompt after the terminal is reclaimed. + * + * @see https://github.com/open-cli-tools/concurrently/issues/255 + */ + isShuttingDown?: () => boolean; + }) { this.logger = logger; + this.isShuttingDown = isShuttingDown; } handle(commands: Command[]) { commands.forEach((command) => command.close.subscribe(({ exitCode }) => { + if (this.isShuttingDown?.()) { + return; + } this.logger.logCommandEvent( `${command.command} exited with code ${exitCode}`, command, diff --git a/lib/index.ts b/lib/index.ts index 03bb2c9d..a8b853cf 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -162,6 +162,15 @@ export function concurrently( const abortController = new AbortController(); const outputStream = options.outputStream || process.stdout; + // Tracks whether the user initiated a shutdown with a signal (e.g. Ctrl-C). + // While shutting down, per-command exit logs are noise — and when the shell + // reclaims the terminal before teardown finishes, those late lines can land + // on the fresh prompt. See https://github.com/open-cli-tools/concurrently/issues/255 + let signalCaught: NodeJS.Signals | undefined; + const onSignal = (signal: NodeJS.Signals) => { + signalCaught = signal; + }; + const spawn = createSpawn(options.shell); return createConcurrently(commands, { maxProcesses: options.maxProcesses, @@ -179,7 +188,7 @@ export function concurrently( ...(options.padPrefix ? [new LoggerPadding({ logger })] : []), new LogError({ logger }), new LogOutput({ logger }), - new LogExit({ logger }), + new LogExit({ logger, isShuttingDown: () => signalCaught != null }), new InputHandler({ logger, defaultInputTarget: options.defaultInputTarget, @@ -187,7 +196,7 @@ export function concurrently( options.inputStream || (options.handleInput ? process.stdin : undefined), pauseInputStreamOnFinish: options.pauseInputStreamOnFinish, }), - new KillOnSignal({ process, abortController }), + new KillOnSignal({ process, abortController, onSignal }), new RestartProcess({ logger, delay: options.restartDelay,