From 94ab73cae10df9cc27f2c6e6b67b9967c2f390ab Mon Sep 17 00:00:00 2001 From: Felipe Coelho Date: Thu, 19 Mar 2026 11:04:13 -0300 Subject: [PATCH 1/2] feat(child_process): add promises API via delegation --- doc/api/child_process.md | 103 +++++++++++ lib/child_process/promises.js | 9 + test/parallel/test-child-process-promises.js | 174 +++++++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 lib/child_process/promises.js create mode 100644 test/parallel/test-child-process-promises.js diff --git a/doc/api/child_process.md b/doc/api/child_process.md index 6818eec4594b25..cc9e598252f607 100644 --- a/doc/api/child_process.md +++ b/doc/api/child_process.md @@ -95,6 +95,109 @@ For certain use cases, such as automating shell scripts, the the synchronous methods can have significant impact on performance due to stalling the event loop while spawned processes complete. +The `node:child_process/promises` API provides promise-based versions of +`exec()` and `execFile()`: + +```mjs +import { execFile } from 'node:child_process/promises'; +const { stdout } = await execFile('node', ['--version']); +console.log(stdout); +``` + +```cjs +const { execFile } = require('node:child_process/promises'); +(async () => { + const { stdout } = await execFile('node', ['--version']); + console.log(stdout); +})(); +``` + +## Promises API + + + +The `child_process/promises` API provides promise-returning versions of +`child_process.exec()` and `child_process.execFile()`. The API is accessible +via `require('node:child_process/promises')` or +`import from 'node:child_process/promises'`. + +### `exec(command[, options])` + + + +* `command` {string} The command to run, with space-separated arguments. +* `options` {Object} + * `cwd` {string|URL} Current working directory of the child process. + **Default:** `process.cwd()`. + * `env` {Object} Environment key-value pairs. **Default:** `process.env`. + * `encoding` {string} **Default:** `'utf8'` + * `shell` {string} Shell to run the command with. + **Default:** `'/bin/sh'` on Unix, `process.env.ComSpec` on Windows. + * `signal` {AbortSignal} Allows aborting the child process using an + AbortSignal. + * `timeout` {number} **Default:** `0` + * `maxBuffer` {number} Largest amount of data in bytes allowed on stdout or + stderr. If exceeded, the child process is terminated and any output is + truncated. **Default:** `1024 * 1024`. + * `killSignal` {string|integer} **Default:** `'SIGTERM'` + * `uid` {number} Sets the user identity of the process (see setuid(2)). + * `gid` {number} Sets the group identity of the process (see setgid(2)). + * `windowsHide` {boolean} Hide the subprocess console window that would + normally be created on Windows systems. **Default:** `false`. +* Returns: {Promise} Fulfills with an {Object} containing: + * `stdout` {string|Buffer} + * `stderr` {string|Buffer} + +The returned promise has a `child` property with a reference to the +[`ChildProcess`][] instance. + +If the child process exits with a non-zero code or encounters an error, the +promise is rejected with an error containing `stdout`, `stderr`, `code`, +`signal`, `cmd`, and `killed` properties. + +### `execFile(file[, args][, options])` + + + +* `file` {string} The name or path of the executable file to run. +* `args` {string\[]} List of string arguments. +* `options` {Object} + * `cwd` {string|URL} Current working directory of the child process. + * `env` {Object} Environment key-value pairs. **Default:** `process.env`. + * `encoding` {string} **Default:** `'utf8'` + * `timeout` {number} **Default:** `0` + * `maxBuffer` {number} Largest amount of data in bytes allowed on stdout or + stderr. If exceeded, the child process is terminated and any output is + truncated. **Default:** `1024 * 1024`. + * `killSignal` {string|integer} **Default:** `'SIGTERM'` + * `uid` {number} Sets the user identity of the process (see setuid(2)). + * `gid` {number} Sets the group identity of the process (see setgid(2)). + * `windowsHide` {boolean} Hide the subprocess console window that would + normally be created on Windows systems. **Default:** `false`. + * `windowsVerbatimArguments` {boolean} No quoting or escaping of arguments is + done on Windows. Ignored on Unix. **Default:** `false`. + * `shell` {boolean|string} If `true`, runs `file` inside of a shell. Uses + `'/bin/sh'` on Unix, and `process.env.ComSpec` on Windows. A different + shell can be specified as a string. **Default:** `false` (no shell). + * `signal` {AbortSignal} Allows aborting the child process using an + AbortSignal. +* Returns: {Promise} Fulfills with an {Object} containing: + * `stdout` {string|Buffer} + * `stderr` {string|Buffer} + +The returned promise has a `child` property with a reference to the +[`ChildProcess`][] instance. + +If the child process exits with a non-zero code or encounters an error, the +promise is rejected with an error containing `stdout`, `stderr`, `code`, +`signal`, `cmd`, and `killed` properties. + ## Asynchronous process creation The [`child_process.spawn()`][], [`child_process.fork()`][], [`child_process.exec()`][], diff --git a/lib/child_process/promises.js b/lib/child_process/promises.js new file mode 100644 index 00000000000000..d60640f9dc9536 --- /dev/null +++ b/lib/child_process/promises.js @@ -0,0 +1,9 @@ +'use strict'; + +const { promisify } = require('internal/util'); +const { exec, execFile } = require('child_process'); + +module.exports = { + exec: promisify(exec), + execFile: promisify(execFile), +}; diff --git a/test/parallel/test-child-process-promises.js b/test/parallel/test-child-process-promises.js new file mode 100644 index 00000000000000..2b7de219afa1ac --- /dev/null +++ b/test/parallel/test-child-process-promises.js @@ -0,0 +1,174 @@ +// Flags: --no-warnings +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const child_process = require('child_process'); + +const { exec, execFile } = require('child_process/promises'); + +// Delegated functions are the same as util.promisify(child_process.exec/execFile). +{ + const { promisify } = require('util'); + assert.strictEqual(exec, promisify(child_process.exec)); + assert.strictEqual(execFile, promisify(child_process.execFile)); +} + +// exec resolves with { stdout, stderr }. +{ + const promise = exec(...common.escapePOSIXShell`"${process.execPath}" -p 42`); + + assert(promise.child instanceof child_process.ChildProcess); + promise.then(common.mustCall((result) => { + assert.deepStrictEqual(result, { stdout: '42\n', stderr: '' }); + })); +} + +// execFile resolves with { stdout, stderr }. +{ + const promise = execFile(process.execPath, ['-p', '42']); + + assert(promise.child instanceof child_process.ChildProcess); + promise.then(common.mustCall((result) => { + assert.deepStrictEqual(result, { stdout: '42\n', stderr: '' }); + })); +} + +// exec rejects when command does not exist. +{ + const promise = exec('doesntexist'); + + assert(promise.child instanceof child_process.ChildProcess); + promise.catch(common.mustCall((err) => { + assert(err.message.includes('doesntexist')); + })); +} + +// execFile rejects when file does not exist. +{ + const promise = execFile('doesntexist', ['-p', '42']); + + assert(promise.child instanceof child_process.ChildProcess); + promise.catch(common.mustCall((err) => { + assert(err.message.includes('doesntexist')); + })); +} + +// Rejected errors include stdout, stderr, and code properties. +const failingCodeWithStdoutErr = + 'console.log(42);console.error(43);process.exit(1)'; + +{ + exec(...common.escapePOSIXShell`"${process.execPath}" -e "${failingCodeWithStdoutErr}"`) + .catch(common.mustCall((err) => { + assert.strictEqual(err.code, 1); + assert.strictEqual(err.stdout, '42\n'); + assert.strictEqual(err.stderr, '43\n'); + })); +} + +{ + execFile(process.execPath, ['-e', failingCodeWithStdoutErr]) + .catch(common.mustCall((err) => { + assert.strictEqual(err.code, 1); + assert.strictEqual(err.stdout, '42\n'); + assert.strictEqual(err.stderr, '43\n'); + })); +} + +// execFile with options but no args array. +{ + execFile(process.execPath, { timeout: 5000 }) + .catch(common.mustCall(() => { + // Expected to fail (no script), but should not throw synchronously. + })); +} + +// encoding option returns strings. +{ + execFile(process.execPath, ['-p', '"hello"'], { encoding: 'utf8' }) + .then(common.mustCall((result) => { + assert.strictEqual(typeof result.stdout, 'string'); + assert.strictEqual(typeof result.stderr, 'string'); + assert.strictEqual(result.stdout, 'hello\n'); + })); +} + +// encoding 'buffer' returns Buffer instances. +{ + execFile(process.execPath, ['-p', '"hello"'], { encoding: 'buffer' }) + .then(common.mustCall((result) => { + assert(Buffer.isBuffer(result.stdout)); + assert(Buffer.isBuffer(result.stderr)); + assert.strictEqual(result.stdout.toString(), 'hello\n'); + })); +} + +// AbortSignal cancels exec. +{ + const waitCommand = common.isWindows ? + `"${process.execPath}" -e "setInterval(()=>{}, 99)"` : + 'sleep 2m'; + const ac = new AbortController(); + const promise = exec(waitCommand, { signal: ac.signal }); + + assert.rejects(promise, { + name: 'AbortError', + }).then(common.mustCall()); + ac.abort(); +} + +// AbortSignal cancels execFile. +{ + const ac = new AbortController(); + const promise = execFile( + process.execPath, + ['-e', 'setInterval(()=>{}, 99)'], + { signal: ac.signal }, + ); + + assert.rejects(promise, { + name: 'AbortError', + }).then(common.mustCall()); + ac.abort(); +} + +// Already-aborted signal rejects immediately. +{ + const signal = AbortSignal.abort(); + const promise = exec('echo hello', { signal }); + + assert.rejects(promise, { name: 'AbortError' }) + .then(common.mustCall()); +} + +// maxBuffer causes rejection. +{ + execFile( + process.execPath, + ['-e', "console.log('a'.repeat(100))"], + { maxBuffer: 10 }, + ).catch(common.mustCall((err) => { + assert(err instanceof RangeError); + assert.strictEqual(err.code, 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'); + })); +} + +// timeout causes the child process to be killed. +{ + const promise = execFile( + process.execPath, + ['-e', 'setInterval(()=>{}, 99)'], + { timeout: 1 }, + ); + + promise.catch(common.mustCall((err) => { + assert.ok(err.killed || err.signal); + })); +} + +// Module can be loaded with node: scheme. +{ + const promises = require('node:child_process/promises'); + assert.strictEqual(typeof promises.exec, 'function'); + assert.strictEqual(typeof promises.execFile, 'function'); +} From 6a758b93663f3c2daf701c8f60076c37b50dcfd5 Mon Sep 17 00:00:00 2001 From: Felipe Coelho Date: Thu, 19 Mar 2026 13:01:15 -0300 Subject: [PATCH 2/2] fix(child_process): apply review feedback from aduh95 --- doc/api/child_process.md | 2 +- test/parallel/test-child-process-promises.js | 53 +++++++++----------- 2 files changed, 25 insertions(+), 30 deletions(-) diff --git a/doc/api/child_process.md b/doc/api/child_process.md index cc9e598252f607..a03361a12a6649 100644 --- a/doc/api/child_process.md +++ b/doc/api/child_process.md @@ -118,7 +118,7 @@ const { execFile } = require('node:child_process/promises'); added: REPLACEME --> -The `child_process/promises` API provides promise-returning versions of +The `node:child_process/promises` API provides promise-returning versions of `child_process.exec()` and `child_process.execFile()`. The API is accessible via `require('node:child_process/promises')` or `import from 'node:child_process/promises'`. diff --git a/test/parallel/test-child-process-promises.js b/test/parallel/test-child-process-promises.js index 2b7de219afa1ac..da1936d54393da 100644 --- a/test/parallel/test-child-process-promises.js +++ b/test/parallel/test-child-process-promises.js @@ -17,7 +17,6 @@ const { exec, execFile } = require('child_process/promises'); { const promise = exec(...common.escapePOSIXShell`"${process.execPath}" -p 42`); - assert(promise.child instanceof child_process.ChildProcess); promise.then(common.mustCall((result) => { assert.deepStrictEqual(result, { stdout: '42\n', stderr: '' }); })); @@ -27,7 +26,6 @@ const { exec, execFile } = require('child_process/promises'); { const promise = execFile(process.execPath, ['-p', '42']); - assert(promise.child instanceof child_process.ChildProcess); promise.then(common.mustCall((result) => { assert.deepStrictEqual(result, { stdout: '42\n', stderr: '' }); })); @@ -37,7 +35,6 @@ const { exec, execFile } = require('child_process/promises'); { const promise = exec('doesntexist'); - assert(promise.child instanceof child_process.ChildProcess); promise.catch(common.mustCall((err) => { assert(err.message.includes('doesntexist')); })); @@ -47,7 +44,6 @@ const { exec, execFile } = require('child_process/promises'); { const promise = execFile('doesntexist', ['-p', '42']); - assert(promise.child instanceof child_process.ChildProcess); promise.catch(common.mustCall((err) => { assert(err.message.includes('doesntexist')); })); @@ -58,12 +54,12 @@ const failingCodeWithStdoutErr = 'console.log(42);console.error(43);process.exit(1)'; { - exec(...common.escapePOSIXShell`"${process.execPath}" -e "${failingCodeWithStdoutErr}"`) - .catch(common.mustCall((err) => { - assert.strictEqual(err.code, 1); - assert.strictEqual(err.stdout, '42\n'); - assert.strictEqual(err.stderr, '43\n'); - })); + assert.rejects(exec(...common.escapePOSIXShell`"${process.execPath}" -e "${failingCodeWithStdoutErr}"`), + { + code: 1, + stdout: '42\n', + stderr: '43\n', + }).then(common.mustCall()); } { @@ -75,21 +71,21 @@ const failingCodeWithStdoutErr = })); } -// execFile with options but no args array. +// execFile with timeout rejects with killed process. { - execFile(process.execPath, { timeout: 5000 }) - .catch(common.mustCall(() => { - // Expected to fail (no script), but should not throw synchronously. - })); + assert.rejects(execFile(process.execPath, ['-e', 'setInterval(()=>{}, 99)'], { timeout: 5 }), { + killed: true, + }).then(common.mustCall()); } // encoding option returns strings. { execFile(process.execPath, ['-p', '"hello"'], { encoding: 'utf8' }) .then(common.mustCall((result) => { - assert.strictEqual(typeof result.stdout, 'string'); - assert.strictEqual(typeof result.stderr, 'string'); - assert.strictEqual(result.stdout, 'hello\n'); + assert.deepStrictEqual(result, { + stdout: 'hello\n', + stderr: '', + }); })); } @@ -97,9 +93,10 @@ const failingCodeWithStdoutErr = { execFile(process.execPath, ['-p', '"hello"'], { encoding: 'buffer' }) .then(common.mustCall((result) => { - assert(Buffer.isBuffer(result.stdout)); - assert(Buffer.isBuffer(result.stderr)); - assert.strictEqual(result.stdout.toString(), 'hello\n'); + assert.deepStrictEqual(result, { + stderr: Buffer.from(''), + stdout: Buffer.from('hello\n'), + }); })); } @@ -143,14 +140,14 @@ const failingCodeWithStdoutErr = // maxBuffer causes rejection. { - execFile( + assert.rejects(execFile( process.execPath, ['-e', "console.log('a'.repeat(100))"], { maxBuffer: 10 }, - ).catch(common.mustCall((err) => { - assert(err instanceof RangeError); - assert.strictEqual(err.code, 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'); - })); + ), { + name: 'RangeError', + code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER', + }).then(common.mustCall()); } // timeout causes the child process to be killed. @@ -168,7 +165,5 @@ const failingCodeWithStdoutErr = // Module can be loaded with node: scheme. { - const promises = require('node:child_process/promises'); - assert.strictEqual(typeof promises.exec, 'function'); - assert.strictEqual(typeof promises.execFile, 'function'); + assert.strictEqual(require('node:child_process/promises'), require('child_process/promises')); }