Skip to content

Commit

Permalink
Add feature to retry process killing after specified duration (#208)
Browse files Browse the repository at this point in the history
Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
  • Loading branch information
zokker13 and sindresorhus committed Jun 1, 2019
1 parent fcd3d85 commit 0bd5596
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 1 deletion.
12 changes: 12 additions & 0 deletions 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);
21 changes: 21 additions & 0 deletions index.d.ts
Expand Up @@ -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<StdoutErrorType> {
catch<ResultType = never>(
onRejected?: (reason: ExecaError<StdoutErrorType>) => ResultType | PromiseLike<ResultType>
): Promise<ExecaReturnValue<StdoutErrorType> | 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`.
*/
Expand Down
25 changes: 25 additions & 0 deletions index.js
Expand Up @@ -213,6 +213,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;
Expand All @@ -231,6 +239,23 @@ const execa = (file, args, options) => {
}));
}

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) {
Expand Down
8 changes: 8 additions & 0 deletions index.test-d.ts
Expand Up @@ -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<ExecaChildProcess<string>>(execa('unicorns'));
expectType<ExecaReturnValue<string>>(await execa('unicorns'));
Expand Down
27 changes: 26 additions & 1 deletion readme.md
Expand Up @@ -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

Expand All @@ -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`<br>
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`<br>
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`.
Expand Down
61 changes: 61 additions & 0 deletions test.js
Expand Up @@ -116,6 +116,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');
Expand Down

0 comments on commit 0bd5596

Please sign in to comment.