Skip to content

Commit 9d9f977

Browse files
Nitzan UzielyLinkgoron
Nitzan Uziely
authored andcommitted
child_process: add timeout to spawn and fork
Add support for timeout to spawn and fork. Fixes: nodejs#27639
1 parent 5968c54 commit 9d9f977

4 files changed

+133
-8
lines changed

doc/api/child_process.md

+13-3
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,9 @@ controller.abort();
351351
<!-- YAML
352352
added: v0.5.0
353353
changes:
354+
- version: REPLACEME
355+
pr-url: https://github.com/nodejs/node/pull/37256
356+
description: timeout was added.
354357
- version: REPLACEME
355358
pr-url: https://github.com/nodejs/node/pull/37325
356359
description: killSignal for AbortSignal was added.
@@ -387,7 +390,7 @@ changes:
387390
See [Advanced serialization][] for more details. **Default:** `'json'`.
388391
* `signal` {AbortSignal} Allows closing the subprocess using an AbortSignal.
389392
* `killSignal` {string} The signal value to be used when the spawned
390-
process will be killed by the abort signal. **Default:** `'SIGTERM'`.
393+
process will be killed by timeout or abort signal. **Default:** `'SIGTERM'`.
391394
* `silent` {boolean} If `true`, stdin, stdout, and stderr of the child will be
392395
piped to the parent, otherwise they will be inherited from the parent, see
393396
the `'pipe'` and `'inherit'` options for [`child_process.spawn()`][]'s
@@ -399,6 +402,8 @@ changes:
399402
* `uid` {number} Sets the user identity of the process (see setuid(2)).
400403
* `windowsVerbatimArguments` {boolean} No quoting or escaping of arguments is
401404
done on Windows. Ignored on Unix. **Default:** `false`.
405+
* `timeout` {number} In milliseconds the maximum amount of time the process
406+
is allowed to run. **Default:** `undefined`.
402407
* Returns: {ChildProcess}
403408

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

491501
* Returns: {ChildProcess}
492502

lib/child_process.js

+20-5
Original file line numberDiff line numberDiff line change
@@ -599,15 +599,14 @@ function abortChildProcess(child, killSignal) {
599599

600600

601601
function spawn(file, args, options) {
602-
const child = new ChildProcess();
603602
options = normalizeSpawnArguments(file, args, options);
603+
validateTimeout(options.timeout, 'options.timeout');
604+
validateAbortSignal(options.signal, 'options.signal');
605+
const killSignal = sanitizeKillSignal(options.killSignal);
606+
const child = new ChildProcess();
604607

605608
if (options.signal) {
606609
const signal = options.signal;
607-
// Validate signal, if present
608-
validateAbortSignal(signal, 'options.signal');
609-
const killSignal = sanitizeKillSignal(options.killSignal);
610-
// Do nothing and throw if already aborted
611610
if (signal.aborted) {
612611
onAbortListener();
613612
} else {
@@ -626,6 +625,22 @@ function spawn(file, args, options) {
626625
debug('spawn', options);
627626
child.spawn(options);
628627

628+
if (options.timeout > 0) {
629+
let timeoutId = setTimeout(() => {
630+
if (timeoutId) {
631+
child.kill(killSignal);
632+
timeoutId = null;
633+
}
634+
}, options.timeout);
635+
636+
child.once('exit', () => {
637+
if (timeoutId) {
638+
clearTimeout(timeoutId);
639+
timeoutId = null;
640+
}
641+
});
642+
}
643+
629644
return child;
630645
}
631646

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
'use strict';
2+
3+
const { mustCall } = require('../common');
4+
const { strictEqual, throws } = require('assert');
5+
const fixtures = require('../common/fixtures');
6+
const { fork } = require('child_process');
7+
const { getEventListeners } = require('events');
8+
9+
{
10+
// Verify default signal
11+
const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), {
12+
timeout: 5,
13+
});
14+
cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGTERM')));
15+
}
16+
17+
{
18+
// Verify correct signal + closes after at least 4 ms.
19+
const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), {
20+
timeout: 5,
21+
killSignal: 'SIGKILL',
22+
});
23+
cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGKILL')));
24+
}
25+
26+
{
27+
// Verify timeout verification
28+
throws(() => fork(fixtures.path('child-process-stay-alive-forever.js'), {
29+
timeout: 'badValue',
30+
}), /ERR_OUT_OF_RANGE/);
31+
32+
throws(() => fork(fixtures.path('child-process-stay-alive-forever.js'), {
33+
timeout: {},
34+
}), /ERR_OUT_OF_RANGE/);
35+
}
36+
37+
{
38+
// Verify abort signal gets unregistered
39+
const signal = new EventTarget();
40+
signal.aborted = false;
41+
42+
const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), {
43+
timeout: 6,
44+
signal,
45+
});
46+
strictEqual(getEventListeners(signal, 'abort').length, 1);
47+
cp.on('exit', mustCall(() => {
48+
strictEqual(getEventListeners(signal, 'abort').length, 0);
49+
}));
50+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
'use strict';
2+
3+
const { mustCall } = require('../common');
4+
const { strictEqual, throws } = require('assert');
5+
const fixtures = require('../common/fixtures');
6+
const { spawn } = require('child_process');
7+
const { getEventListeners } = require('events');
8+
9+
const aliveForeverFile = 'child-process-stay-alive-forever.js';
10+
{
11+
// Verify default signal + closes
12+
const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
13+
timeout: 5,
14+
});
15+
cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGTERM')));
16+
}
17+
18+
{
19+
// Verify SIGKILL signal + closes
20+
const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
21+
timeout: 6,
22+
killSignal: 'SIGKILL',
23+
});
24+
cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGKILL')));
25+
}
26+
27+
{
28+
// Verify timeout verification
29+
throws(() => spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
30+
timeout: 'badValue',
31+
}), /ERR_OUT_OF_RANGE/);
32+
33+
throws(() => spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
34+
timeout: {},
35+
}), /ERR_OUT_OF_RANGE/);
36+
}
37+
38+
{
39+
// Verify abort signal gets unregistered
40+
const controller = new AbortController();
41+
const { signal } = controller;
42+
const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
43+
timeout: 6,
44+
signal,
45+
});
46+
strictEqual(getEventListeners(signal, 'abort').length, 1);
47+
cp.on('exit', mustCall(() => {
48+
strictEqual(getEventListeners(signal, 'abort').length, 0);
49+
}));
50+
}

0 commit comments

Comments
 (0)