diff --git a/bin/concurrently.ts b/bin/concurrently.ts index 2e5e6fb0..026e9ff9 100755 --- a/bin/concurrently.ts +++ b/bin/concurrently.ts @@ -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: { @@ -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); @@ -244,6 +248,7 @@ concurrently( ? ['failure'] : [], killSignal: args.killSignal, + killTimeout: args.killTimeout, maxProcesses: args.maxProcesses, raw: args.raw, hide: args.hide.split(','), diff --git a/docs/cli/terminating.md b/docs/cli/terminating.md index e5e9f394..8aafd7a2 100644 --- a/docs/cli/terminating.md +++ b/docs/cli/terminating.md @@ -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 +``` diff --git a/src/flow-control/kill-others.spec.ts b/src/flow-control/kill-others.spec.ts index bcd23cea..2b2e3c8a 100644 --- a/src/flow-control/kill-others.spec.ts +++ b/src/flow-control/kill-others.spec.ts @@ -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) => { @@ -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 }); @@ -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 })); @@ -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); +}); diff --git a/src/flow-control/kill-others.ts b/src/flow-control/kill-others.ts index f693c879..b6d25acc 100644 --- a/src/flow-control/kill-others.ts +++ b/src/flow-control/kill-others.ts @@ -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[]) { @@ -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); + } } diff --git a/src/index.ts b/src/index.ts index 8f1c1fe5..ab9f1f27 100644 --- a/src/index.ts +++ b/src/index.ts @@ -94,6 +94,11 @@ export type ConcurrentlyOptions = Omit