diff --git a/fixtures/no-killable b/fixtures/no-killable new file mode 100644 index 0000000000..3ce9ba121f --- /dev/null +++ b/fixtures/no-killable @@ -0,0 +1,12 @@ +#!/usr/bin/env node +'use strict'; + +process.on('SIGTERM', () => { + console.log('Received SIGTERM, but we ignore it'); +}); + +process.send(''); + +setInterval(() => { + // Run forever +}, 20000); diff --git a/index.d.ts b/index.d.ts index beee76b01d..fd814f0f2a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -279,11 +279,32 @@ declare namespace execa { isCanceled: boolean; } + interface KillOptions { + /** + If the first signal does not terminate the child process after a specified timeout, a `SIGKILL` signal will be sent to the process. + + @default true + */ + forceKill?: boolean; + + /** + Milliseconds to wait for the child process to terminate before sending a `SIGKILL` signal. + + @default 5000 + */ + forceKillAfter?: number; + } + interface ExecaChildPromise { catch( onRejected?: (reason: ExecaError) => ResultType | PromiseLike ): Promise | ResultType>; + /** + Same as the original [`child_process#kill()`](https://nodejs.org/api/child_process.html#child_process_subprocess_kill_signal), except if `signal` is `SIGTERM` (the default value) and the child process is not terminated after 5 seconds, force it by sending `SIGKILL`. + */ + kill(signal?: string, options?: execa.KillOptions): void; + /** Similar to [`childProcess.kill()`](https://nodejs.org/api/child_process.html#child_process_subprocess_kill_signal). This is preferred when cancelling the child process execution as the error is more descriptive and [`childProcessResult.isCanceled`](#iscanceled) is set to `true`. */ diff --git a/index.js b/index.js index bb3d217c2f..73f3b196f2 100644 --- a/index.js +++ b/index.js @@ -209,6 +209,14 @@ function joinCommand(file, args = []) { return [file, ...args].join(' '); } +function shouldForceKill(signal, options, killResult) { + return ((typeof signal === 'string' && + signal.toUpperCase() === 'SIGTERM') || + signal === os.constants.signals.SIGTERM) && + options.forceKill !== false && + killResult; +} + const execa = (file, args, options) => { const parsed = handleArgs(file, args, options); const {encoding, buffer, maxBuffer} = parsed.options; @@ -221,6 +229,23 @@ const execa = (file, args, options) => { return Promise.reject(error); } + const originalKill = spawned.kill.bind(spawned); + spawned.kill = (signal = 'SIGTERM', options = {}) => { + const killResult = originalKill(signal); + if (shouldForceKill(signal, options, killResult)) { + const forceKillAfter = Number.isInteger(options.forceKillAfter) ? + options.forceKillAfter : + 5000; + setTimeout(() => { + try { + originalKill('SIGKILL'); + } catch (_) {} + }, forceKillAfter).unref(); + } + + return killResult; + }; + // #115 let removeExitHandler; if (parsed.options.cleanup && !parsed.options.detached) { diff --git a/index.test-d.ts b/index.test-d.ts index ef8d904015..653e97b251 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -119,6 +119,14 @@ execa('unicorns', {maxBuffer: 1000}); execa('unicorns', {killSignal: 'SIGTERM'}); execa('unicorns', {killSignal: 9}); execa('unicorns', {windowsVerbatimArguments: true}); +execa('unicorns').kill(); +execa('unicorns').kill('SIGKILL'); +execa('unicorns').kill(undefined); +execa('unicorns').kill('SIGKILL', {}); +execa('unicorns').kill('SIGKILL', {forceKill: true}); +execa('unicorns').kill('SIGKILL', {forceKill: false}); +execa('unicorns').kill('SIGKILL', {forceKillAfter: 42}); +execa('unicorns').kill('SIGKILL', {forceKillAfter: undefined}); expectType>(execa('unicorns')); expectType>(await execa('unicorns')); diff --git a/readme.md b/readme.md index 9edfdaf420..0539d8c1f7 100644 --- a/readme.md +++ b/readme.md @@ -112,8 +112,15 @@ try { } */ } -``` +// Kill a process with SIGTERM, and after 2 seconds, kill it with SIGKILL +const subprocess = execa('node'); +setTimeout(() => { + subprocess.kill('SIGTERM', { + forceKillAfter: 2000 + }); +}, 1000); +``` ## API @@ -129,6 +136,24 @@ Returns a [`child_process` instance](https://nodejs.org/api/child_process.html#c - is also a `Promise` resolving or rejecting with a [`childProcessResult`](#childProcessResult). - exposes the following additional methods and properties. +#### kill([signal], [options]) + +Same as the original [`child_process#kill()`](https://nodejs.org/api/child_process.html#child_process_subprocess_kill_signal) except: if `signal` is `SIGTERM` (the default value) and the child process is not terminated after 5 seconds, force it by sending `SIGKILL`. + +##### options.forceKill + +Type: `boolean`
+Default: `true` + +If the first signal does not terminate the child process after a specified timeout, a `SIGKILL` signal will be sent to the process. + +##### options.forceKillAfter + +Type: `string`
+Default: `5000` + +Milliseconds to wait for the child process to terminate before sending `SIGKILL`. + #### cancel() Similar to [`childProcess.kill()`](https://nodejs.org/api/child_process.html#child_process_subprocess_kill_signal). This is preferred when cancelling the child process execution as the error is more descriptive and [`childProcessResult.isCanceled`](#iscanceled) is set to `true`. diff --git a/test.js b/test.js index aa74acd5e4..48df0daae4 100644 --- a/test.js +++ b/test.js @@ -118,6 +118,67 @@ test('skip throwing when using reject option in sync mode', t => { t.is(exitCode, 2); }); +test('execa() with .kill() after it with SIGKILL should kill cleanly', async t => { + const subprocess = execa('node', ['fixtures/no-killable'], { + stdio: ['ipc'] + }); + + await pEvent(subprocess, 'message'); + + subprocess.kill('SIGKILL'); + + const {signal} = await t.throwsAsync(subprocess); + t.is(signal, 'SIGKILL'); +}); + +// `SIGTERM` cannot be caught on Windows, and it always aborts the process (like `SIGKILL` on Unix). +// Therefore, this feature and those tests do not make sense on Windows. +if (process.platform !== 'win32') { + test('execa() with .kill() after it with SIGTERM should not kill (no retry)', async t => { + const subprocess = execa('node', ['fixtures/no-killable'], { + stdio: ['ipc'] + }); + + await pEvent(subprocess, 'message'); + + subprocess.kill('SIGTERM', { + forceKill: false, + forceKillAfter: 50 + }); + + t.true(isRunning(subprocess.pid)); + subprocess.kill('SIGKILL'); + }); + + test('execa() with .kill() after it with SIGTERM should kill after 50 ms with SIGKILL', async t => { + const subprocess = execa('node', ['fixtures/no-killable'], { + stdio: ['ipc'] + }); + + await pEvent(subprocess, 'message'); + + subprocess.kill('SIGTERM', { + forceKillAfter: 50 + }); + + const {signal} = await t.throwsAsync(subprocess); + t.is(signal, 'SIGKILL'); + }); + + test('execa() with .kill() after it with nothing (undefined) should kill after 50 ms with SIGKILL', async t => { + const subprocess = execa('node', ['fixtures/no-killable'], { + stdio: ['ipc'] + }); + + await pEvent(subprocess, 'message'); + + subprocess.kill(); + + const {signal} = await t.throwsAsync(subprocess); + t.is(signal, 'SIGKILL'); + }); +} + test('stripFinalNewline: true', async t => { const {stdout} = await execa('noop', ['foo']); t.is(stdout, 'foo');