Skip to content
Merged
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
7 changes: 6 additions & 1 deletion bin/concurrently.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ const program = yargs(hideBin(process.argv))
type: 'string',
default: defaults.killSignal,
},
'kill-timeout': {
describe: 'How many milliseconds to wait before forcing process terminating.',
type: 'number',
},

// Prefix
prefix: {
Expand Down Expand Up @@ -208,7 +212,7 @@ const program = yargs(hideBin(process.argv))
)
.group(['p', 'c', 'l', 't', 'pad-prefix'], 'Prefix styling')
.group(['i', 'default-input-target'], 'Input handling')
.group(['k', 'kill-others-on-fail', 'kill-signal'], 'Killing other processes')
.group(['k', 'kill-others-on-fail', 'kill-signal', 'kill-timeout'], 'Killing other processes')
.group(['restart-tries', 'restart-after'], 'Restarting')
.epilogue(epilogue);

Expand Down Expand Up @@ -244,6 +248,7 @@ concurrently(
? ['failure']
: [],
killSignal: args.killSignal,
killTimeout: args.killTimeout,
maxProcesses: args.maxProcesses,
raw: args.raw,
hide: args.hide.split(','),
Expand Down
16 changes: 16 additions & 0 deletions docs/cli/terminating.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,19 @@ The default is `SIGTERM`, but it's also possible to send `SIGKILL`.
```bash
$ concurrently --kill-others --kill-signal SIGKILL 'npm start' 'npm test'
```

### Timeout

In case you have a misbehaving process that ignores the kill signal, you can force kill it after some
timeout (in milliseconds) by using the `--kill-timeout` flag.
This sends a `SIGKILL`, which cannot be caught.

```bash
$ concurrently --kill-others --kill-timeout 1000 'sleep 1 && echo bye' './misbehaving'
[0] bye
[0] sleep 1 && echo bye exited with code 0
--> Sending SIGTERM to other processes..
[1] IGNORING SIGNAL
--> Sending SIGKILL to 1 processes..
[1] ./misbehaving exited with code SIGKILL
```
32 changes: 29 additions & 3 deletions src/flow-control/kill-others.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ beforeEach(() => {
abortController = new AbortController();
});

const createWithConditions = (conditions: ProcessCloseCondition[], killSignal?: string) =>
const createWithConditions = (
conditions: ProcessCloseCondition[],
opts?: { timeoutMs?: number; killSignal?: string },
) =>
new KillOthers({
logger,
abortController,
conditions,
killSignal,
killSignal: undefined,
...opts,
});

const assignProcess = (command: FakeCommand) => {
Expand All @@ -27,6 +31,11 @@ const assignProcess = (command: FakeCommand) => {
command.process = process;
};

const unassignProcess = (command: FakeCommand) => {
command.pid = undefined;
command.process = undefined;
};

it('returns same commands', () => {
expect(createWithConditions(['success']).handle(commands)).toMatchObject({ commands });
expect(createWithConditions(['failure']).handle(commands)).toMatchObject({ commands });
Expand Down Expand Up @@ -58,7 +67,7 @@ describe.each(['success', 'failure'] as const)('on %s', (condition) => {
});

it('kills other processes, with specified signal', () => {
createWithConditions([condition], 'SIGKILL').handle(commands);
createWithConditions([condition], { killSignal: 'SIGKILL' }).handle(commands);
assignProcess(commands[1]);
commands[0].close.next(createFakeCloseEvent({ exitCode }));

Expand Down Expand Up @@ -100,3 +109,20 @@ it('does not try to kill processes already dead', () => {
expect(commands[0].kill).not.toHaveBeenCalled();
expect(commands[1].kill).not.toHaveBeenCalled();
});

it('force kills misbehaving processes after a timeout', () => {
jest.useFakeTimers();
commands.push(new FakeCommand());

createWithConditions(['failure'], { timeoutMs: 500 }).handle(commands);
assignProcess(commands[1]);
assignProcess(commands[2]);
commands[2].kill = jest.fn(() => unassignProcess(commands[2]));
commands[0].close.next(createFakeCloseEvent({ exitCode: 1 }));

jest.advanceTimersByTime(500);

expect(commands[1].kill).toHaveBeenCalledTimes(2);
expect(commands[1].kill).toHaveBeenCalledWith('SIGKILL');
expect(commands[2].kill).toHaveBeenCalledTimes(1);
});
22 changes: 22 additions & 0 deletions src/flow-control/kill-others.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,26 @@ export class KillOthers implements FlowController {
private readonly abortController?: AbortController;
private readonly conditions: ProcessCloseCondition[];
private readonly killSignal: string | undefined;
private readonly timeoutMs?: number;

constructor({
logger,
abortController,
conditions,
killSignal,
timeoutMs,
}: {
logger: Logger;
abortController?: AbortController;
conditions: ProcessCloseCondition | ProcessCloseCondition[];
killSignal: string | undefined;
timeoutMs?: number;
}) {
this.logger = logger;
this.abortController = abortController;
this.conditions = _.castArray(conditions);
this.killSignal = killSignal;
this.timeoutMs = timeoutMs;
}

handle(commands: Command[]) {
Expand Down Expand Up @@ -61,10 +65,28 @@ export class KillOthers implements FlowController {
`Sending ${this.killSignal || 'SIGTERM'} to other processes..`,
);
killableCommands.forEach((command) => command.kill(this.killSignal));
this.maybeForceKill(killableCommands);
}
}),
);

return { commands };
}

private maybeForceKill(commands: Command[]) {
// No need to force kill when the signal already is SIGKILL.
if (!this.timeoutMs || this.killSignal === 'SIGKILL') {
return;
}

setTimeout(() => {
const killableCommands = commands.filter((command) => Command.canKill(command));
if (killableCommands) {
this.logger.logGlobalEvent(
`Sending SIGKILL to ${killableCommands.length} processes..`,
);
killableCommands.forEach((command) => command.kill('SIGKILL'));
}
}, this.timeoutMs);
}
}
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ export type ConcurrentlyOptions = Omit<BaseConcurrentlyOptions, 'abortSignal' |
*/
killSignal?: string;

/**
* How many milliseconds to wait before killing processes.
*/
killTimeout?: number;

// Timing options
/**
* Whether to output timing information for processes.
Expand Down Expand Up @@ -173,6 +178,7 @@ export function concurrently(
new KillOthers({
logger,
conditions: options.killOthersOn || options.killOthers || [],
timeoutMs: options.killTimeout,
killSignal: options.killSignal,
abortController,
}),
Expand Down