Skip to content

child_process: add promises API#62337

Open
Felipeness wants to merge 2 commits intonodejs:mainfrom
Felipeness:fix/child-process-promises
Open

child_process: add promises API#62337
Felipeness wants to merge 2 commits intonodejs:mainfrom
Felipeness:fix/child-process-promises

Conversation

@Felipeness
Copy link

@Felipeness Felipeness commented Mar 19, 2026

Summary

Add node:child_process/promises module providing promise-based exec() and execFile() functions.

Delegates to the existing customPromiseExecFunction in child_process.js via promisify(), following the same pattern as fs/promises, dns/promises, and stream/promises.

Fixes: #49904

@nodejs-github-bot nodejs-github-bot added child_process Issues and PRs related to the child_process subsystem. needs-ci PRs that need a full CI run. labels Mar 19, 2026
* }} [options]
* @returns {Promise<{ stdout: string | Buffer, stderr: string | Buffer }>}
*/
function execFile(file, args, options) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
function execFile(file, args, options) {
async function execFile(file, args, options) {

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @aduh95, these suggestions were on the previous version — I've since rewritten the implementation to delegate to the existing \ via \ / , so the module is now just 9 lines. The force-push landed after your review, sorry for the confusion!

* }} [options]
* @returns {Promise<{ stdout: string | Buffer, stderr: string | Buffer }>}
*/
function exec(command, options) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
function exec(command, options) {
async function exec(command, options) {

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above — this was on the old version. The current implementation delegates via promisify(exec) so this function no longer exists. Sorry for the confusing force-push timing!

Comment on lines +56 to +61
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');
}));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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());

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied, much cleaner — thanks!

Comment on lines +75 to +78
execFile(process.execPath, { timeout: 5000 })
.catch(common.mustCall(() => {
// Expected to fail (no script), but should not throw synchronously.
}));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
execFile(process.execPath, { timeout: 5000 })
.catch(common.mustCall(() => {
// Expected to fail (no script), but should not throw synchronously.
}));
assert.rejects(execFile(process.execPath, { timeout: 5 }), {
code: 'TODO',
});

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied! Used assert.rejects with { killed: true } — the timeout path kills the child with SIGTERM and sets killed: true on the error, so that seemed like the right property to assert on.

Comment on lines +85 to +87
assert.strictEqual(typeof result.stdout, 'string');
assert.strictEqual(typeof result.stderr, 'string');
assert.strictEqual(result.stdout, 'hello\n');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
assert.strictEqual(typeof result.stdout, 'string');
assert.strictEqual(typeof result.stderr, 'string');
assert.strictEqual(result.stdout, 'hello\n');
assert.strictDeepEqual(result, {
stdout: 'hello\n',
stderr: '',
});

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied, makes the assertions much tighter.

Comment on lines +95 to +97
assert(Buffer.isBuffer(result.stdout));
assert(Buffer.isBuffer(result.stderr));
assert.strictEqual(result.stdout.toString(), 'hello\n');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
assert(Buffer.isBuffer(result.stdout));
assert(Buffer.isBuffer(result.stderr));
assert.strictEqual(result.stdout.toString(), 'hello\n');
assert.strictDeepEqual(result, {
stderr: Buffer.from(),
stdout: Buffer.from('hello\n'),
});

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied!

Comment on lines +141 to +148
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');
}));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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');
}));
assert.rejects(execFile(
process.execPath,
['-e', "console.log('a'.repeat(100))"],
{ maxBuffer: 10 },
), {
name: 'RangeError',
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
}).then(common.mustCall());

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied, cleaner structure.

Comment on lines +163 to +169

// Test: 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');
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is needed

Suggested change
// Test: 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');
}

If we really want it, we should be checking for strict equality.

Suggested change
// Test: 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'));

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — replaced it with the strict equality check between node: and non-prefixed require, which is more useful.

{
const promise = exec(...common.escapePOSIXShell`"${process.execPath}" -p 42`);

assert(promise.child instanceof child_process.ChildProcess);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we should expose that, I wonder if folks who want access to ChildProcess would not already be using spawn

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a fair point. The .child property is inherited from util.promisify(exec) — it's already part of the existing customPromiseExecFunction in child_process.js, so it's not something we explicitly add. Since it's already there and documented, I kept the test, but I removed the .child assertions from this file for now. Happy to add them back or remove the property from the docs entirely if you think it shouldn't be surfaced in the promises API. What do you think?

@Felipeness Felipeness force-pushed the fix/child-process-promises branch from f583b4b to 94ab73c Compare March 19, 2026 15:36
@aduh95
Copy link
Contributor

aduh95 commented Mar 19, 2026

What's up with your last commit?

@Felipeness
Copy link
Author

Hey @aduh95, sorry about that! I realized the original implementation was a 415-line reimplementation of logic that already exists in child_process.js (customPromiseExecFunction). I rewrote it to delegate via promisify(exec) / promisify(execFile), which brings the module down to 9 lines and inherits all the existing error handling, AbortSignal support, and .child property for free. The force-push landed right as you were reviewing — bad timing, sorry!

@aduh95
Copy link
Contributor

aduh95 commented Mar 19, 2026

Did you rewrite it or are you using an agent to do it for you?

@Felipeness
Copy link
Author

I wrote the core implementation and did the analysis myself — the rewrite from reimplementation to delegation was my call after reviewing how fs/promises and dns/promises work internally. I do use Claude Code as a coding assistant, mainly because English isn't my native language (I'm Brazilian) and it helps me write clearer commit messages, PR descriptions, and adapt to the project's code style. The actual design decisions and code review are mine — I wouldn't submit something I don't understand.

@aduh95
Copy link
Contributor

aduh95 commented Mar 19, 2026

I mean you did submit a PR saying in bold "Not a thin util.promisify() wrapper" to override the entire implementation with a thin util.promisify() wrapper in the next commit, which is not a good look

@Felipeness
Copy link
Author

Yes, fair enough, it's entirely my fault. The description was written for the original 415-line version, and I simply forgot to update it after switching to the delegation approach. It definitely doesn't look good when the bold text says the opposite of what the code does. Thanks for pointing it out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

child_process Issues and PRs related to the child_process subsystem. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Promise-based versions for some functions in child_process

3 participants