process.stdout/stderr: clear O_NONBLOCK for spawned children, don't drop console output on EAGAIN

Merged
JA
1mo ago
+412-87

What does this PR do?

Same model as Node/libuv for process.stdout / process.stderr on POSIX: a pipe or socket behind fd 1/2 is O_NONBLOCK (libuv's uv_pipe_open does this too) and written asynchronously; TTYs and files are synchronous. O_NONBLOCK lives on the shared open file description, so it leaked in two places that Node covers and Bun did not:

beforeafter
stdio: "inherit" child runs head -c 1000000 after parent used process.stdoutEAGAIN, short output, exit 1full output, exit 0
fd 1/2 flags seen by an inherit childO_NONBLOCKblocking
console.log(1 MiB line) after process.stdout.isTTY, piped720896 bytes1048577 bytes
process.stdout.write(1 MiB) to a full pipefalse + 'drain'false + 'drain' (unchanged)
same, after an inherit spawn (socket any OS / pipe Linux)would block like Nodefalse + 'drain'
  • spawn clears O_NONBLOCK on the fds that become the child's 0–2, as libuv's uv__process_child_init does. Done parent-side before posix_spawn (same effect, since the description is shared, and macOS file actions cannot fcntl).
  • The native console writer waits for POLLOUT on EAGAIN instead of dropping the rest of the line. Node does not need this because console.* goes through the stdout stream there.

Supersedes #33560 and #33827.

How did you verify your code works?

test/js/node/process/process-stdio.test.ts gains 9 tests (fd-flag probe in an inherit child, head -c through inherited stdout, 1 MiB console.log/console.error, 1 MiB through a real pipe(2) via sh, and the false + 'drain' contract over both a socketpair and a real pipe). 6 fail on 1.4.3 (the post-spawn ones guard this PR's own spawn change), all pass here; also ran process-stdin, spawn, child-process-stdio, console-log locally on macOS.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/process/process-stdio.test.ts