Skip to content

process.execve() passes non-blocking stdio to the new program #66824

Open

Description

@fengmk2

Version

v22.23.2, v24.22.0, v26.12.0

Platform

Darwin 25.6.0 arm64

Subsystem

process

What steps will reproduce the bug?

// repro.mjs
// Touching process.stdout opens a libuv handle for the pipe, which sets O_NONBLOCK
// on its open file description. Loading `node:util` does the same for stderr
// (internal/util/colors reads process.stderr), so most programs hit this.
process.stdout;

const check =
  'import fcntl, os; print("O_NONBLOCK on stdout:", bool(fcntl.fcntl(1, fcntl.F_GETFL) & os.O_NONBLOCK))';
if (process.argv[2] === 'spawn') {
  (await import('node:child_process')).spawnSync('python3', ['-c', check], { stdio: 'inherit' });
} else {
  process.execve('/usr/bin/python3', ['python3', '-c', check]);
}
$ node repro.mjs | cat
O_NONBLOCK on stdout: True
$ node repro.mjs spawn | cat
O_NONBLOCK on stdout: False

A program that writes more than a pipe buffer then fails:

// yes.mjs
process.stdout;
process.execve('/usr/bin/yes', ['yes']);
$ node yes.mjs | (sleep 1; head -c 2 >/dev/null)
yes: stdout: Resource temporarily unavailable
$ yes | (sleep 1; head -c 2 >/dev/null)
$

How often does it reproduce? Is there a required condition?

Always, when a stdio fd is a pipe (or socket) and Node.js has opened its handle before process.execve().

What is the expected behavior? Why is that the expected behavior?

The new program gets blocking stdio, as a child started with child_process.spawn() does (libuv clears O_NONBLOCK on a child's fds 0-2) and as the next process does after Node.js exits normally (ResetStdio() restores the flags recorded at startup). Most programs assume blocking stdio.

What do you see instead?

The new program inherits O_NONBLOCK on fds 0-2, so its writes fail with EAGAIN once a pipe is full. Execve() in src/node_process_methods.cc clears FD_CLOEXEC on fds 0-2 but does not touch O_NONBLOCK, and it skips the exit path that would call ResetStdio().

Additional information

Restoring the startup O_NONBLOCK state of fds 0-2 before execve(2), as ResetStdio() does on exit, would fix it.

As a workaround, a caller can clear the flag through each stdio stream's handle before exec:

for (const stream of [process.stdin, process.stdout, process.stderr]) {
  stream._handle?.setBlocking?.(true);
}

Only verified on macOS.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions