From d8fb5c2f1c907f09e2763046c436a236b70cdea5 Mon Sep 17 00:00:00 2001
From: Nitzan Uziely <nitzan@testim.io>
Date: Sun, 7 Feb 2021 01:39:54 +0200
Subject: [PATCH] child_process: add timeout to spawn and fork

Add support for timeout to spawn and fork.

Fixes: https://github.com/nodejs/node/issues/27639

PR-URL: https://github.com/nodejs/node/pull/37256
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
---
 doc/api/child_process.md                      | 18 +++++--
 lib/child_process.js                          | 29 +++++++++--
 ...-child-process-fork-timeout-kill-signal.js | 50 +++++++++++++++++++
 ...child-process-spawn-timeout-kill-signal.js | 50 +++++++++++++++++++
 4 files changed, 138 insertions(+), 9 deletions(-)
 create mode 100644 test/parallel/test-child-process-fork-timeout-kill-signal.js
 create mode 100644 test/parallel/test-child-process-spawn-timeout-kill-signal.js

diff --git a/doc/api/child_process.md b/doc/api/child_process.md
index 677f3a5c190554..77467cc6573064 100644
--- a/doc/api/child_process.md
+++ b/doc/api/child_process.md
@@ -374,6 +374,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.
@@ -410,8 +413,8 @@ changes:
     See [Advanced serialization][] for more details. **Default:** `'json'`.
   * `signal` {AbortSignal} Allows closing the child process using an
     AbortSignal.
-  * `killSignal` {string} The signal value to be used when the spawned
-    process will be killed by the abort signal. **Default:** `'SIGTERM'`.
+  * `killSignal` {string|integer} The signal value to be used when the spawned
+    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
@@ -423,6 +426,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
@@ -478,6 +483,9 @@ if (process.argv[2] === 'child') {
 <!-- 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.
@@ -528,8 +536,10 @@ changes:
     normally be created on Windows systems. **Default:** `false`.
   * `signal` {AbortSignal} allows aborting the child process 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}
 
diff --git a/lib/child_process.js b/lib/child_process.js
index 57b5eaa71b1bb5..18996f8a5643ce 100644
--- a/lib/child_process.js
+++ b/lib/child_process.js
@@ -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 {
@@ -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;
 }
 
diff --git a/test/parallel/test-child-process-fork-timeout-kill-signal.js b/test/parallel/test-child-process-fork-timeout-kill-signal.js
new file mode 100644
index 00000000000000..ef08d4b12ab94a
--- /dev/null
+++ b/test/parallel/test-child-process-fork-timeout-kill-signal.js
@@ -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);
+  }));
+}
diff --git a/test/parallel/test-child-process-spawn-timeout-kill-signal.js b/test/parallel/test-child-process-spawn-timeout-kill-signal.js
new file mode 100644
index 00000000000000..59a974d0e0b448
--- /dev/null
+++ b/test/parallel/test-child-process-spawn-timeout-kill-signal.js
@@ -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);
+  }));
+}