Skip to content

Commit

Permalink
child_process: add timeout to spawn and fork
Browse files Browse the repository at this point in the history
Add support for timeout to spawn and fork.

Fixes: nodejs#27639
  • Loading branch information
Nitzan Uziely authored and Linkgoron committed Mar 3, 2021
1 parent 26288ff commit abfbeeb
Show file tree
Hide file tree
Showing 4 changed files with 137 additions and 8 deletions.
16 changes: 13 additions & 3 deletions doc/api/child_process.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,9 @@ controller.abort();
<!-- YAML
added: v0.5.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/37256
description: timeout was added.
- version: v15.11.0
pr-url: https://github.com/nodejs/node/pull/37325
description: killSignal for AbortSignal was added.
Expand Down Expand Up @@ -387,7 +390,7 @@ changes:
See [Advanced serialization][] for more details. **Default:** `'json'`.
* `signal` {AbortSignal} Allows closing the subprocess using an AbortSignal.
* `killSignal` {string} The signal value to be used when the spawned
process will be killed by the abort signal. **Default:** `'SIGTERM'`.
process will be killed by timeout or abort signal. **Default:** `'SIGTERM'`.
* `silent` {boolean} If `true`, stdin, stdout, and stderr of the child will be
piped to the parent, otherwise they will be inherited from the parent, see
the `'pipe'` and `'inherit'` options for [`child_process.spawn()`][]'s
Expand All @@ -399,6 +402,8 @@ changes:
* `uid` {number} Sets the user identity of the process (see setuid(2)).
* `windowsVerbatimArguments` {boolean} No quoting or escaping of arguments is
done on Windows. Ignored on Unix. **Default:** `false`.
* `timeout` {number} In milliseconds the maximum amount of time the process
is allowed to run. **Default:** `undefined`.
* Returns: {ChildProcess}

The `child_process.fork()` method is a special case of
Expand Down Expand Up @@ -436,6 +441,9 @@ The `signal` option works exactly the same way it does in
<!-- YAML
added: v0.1.90
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/37256
description: timeout was added.
- version: v15.11.0
pr-url: https://github.com/nodejs/node/pull/37325
description: killSignal for AbortSignal was added.
Expand Down Expand Up @@ -485,8 +493,10 @@ changes:
* `windowsHide` {boolean} Hide the subprocess console window that would
normally be created on Windows systems. **Default:** `false`.
* `signal` {AbortSignal} allows aborting the execFile using an AbortSignal.
* `killSignal` {string} The signal value to be used when the spawned
process will be killed by the abort signal. **Default:** `'SIGTERM'`.
* `timeout` {number} In milliseconds the maximum amount of time the process
is allowed to run. **Default:** `undefined`.
* `killSignal` {string|integer} The signal value to be used when the spawned
process will be killed by timeout or abort signal. **Default:** `'SIGTERM'`.

* Returns: {ChildProcess}

Expand Down
29 changes: 24 additions & 5 deletions lib/child_process.js
Original file line number Diff line number Diff line change
Expand Up @@ -599,15 +599,14 @@ function abortChildProcess(child, killSignal) {


function spawn(file, args, options) {
const child = new ChildProcess();
options = normalizeSpawnArguments(file, args, options);
validateTimeout(options.timeout, 'options.timeout');
validateAbortSignal(options.signal, 'options.signal');
const killSignal = sanitizeKillSignal(options.killSignal);
const child = new ChildProcess();

if (options.signal) {
const signal = options.signal;
// Validate signal, if present
validateAbortSignal(signal, 'options.signal');
const killSignal = sanitizeKillSignal(options.killSignal);
// Do nothing and throw if already aborted
if (signal.aborted) {
onAbortListener();
} else {
Expand All @@ -626,6 +625,26 @@ function spawn(file, args, options) {
debug('spawn', options);
child.spawn(options);

if (options.timeout > 0) {
let timeoutId = setTimeout(() => {
if (timeoutId) {
try {
child.kill(killSignal);
} catch (err) {
child.emit('error', err);
}
timeoutId = null;
}
}, options.timeout);

child.once('exit', () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
});
}

return child;
}

Expand Down
50 changes: 50 additions & 0 deletions test/parallel/test-child-process-fork-timeout-kill-signal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use strict';

const { mustCall } = require('../common');
const { strictEqual, throws } = require('assert');
const fixtures = require('../common/fixtures');
const { fork } = require('child_process');
const { getEventListeners } = require('events');

{
// Verify default signal
const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), {
timeout: 5,
});
cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGTERM')));
}

{
// Verify correct signal + closes after at least 4 ms.
const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), {
timeout: 5,
killSignal: 'SIGKILL',
});
cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGKILL')));
}

{
// Verify timeout verification
throws(() => fork(fixtures.path('child-process-stay-alive-forever.js'), {
timeout: 'badValue',
}), /ERR_OUT_OF_RANGE/);

throws(() => fork(fixtures.path('child-process-stay-alive-forever.js'), {
timeout: {},
}), /ERR_OUT_OF_RANGE/);
}

{
// Verify abort signal gets unregistered
const signal = new EventTarget();
signal.aborted = false;

const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), {
timeout: 6,
signal,
});
strictEqual(getEventListeners(signal, 'abort').length, 1);
cp.on('exit', mustCall(() => {
strictEqual(getEventListeners(signal, 'abort').length, 0);
}));
}
50 changes: 50 additions & 0 deletions test/parallel/test-child-process-spawn-timeout-kill-signal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use strict';

const { mustCall } = require('../common');
const { strictEqual, throws } = require('assert');
const fixtures = require('../common/fixtures');
const { spawn } = require('child_process');
const { getEventListeners } = require('events');

const aliveForeverFile = 'child-process-stay-alive-forever.js';
{
// Verify default signal + closes
const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
timeout: 5,
});
cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGTERM')));
}

{
// Verify SIGKILL signal + closes
const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
timeout: 6,
killSignal: 'SIGKILL',
});
cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGKILL')));
}

{
// Verify timeout verification
throws(() => spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
timeout: 'badValue',
}), /ERR_OUT_OF_RANGE/);

throws(() => spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
timeout: {},
}), /ERR_OUT_OF_RANGE/);
}

{
// Verify abort signal gets unregistered
const controller = new AbortController();
const { signal } = controller;
const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
timeout: 6,
signal,
});
strictEqual(getEventListeners(signal, 'abort').length, 1);
cp.on('exit', mustCall(() => {
strictEqual(getEventListeners(signal, 'abort').length, 0);
}));
}

0 comments on commit abfbeeb

Please sign in to comment.