Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion bin/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 9 additions & 0 deletions lib/flow-control/kill-on-signal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions lib/flow-control/kill-on-signal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
};
Expand Down
19 changes: 19 additions & 0 deletions lib/flow-control/log-exit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
21 changes: 20 additions & 1 deletion lib/flow-control/log-exit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 11 additions & 2 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -179,15 +188,15 @@ 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,
inputStream:
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,
Expand Down
Loading