Commit c2591daac54 for nodejs

commit c2591daac54c0a7744751031caa4eca04c4233d3
Author: Marco <marco.piraccini@gmail.com>
Date:   Thu Oct 29 09:56:09 2026 +0100

    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: https://github.com/nodejs/node/issues/51561
    Refs: https://github.com/nodejs/node/pull/51608
    Signed-off-by: marcopiraccini <marco.piraccini@gmail.com>
    PR-URL: https://github.com/nodejs/node/pull/66639
    Reviewed-By: Paolo Insogna <paolo@cowtech.it>

diff --git a/doc/api/child_process.md b/doc/api/child_process.md
index 28729ef788a..6ac072beedb 100644
--- a/doc/api/child_process.md
+++ b/doc/api/child_process.md
@@ -233,7 +233,8 @@ If a `callback` function is provided, it is called with the arguments
 `error` will be an instance of [`Error`][]. The `error.code` property will be
 the exit code of the process. By convention, any exit code other than `0`
 indicates an error. `error.signal` will be the signal that terminated the
-process.
+process. `error.timedOut` will be `true` if the process was killed because
+the `timeout` option expired.

 The `stdout` and `stderr` arguments passed to the callback will contain the
 stdout and stderr output of the child process. By default, Node.js will decode
@@ -2316,6 +2317,32 @@ subprocess.stdout.on('data', (data) => {
 The `subprocess.stdout` property can be `null` or `undefined`
 if the child process could not be successfully spawned.

+### `subprocess.timedOut`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* Type: {boolean} Set to `true` when the child process is killed because the
+  `timeout` option expired.
+
+The `subprocess.timedOut` property indicates whether the `timeout` option of
+[`child_process.spawn()`][], [`child_process.exec()`][],
+[`child_process.execFile()`][] or [`child_process.fork()`][] expired and the
+child process was sent the `killSignal` as a result. Like
+[`subprocess.killed`][], it does not indicate that the child process has
+terminated yet.
+
+```js
+const { spawn } = require('node:child_process');
+
+const subprocess = spawn('sleep', ['10'], { timeout: 100 });
+
+subprocess.on('exit', (code, signal) => {
+  console.log(subprocess.timedOut); // true
+});
+```
+
 ### `subprocess.unref()`

 <!-- YAML
@@ -2431,6 +2458,7 @@ or [`child_process.fork()`][].
 [`subprocess.disconnect()`]: #subprocessdisconnect
 [`subprocess.exitCode`]: #subprocessexitcode
 [`subprocess.kill()`]: #subprocesskillsignal
+[`subprocess.killed`]: #subprocesskilled
 [`subprocess.send()`]: #subprocesssendmessage-sendhandle-options-callback
 [`subprocess.signalCode`]: #subprocesssignalcode
 [`subprocess.stderr`]: #subprocessstderr
diff --git a/lib/child_process.js b/lib/child_process.js
index 6b411f43f07..03cc2b47f34 100644
--- a/lib/child_process.js
+++ b/lib/child_process.js
@@ -426,6 +426,7 @@ function execFile(file, args, options, callback) {
     });

     ex.cmd = cmd;
+    ex.timedOut = child.timedOut;
     callback(ex, stdout, stderr);
   }

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

     killed = true;
     try {
-      child.kill(options.killSignal);
+      return child.kill(options.killSignal);
     } catch (e) {
       ex = e;
       exithandler();
     }
+    return false;
   }

   if (options.timeout > 0) {
     timeoutId = setTimeout(function delayedKill() {
-      kill();
+      // The child may have exited already, with a descendant keeping its
+      // stdio open: kill() reports whether the signal was actually sent.
+      child.timedOut = kill();
       timeoutId = null;
     }, options.timeout);
   }
@@ -895,7 +899,9 @@ function spawn(file, args, options) {
     let timeoutId = setTimeout(() => {
       if (timeoutId) {
         try {
-          child.kill(killSignal);
+          // The child may have exited already, with a descendant keeping its
+          // stdio open: kill() reports whether the signal was actually sent.
+          child.timedOut = child.kill(killSignal);
         } catch (err) {
           child.emit('error', err);
         }
diff --git a/lib/internal/child_process.js b/lib/internal/child_process.js
index 363825061dd..56a772de371 100644
--- a/lib/internal/child_process.js
+++ b/lib/internal/child_process.js
@@ -291,6 +291,7 @@ function ChildProcess() {
   this.signalCode = null;
   this.exitCode = null;
   this.killed = false;
+  this.timedOut = false;
   this.spawnfile = null;

   this._handle = new Process();
diff --git a/test/parallel/test-child-process-exec-timeout-expire.js b/test/parallel/test-child-process-exec-timeout-expire.js
index 0fd4f85cbc9..e7c3b17c3ec 100644
--- a/test/parallel/test-child-process-exec-timeout-expire.js
+++ b/test/parallel/test-child-process-exec-timeout-expire.js
@@ -29,6 +29,7 @@ cp.exec(cmd, {

   let sigterm = 'SIGTERM';
   assert.strictEqual(err.killed, true);
+  assert.strictEqual(err.timedOut, true);
   // TODO OpenBSD returns a null signal and 143 for code
   if (common.isOpenBSD) {
     assert.strictEqual(err.code, 143);
diff --git a/test/parallel/test-child-process-exec-timeout-kill.js b/test/parallel/test-child-process-exec-timeout-kill.js
index 4938cea8a22..c5c651749ed 100644
--- a/test/parallel/test-child-process-exec-timeout-kill.js
+++ b/test/parallel/test-child-process-exec-timeout-kill.js
@@ -21,7 +21,7 @@ if (process.argv[2] === 'child') {
 const [cmd, opts] = common.escapePOSIXShell`"${process.execPath}" "${__filename}" child`;

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

   assert.strictEqual(err.killed, true);
+  assert.strictEqual(err.timedOut, true);
+  assert.strictEqual(child.timedOut, true);
   assert.strictEqual(err.code, null);
   assert.strictEqual(err.signal, 'SIGKILL');
   assert.strictEqual(err.cmd, cmd);
diff --git a/test/parallel/test-child-process-exec-timeout-not-expired.js b/test/parallel/test-child-process-exec-timeout-not-expired.js
index 0d0c0f47f9d..184a0c89a53 100644
--- a/test/parallel/test-child-process-exec-timeout-not-expired.js
+++ b/test/parallel/test-child-process-exec-timeout-not-expired.js
@@ -22,14 +22,52 @@ if (process.argv[2] === 'child') {
   return;
 }

+// The property is also present on errors that are not caused by the timeout.
+cp.execFile('this-command-does-not-exist', { timeout: 2 ** 30 },
+            common.mustCall((err) => {
+              assert.strictEqual(err.code, 'ENOENT');
+              assert.strictEqual(err.timedOut, false);
+            }));
+
+// The timeout expires after the child exited, while a descendant still keeps
+// its stdio open: the signal is not sent, so timedOut stays false. The child
+// prints the pid of the descendant and the test ends it once the timer has
+// fired, so the test does not depend on how fast the child starts.
+{
+  const timeout = common.platformTimeout(5000);
+  const child = cp.execFile(process.execPath, ['-e', `
+    const { spawn } = require('child_process');
+    // Detached: on Windows a non-detached descendant is killed with the job
+    // object of the child when the child exits.
+    const child = spawn(process.execPath,
+                        ['-e', 'setTimeout(() => {}, ${common.platformTimeout(30000)})'],
+                        { stdio: 'inherit', detached: true });
+    child.unref();
+    process.stdout.write(String(child.pid));
+  `], { timeout }, common.mustSucceed(() => {
+    assert.strictEqual(child.exitCode, 0);
+    assert.strictEqual(child.timedOut, false);
+  }));
+  const pid = new Promise((resolve) => {
+    child.stdout.once('data', (chunk) => resolve(Number(String(chunk))));
+  });
+  child.on('exit', common.mustCall((code) => {
+    assert.strictEqual(code, 0);
+    // The timeout of the child started before this timer, so it fires first.
+    // The descendant is ended after that, which closes the stdio of the child.
+    setTimeout(async () => process.kill(await pid), timeout);
+  }));
+}
+
 const [cmd, opts] = common.escapePOSIXShell`"${process.execPath}" "${__filename}" child`;

-cp.exec(cmd, {
+const child = cp.exec(cmd, {
   ...opts,
   timeout: kTimeoutNotSupposedToExpire,
 }, common.mustSucceed((stdout, stderr) => {
   assert.strictEqual(stdout.trim(), 'child stdout');
   assert.strictEqual(stderr.trim(), 'child stderr');
+  assert.strictEqual(child.timedOut, false);
 }));

 cleanupStaleProcess(__filename);
diff --git a/test/parallel/test-child-process-spawn-timeout-kill-signal.js b/test/parallel/test-child-process-spawn-timeout-kill-signal.js
index 27df4e67857..4469b3b2eae 100644
--- a/test/parallel/test-child-process-spawn-timeout-kill-signal.js
+++ b/test/parallel/test-child-process-spawn-timeout-kill-signal.js
@@ -1,6 +1,7 @@
 'use strict';

-const { mustCall } = require('../common');
+const common = require('../common');
+const { mustCall } = common;
 const assert = require('assert');
 const fixtures = require('../common/fixtures');
 const { spawn } = require('child_process');
@@ -12,7 +13,61 @@ const aliveForeverFile = 'child-process-stay-alive-forever.js';
   const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
     timeout: 5,
   });
-  cp.on('exit', mustCall((code, ks) => assert.strictEqual(ks, 'SIGTERM')));
+  assert.strictEqual(cp.timedOut, false);
+  cp.on('exit', mustCall((code, ks) => {
+    assert.strictEqual(ks, 'SIGTERM');
+    assert.strictEqual(cp.timedOut, true);
+  }));
+}
+
+{
+  // Verify timedOut stays false when the child exits before the timeout
+  const cp = spawn(process.execPath, ['-e', ''], { timeout: 2 ** 30 });
+  cp.on('exit', mustCall((code) => {
+    assert.strictEqual(code, 0);
+    assert.strictEqual(cp.timedOut, false);
+  }));
+}
+
+{
+  // Verify timedOut stays false when the child exits in time but a descendant
+  // keeps its stdio open past the timeout, so that the timer fires after exit.
+  // The child prints the pid of the descendant and the test ends it once the
+  // timer has fired, so the test does not depend on how fast the child starts.
+  const timeout = common.platformTimeout(5000);
+  const cp = spawn(process.execPath, ['-e', `
+    const { spawn } = require('child_process');
+    // Detached: on Windows a non-detached descendant is killed with the job
+    // object of the child when the child exits.
+    const child = spawn(process.execPath,
+                        ['-e', 'setTimeout(() => {}, ${common.platformTimeout(30000)})'],
+                        { stdio: 'inherit', detached: true });
+    child.unref();
+    process.stdout.write(String(child.pid));
+  `], { timeout });
+  const pid = new Promise((resolve) => {
+    cp.stdout.once('data', (chunk) => resolve(Number(String(chunk))));
+  });
+  cp.on('exit', mustCall((code) => {
+    assert.strictEqual(code, 0);
+    assert.strictEqual(cp.timedOut, false);
+    // The timeout of the child started before this timer, so it fires first.
+    // The descendant is ended after that, which closes the stdio of the child.
+    setTimeout(async () => process.kill(await pid), timeout);
+  }));
+  cp.on('close', mustCall(() => assert.strictEqual(cp.timedOut, false)));
+}
+
+{
+  // Verify timedOut stays false when the child is killed by the caller
+  const cp = spawn(process.execPath, [fixtures.path(aliveForeverFile)], {
+    timeout: 2 ** 30,
+  });
+  cp.on('spawn', mustCall(() => cp.kill()));
+  cp.on('exit', mustCall(() => {
+    assert.strictEqual(cp.killed, true);
+    assert.strictEqual(cp.timedOut, false);
+  }));
 }

 {