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,