Skip to content

Commit 3989ee0

Browse files
child_process: add subprocess.timedOut
When the timeout option of spawn(), exec(), execFile() or fork() expires, the child is sent the kill signal and exits with that signal. Nothing told the caller that the timeout was the reason. Set subprocess.timedOut to true when the kill signal is sent because of the timeout, and add the same flag to the error passed to the exec() and execFile() callback. Fixes: #51561 Refs: #51608 Signed-off-by: marcopiraccini <marco.piraccini@gmail.com>
1 parent ae5a0f4 commit 3989ee0

7 files changed

Lines changed: 108 additions & 8 deletions

‎doc/api/child_process.md‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,8 @@ If a `callback` function is provided, it is called with the arguments
233233
`error` will be an instance of [`Error`][]. The `error.code` property will be
234234
the exit code of the process. By convention, any exit code other than `0`
235235
indicates an error. `error.signal` will be the signal that terminated the
236-
process.
236+
process. `error.timedOut` will be `true` if the process was killed because
237+
the `timeout` option expired.
237238

238239
The `stdout` and `stderr` arguments passed to the callback will contain the
239240
stdout and stderr output of the child process. By default, Node.js will decode
@@ -2314,6 +2315,32 @@ subprocess.stdout.on('data', (data) => {
23142315
The `subprocess.stdout` property can be `null` or `undefined`
23152316
if the child process could not be successfully spawned.
23162317
2318+
### `subprocess.timedOut`
2319+
2320+
<!-- YAML
2321+
added: REPLACEME
2322+
-->
2323+
2324+
* Type: {boolean} Set to `true` when the child process is killed because the
2325+
`timeout` option expired.
2326+
2327+
The `subprocess.timedOut` property indicates whether the `timeout` option of
2328+
[`child_process.spawn()`][], [`child_process.exec()`][],
2329+
[`child_process.execFile()`][] or [`child_process.fork()`][] expired and the
2330+
child process was sent the `killSignal` as a result. Like
2331+
[`subprocess.killed`][], it does not indicate that the child process has
2332+
terminated yet.
2333+
2334+
```js
2335+
const { spawn } = require('node:child_process');
2336+
2337+
const subprocess = spawn('sleep', ['10'], { timeout: 100 });
2338+
2339+
subprocess.on('exit', (code, signal) => {
2340+
console.log(subprocess.timedOut); // true
2341+
});
2342+
```
2343+
23172344
### `subprocess.unref()`
23182345
23192346
<!-- YAML
@@ -2429,6 +2456,7 @@ or [`child_process.fork()`][].
24292456
[`subprocess.disconnect()`]: #subprocessdisconnect
24302457
[`subprocess.exitCode`]: #subprocessexitcode
24312458
[`subprocess.kill()`]: #subprocesskillsignal
2459+
[`subprocess.killed`]: #subprocesskilled
24322460
[`subprocess.send()`]: #subprocesssendmessage-sendhandle-options-callback
24332461
[`subprocess.signalCode`]: #subprocesssignalcode
24342462
[`subprocess.stderr`]: #subprocessstderr

‎lib/child_process.js‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@ function execFile(file, args, options, callback) {
426426
});
427427

428428
ex.cmd = cmd;
429+
ex.timedOut = child.timedOut;
429430
callback(ex, stdout, stderr);
430431
}
431432

@@ -450,16 +451,19 @@ function execFile(file, args, options, callback) {
450451

451452
killed = true;
452453
try {
453-
child.kill(options.killSignal);
454+
return child.kill(options.killSignal);
454455
} catch (e) {
455456
ex = e;
456457
exithandler();
457458
}
459+
return false;
458460
}
459461

460462
if (options.timeout > 0) {
461463
timeoutId = setTimeout(function delayedKill() {
462-
kill();
464+
// The child may have exited already, with a descendant keeping its
465+
// stdio open: kill() reports whether the signal was actually sent.
466+
child.timedOut = kill();
463467
timeoutId = null;
464468
}, options.timeout);
465469
}
@@ -895,7 +899,9 @@ function spawn(file, args, options) {
895899
let timeoutId = setTimeout(() => {
896900
if (timeoutId) {
897901
try {
898-
child.kill(killSignal);
902+
// The child may have exited already, with a descendant keeping its
903+
// stdio open: kill() reports whether the signal was actually sent.
904+
child.timedOut = child.kill(killSignal);
899905
} catch (err) {
900906
child.emit('error', err);
901907
}

‎lib/internal/child_process.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ function ChildProcess() {
291291
this.signalCode = null;
292292
this.exitCode = null;
293293
this.killed = false;
294+
this.timedOut = false;
294295
this.spawnfile = null;
295296

296297
this._handle = new Process();

‎test/parallel/test-child-process-exec-timeout-expire.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ cp.exec(cmd, {
2929

3030
let sigterm = 'SIGTERM';
3131
assert.strictEqual(err.killed, true);
32+
assert.strictEqual(err.timedOut, true);
3233
// TODO OpenBSD returns a null signal and 143 for code
3334
if (common.isOpenBSD) {
3435
assert.strictEqual(err.code, 143);

‎test/parallel/test-child-process-exec-timeout-kill.js‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ if (process.argv[2] === 'child') {
2121
const [cmd, opts] = common.escapePOSIXShell`"${process.execPath}" "${__filename}" child`;
2222

2323
// Test with a different kill signal.
24-
cp.exec(cmd, {
24+
const child = cp.exec(cmd, {
2525
...opts,
2626
timeout: kExpiringParentTimer,
2727
killSignal: 'SIGKILL'
@@ -30,6 +30,8 @@ cp.exec(cmd, {
3030
console.log('[stderr]', stderr.trim());
3131

3232
assert.strictEqual(err.killed, true);
33+
assert.strictEqual(err.timedOut, true);
34+
assert.strictEqual(child.timedOut, true);
3335
assert.strictEqual(err.code, null);
3436
assert.strictEqual(err.signal, 'SIGKILL');
3537
assert.strictEqual(err.cmd, cmd);

‎test/parallel/test-child-process-exec-timeout-not-expired.js‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,35 @@ if (process.argv[2] === 'child') {
2222
return;
2323
}
2424

25+
// The property is also present on errors that are not caused by the timeout.
26+
cp.execFile('this-command-does-not-exist', { timeout: 2 ** 30 },
27+
common.mustCall((err) => {
28+
assert.strictEqual(err.code, 'ENOENT');
29+
assert.strictEqual(err.timedOut, false);
30+
}));
31+
32+
// The timeout expires after the child exited, while a descendant still keeps
33+
// its stdio open: the signal is not sent, so timedOut stays false.
34+
{
35+
const child = cp.execFile(process.execPath, ['-e', `
36+
const { spawn } = require('child_process');
37+
spawn(process.execPath, ['-e', 'setTimeout(() => {}, ${common.platformTimeout(1500)})'],
38+
{ stdio: 'inherit' }).unref();
39+
`], { timeout: common.platformTimeout(500) }, common.mustSucceed(() => {
40+
assert.strictEqual(child.exitCode, 0);
41+
assert.strictEqual(child.timedOut, false);
42+
}));
43+
}
44+
2545
const [cmd, opts] = common.escapePOSIXShell`"${process.execPath}" "${__filename}" child`;
2646

27-
cp.exec(cmd, {
47+
const child = cp.exec(cmd, {
2848
...opts,
2949
timeout: kTimeoutNotSupposedToExpire,
3050
}, common.mustSucceed((stdout, stderr) => {
3151
assert.strictEqual(stdout.trim(), 'child stdout');
3252
assert.strictEqual(stderr.trim(), 'child stderr');
53+
assert.strictEqual(child.timedOut, false);
3354
}));
3455

3556
cleanupStaleProcess(__filename);

‎test/parallel/test-child-process-spawn-timeout-kill-signal.js‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

3-
const { mustCall } = require('../common');
3+
const common = require('../common');
4+
const { mustCall } = common;
45
const assert = require('assert');
56
const fixtures = require('../common/fixtures');
67
const { spawn } = require('child_process');
@@ -12,7 +13,47 @@ const aliveForeverFile = 'child-process-stay-alive-forever.js';
1213
const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
1314
timeout: 5,
1415
});
15-
cp.on('exit', mustCall((code, ks) => assert.strictEqual(ks, 'SIGTERM')));
16+
assert.strictEqual(cp.timedOut, false);
17+
cp.on('exit', mustCall((code, ks) => {
18+
assert.strictEqual(ks, 'SIGTERM');
19+
assert.strictEqual(cp.timedOut, true);
20+
}));
21+
}
22+
23+
{
24+
// Verify timedOut stays false when the child exits before the timeout
25+
const cp = spawn(process.execPath, ['-e', ''], { timeout: 2 ** 30 });
26+
cp.on('exit', mustCall((code) => {
27+
assert.strictEqual(code, 0);
28+
assert.strictEqual(cp.timedOut, false);
29+
}));
30+
}
31+
32+
{
33+
// Verify timedOut stays false when the child exits in time but a descendant
34+
// keeps its stdio open past the timeout, so that the timer fires after exit.
35+
const cp = spawn(process.execPath, ['-e', `
36+
const { spawn } = require('child_process');
37+
spawn(process.execPath, ['-e', 'setTimeout(() => {}, ${common.platformTimeout(1500)})'],
38+
{ stdio: 'inherit' }).unref();
39+
`], { timeout: common.platformTimeout(500) });
40+
cp.on('exit', mustCall((code) => {
41+
assert.strictEqual(code, 0);
42+
assert.strictEqual(cp.timedOut, false);
43+
}));
44+
cp.on('close', mustCall(() => assert.strictEqual(cp.timedOut, false)));
45+
}
46+
47+
{
48+
// Verify timedOut stays false when the child is killed by the caller
49+
const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
50+
timeout: 2 ** 30,
51+
});
52+
cp.on('spawn', mustCall(() => cp.kill()));
53+
cp.on('exit', mustCall(() => {
54+
assert.strictEqual(cp.killed, true);
55+
assert.strictEqual(cp.timedOut, false);
56+
}));
1657
}
1758

1859
{

0 commit comments

Comments
 (0)