Skip to content

Commit 8d45929

Browse files
author
Nitzan Uziely
committed
child_process: add timeout and killSignal to spawn and fork
Add support for timeout and killSignal to spawn and fork. Fixes: nodejs#27639
1 parent d6e9446 commit 8d45929

6 files changed

+256
-2
lines changed

doc/api/child_process.md

+14
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: xxx
356+
description: killSignal and timeout was added.
354357
- version: v15.6.0
355358
pr-url: https://github.com/nodejs/node/pull/36603
356359
description: AbortSignal support was added.
@@ -394,6 +397,10 @@ changes:
394397
* `uid` {number} Sets the user identity of the process (see setuid(2)).
395398
* `windowsVerbatimArguments` {boolean} No quoting or escaping of arguments is
396399
done on Windows. Ignored on Unix. **Default:** `false`.
400+
* `timeout` {number} In milliseconds the maximum amount of time the process
401+
is allowed to run. **Default:** `undefined`.
402+
* `killSignal` {string|integer} The signal value to be used when the spawned
403+
process will be killed by timeout or abort signal. **Default:** `'SIGTERM'`.
397404
* Returns: {ChildProcess}
398405

399406
The `child_process.fork()` method is a special case of
@@ -431,6 +438,9 @@ The `signal` option works exactly the same way it does in
431438
<!-- YAML
432439
added: v0.1.90
433440
changes:
441+
- version: REPLACEME
442+
pr-url: xxx
443+
description: killSignal and timeout were added.
434444
- version: v15.5.0
435445
pr-url: https://github.com/nodejs/node/pull/36432
436446
description: AbortSignal support was added.
@@ -477,6 +487,10 @@ changes:
477487
* `windowsHide` {boolean} Hide the subprocess console window that would
478488
normally be created on Windows systems. **Default:** `false`.
479489
* `signal` {AbortSignal} allows aborting the execFile using an AbortSignal.
490+
* `timeout` {number} In milliseconds the maximum amount of time the process
491+
is allowed to run. **Default:** `undefined`.
492+
* `killSignal` {string|integer} The signal value to be used when the spawned
493+
process will be killed by timeout or abort signal. **Default:** `'SIGTERM'`.
480494

481495
* Returns: {ChildProcess}
482496

lib/child_process.js

+41-1
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,26 @@ function fork(modulePath /* , args, options */) {
141141
options.execPath = options.execPath || process.execPath;
142142
options.shell = false;
143143

144-
return spawn(options.execPath, args, options);
144+
validateTimeout(options.timeout);
145+
if (options.killSignal) {
146+
options.killSignal = sanitizeKillSignal(options.killSignal);
147+
}
148+
const child = spawn(options.execPath, args, options);
149+
150+
if (options.timeout > 0) {
151+
let timeoutId = setTimeout(() => {
152+
child.kill(options.killSignal);
153+
timeoutId = null;
154+
}, options.timeout);
155+
156+
child.once('close', () => {
157+
if (timeoutId) {
158+
clearTimeout(timeoutId);
159+
timeoutId = null;
160+
}
161+
});
162+
}
163+
return child;
145164
}
146165

147166
function _forkChild(fd, serializationMode) {
@@ -758,6 +777,12 @@ function spawnWithSignal(file, args, options) {
758777
const opts = options && typeof options === 'object' && ('signal' in options) ?
759778
{ ...options, signal: undefined } :
760779
options;
780+
781+
// Validate the timeout, if present.
782+
validateTimeout(options?.timeout);
783+
if (options?.killSignal) {
784+
options.killSignal = sanitizeKillSignal(options.killSignal);
785+
}
761786
const child = spawn(file, args, opts);
762787

763788
if (options && options.signal) {
@@ -777,6 +802,21 @@ function spawnWithSignal(file, args, options) {
777802
child.once('close', remove);
778803
}
779804
}
805+
806+
if (options?.timeout > 0) {
807+
let timeoutId = setTimeout(() => {
808+
child.kill(options.killSignal);
809+
timeoutId = null;
810+
}, options.timeout);
811+
812+
child.once('close', () => {
813+
if (timeoutId) {
814+
clearTimeout(timeoutId);
815+
timeoutId = null;
816+
}
817+
});
818+
}
819+
780820
return child;
781821
}
782822
module.exports = {

test/parallel/test-child-process-fork-abort-signal.js

+16-1
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,23 @@ const { fork } = require('child_process');
2626
const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), {
2727
signal
2828
});
29-
cp.on('exit', mustCall());
29+
cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGTERM')));
30+
cp.on('error', mustCall((err) => {
31+
strictEqual(err.name, 'AbortError');
32+
}));
33+
}
34+
35+
{
36+
// Test correct signal sent
37+
const ac = new AbortController();
38+
const { signal } = ac;
39+
const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), {
40+
signal,
41+
killSignal: 'SIGKILL',
42+
});
43+
cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGKILL')));
3044
cp.on('error', mustCall((err) => {
3145
strictEqual(err.name, 'AbortError');
3246
}));
47+
process.nextTick(() => ac.abort());
3348
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
'use strict';
2+
3+
const { mustCall } = require('../common');
4+
const { strictEqual, ok, 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 + closes after at least 4 ms.
11+
let closed = false;
12+
setTimeout(() => ok(!closed), 4);
13+
const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), {
14+
timeout: 5,
15+
});
16+
cp.on('close', mustCall(() => closed = true));
17+
cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGTERM')));
18+
}
19+
20+
{
21+
// Verify correct signal + closes after at least 4 ms.
22+
let closed = false;
23+
setTimeout(() => ok(!closed), 4);
24+
const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), {
25+
timeout: 5,
26+
killSignal: 'SIGKILL',
27+
});
28+
cp.on('close', mustCall(() => closed = true));
29+
cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGKILL')));
30+
}
31+
32+
{
33+
// Verify timeout verification
34+
throws(() => fork(fixtures.path('child-process-stay-alive-forever.js'), {
35+
timeout: 'badValue',
36+
}), /ERR_OUT_OF_RANGE/);
37+
38+
throws(() => fork(fixtures.path('child-process-stay-alive-forever.js'), {
39+
timeout: {},
40+
}), /ERR_OUT_OF_RANGE/);
41+
}
42+
43+
{
44+
// Verify abort signal gets unregistered
45+
const signal = new EventTarget();
46+
signal.aborted = false;
47+
48+
const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), {
49+
timeout: 6,
50+
signal,
51+
});
52+
strictEqual(getEventListeners(signal, 'abort').length, 1);
53+
cp.on('close', mustCall(() => {
54+
strictEqual(getEventListeners(signal, 'abort').length, 0);
55+
}));
56+
}
57+
58+
{
59+
// Verify no error happens when abort is called timeout
60+
const controller = new AbortController();
61+
const { signal } = controller;
62+
63+
const cp = fork(fixtures.path('child-process-stay-alive-forever.js'), {
64+
timeout: 100,
65+
signal,
66+
});
67+
cp.on('error', mustCall());
68+
setTimeout(() => controller.abort(), 1);
69+
}

test/parallel/test-child-process-spawn-controller.js

+46
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,49 @@ const cp = require('child_process');
3939
assert.strictEqual(e.name, 'AbortError');
4040
}));
4141
}
42+
43+
{
44+
// Verify that passing kill signal works.
45+
const controller = new AbortController();
46+
const { signal } = controller;
47+
48+
const echo = cp.spawn('echo', ['fun'], {
49+
encoding: 'utf8',
50+
shell: true,
51+
signal,
52+
killSignal: 'SIGKILL',
53+
});
54+
55+
echo.on('close', common.mustCall((code, killSignal) => {
56+
assert.strictEqual(killSignal, 'SIGKILL');
57+
}));
58+
59+
echo.on('error', common.mustCall((e) => {
60+
assert.strictEqual(e.name, 'AbortError');
61+
}));
62+
63+
controller.abort();
64+
}
65+
66+
{
67+
// Verify that passing a different kill signal works.
68+
const controller = new AbortController();
69+
const { signal } = controller;
70+
71+
const echo = cp.spawn('echo', ['fun'], {
72+
encoding: 'utf8',
73+
shell: true,
74+
signal,
75+
killSignal: 'SIGTERM',
76+
});
77+
78+
echo.on('close', common.mustCall((code, killSignal) => {
79+
assert.strictEqual(killSignal, 'SIGTERM');
80+
}));
81+
82+
echo.on('error', common.mustCall((e) => {
83+
assert.strictEqual(e.name, 'AbortError');
84+
}));
85+
86+
controller.abort();
87+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
'use strict';
2+
3+
const { mustCall } = require('../common');
4+
const { strictEqual, ok, 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 after at least 4 ms.
12+
let closed = false;
13+
setTimeout(() => ok(!closed), 4);
14+
const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
15+
timeout: 5,
16+
});
17+
cp.on('close', mustCall(() => closed = true));
18+
cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGTERM')));
19+
}
20+
21+
{
22+
// Verify correct signal + closes after at least 4 ms.
23+
let closed = false;
24+
setTimeout(() => ok(!closed), 4);
25+
const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
26+
timeout: 5,
27+
killSignal: 'SIGKILL',
28+
});
29+
cp.on('close', mustCall(() => closed = true));
30+
cp.on('exit', mustCall((code, ks) => strictEqual(ks, 'SIGKILL')));
31+
}
32+
33+
{
34+
// Verify timeout verification
35+
throws(() => spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
36+
timeout: 'badValue',
37+
}), /ERR_OUT_OF_RANGE/);
38+
39+
throws(() => spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
40+
timeout: {},
41+
}), /ERR_OUT_OF_RANGE/);
42+
}
43+
44+
{
45+
// Verify abort signal gets unregistered
46+
const signal = new EventTarget();
47+
signal.aborted = false;
48+
49+
const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
50+
timeout: 6,
51+
signal,
52+
});
53+
strictEqual(getEventListeners(signal, 'abort').length, 1);
54+
cp.on('close', mustCall(() => {
55+
strictEqual(getEventListeners(signal, 'abort').length, 0);
56+
}));
57+
}
58+
59+
{
60+
// Verify no error happens when abort is called timeout
61+
const controller = new AbortController();
62+
const { signal } = controller;
63+
64+
const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
65+
timeout: 100,
66+
signal,
67+
});
68+
cp.on('error', mustCall());
69+
setTimeout(() => controller.abort(), 1);
70+
}

0 commit comments

Comments
 (0)