diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 90d4cdbac33f..9ce2e6ab118a 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9410,6 +9410,21 @@ fn fd_write_all_quiet(fd: Fd, mut bytes: &[u8]) -> bool { match write(fd, bytes) { Ok(0) => return false, // short write → give up Ok(n) => bytes = &bytes[n..], + #[cfg(unix)] + Err(e) if e.get_errno() == E::EINTR => continue, + #[cfg(unix)] + Err(e) if e.get_errno() == E::EAGAIN => { + // fd 1/2 may be O_NONBLOCK once `process.stdout`/`stderr` is + // materialized; block until writable instead of dropping bytes. + let mut pfd = [posix::PollFd { + fd: fd.native() as core::ffi::c_int, + events: posix::POLL_OUT, + revents: 0, + }]; + if posix::poll(&mut pfd, -1).is_err() { + return false; + } + } Err(_) => return false, } } diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index ba35bc4f0484..06599e5e6af4 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -1,6 +1,6 @@ import { spawn, spawnSync } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import path from "path"; import { isatty } from "tty"; describe.concurrent("process-stdio", () => { @@ -159,3 +159,93 @@ describe.concurrent("process-stdio", () => { ); }); }); + +// Materializing process.stdout / process.stderr puts O_NONBLOCK on the shared +// open file description of fd 1 / fd 2. The native console writer behind +// console.log / console.error must then retry on EAGAIN instead of dropping the +// unwritten tail. A single 1 MiB write is larger than any pipe buffer, so the +// first write(2) is always short and the next one always sees EAGAIN: the +// unfixed binary delivers exactly one pipe buffer no matter how fast the reader +// is, which keeps these deterministic without a sleeping reader. Kept outside the +// concurrent block above so the extra children don't push the stdin tests past +// their timeout. +describe.skipIf(isWindows)("console output is not truncated once the stdio fd is nonblocking", () => { + const ONE_MIB_LINE = (1 << 20) + 1; + + test("console.log after touching process.stdout", async () => { + await using proc = Bun.spawn({ + cmd: [ + "/bin/sh", + "-c", + 'exec "$0" -e "void process.stdout.isTTY; console.log(Buffer.alloc(1<<20, 65).toString())" | wc -c', + bunExe(), + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + expect(Number((await proc.stdout.text()).trim())).toBe(ONE_MIB_LINE); + }); + + test("console.error after touching process.stderr", async () => { + await using proc = Bun.spawn({ + cmd: [ + "/bin/sh", + "-c", + 'exec "$0" -e "void process.stderr.isTTY; console.error(Buffer.alloc(1<<20, 66).toString())" 2>&1 >/dev/null | wc -c', + bunExe(), + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + expect(Number((await proc.stdout.text()).trim())).toBe(ONE_MIB_LINE); + }); + + test("console.log in a parent after a bun child touched the inherited stdout", async () => { + // The flag lives on the open file description, so a child with + // stdio: 'inherit' that touches its process.stdout flips the parent's fd 1 + // too, even though the parent never touched its own stream. + using dir = tempDir("stdout-nonblock-inherited", { + "parent.ts": ` + const r = Bun.spawnSync({ + cmd: [process.execPath, "-e", 'process.stdout.write("")'], + stdout: "inherit", + }); + if (!r.success) throw new Error("child failed: " + r.exitCode); + console.log(Buffer.alloc(1 << 20, 65).toString()); + `, + }); + await using proc = Bun.spawn({ + cmd: ["/bin/sh", "-c", 'exec "$0" "$1" | wc -c', bunExe(), path.join(String(dir), "parent.ts")], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + expect(Number((await proc.stdout.text()).trim())).toBe(ONE_MIB_LINE); + }); + + test("process.stdout.write to a pipe stays asynchronous", async () => { + // Pins the contract the console-writer fix must not disturb: like node, a + // write larger than the pipe returns false immediately and emits 'drain' + // once the reader catches up, rather than blocking the JS thread. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const ret = process.stdout.write(Buffer.alloc(1 << 20, 65)); + process.stdout.once("drain", () => { + process.stderr.write(JSON.stringify({ ret, drained: true })); + }); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr] = await Promise.all([proc.stdout.bytes(), proc.stderr.text()]); + expect(stdout.byteLength).toBe(1 << 20); + expect(JSON.parse(stderr)).toEqual({ ret: false, drained: true }); + }); +});