console: retry on EAGAIN instead of dropping output when stdout is nonblocking

Closed
RO
2mo ago
+106-1

Problem

Once process.stdout or process.stderr has been touched (reading .isTTY, any write, etc.), console.log / console.error to a pipe silently drop output when the pipe is momentarily full. Exit code stays 0 and nothing is reported.

void process.stdout.isTTY;
console.log(Buffer.alloc(1 << 20, "A").toString());
// piped to `wc -c`: node prints 1048577, bun prints one pipe buffer (8192 to 65536) and exits 0.

The same thing happens to a bun parent that never touched its own stream, if a child it spawned with inherited stdio touched its process.stdout: the O_NONBLOCK flag lives on the shared open file description. bun test --parallel builds this topology itself and loses worker output.

Cause

Materializing process.stdout creates the FileSink on a dup of fd 1 and puts O_NONBLOCK on the shared description (open_for_writing). The native console writer behind console.* is a separate writer on the same fd; its write loop, fd_write_all_quiet in src/sys/lib.rs, treated the resulting EAGAIN like any other error and returned, discarding the unwritten tail.

Fix

fd_write_all_quiet now retries on EINTR and, on EAGAIN, waits for POLLOUT and retries; a poll failure or any other write error still ends the loop as before. This restores the blocking-write semantics console.* has when the fd is blocking, independent of who flipped the flag (this process, a bun child, or any other program sharing the description).

process.stdout.write() itself is unchanged: it keeps returning false and emitting drain on a full pipe, as on main and in node. An earlier revision of this PR also made ForceFileSinkToBeSynchronousForProcessObjectStdio clear O_NONBLOCK; that turned process.stdout.write() to pipes into a blocking call and has been dropped (see alii's review). A test now pins the async contract.

The flag itself is therefore still set on fd 1 / fd 2 after process.stdout / process.stderr is materialized. This PR makes bun's own console writer correct under it; a non-bun child that inherits the fd and does plain write(2) (alii's head -c example) is unchanged and out of scope here, since clearing the flag is what regressed write().

Tests

test/js/node/process/process-stdio.test.ts, POSIX only. Each loss test writes a single 1 MiB line, which exceeds any pipe buffer, so the unfixed binary fails deterministically with no reader delay:

testunfixedfixed
console.log after touching process.stdout, into wc -c81931048577
console.error after touching process.stderr, into wc -c81921048577
parent console.log after a bun child with inherited stdout touched its stream81921048577
process.stdout.write(1 MiB) to a pipe returns false and emits drainpassespasses (fails with the dropped FileSink change)

Verified separately (not in CI, timing dependent): the bun test --parallel=4 fixture from the report goes from losing 29 to 71 percent of worker console.log lines to losing none.