From ff28f3fc09708a978cb73ea3d8820f9b66e04ec2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:46:49 +0000 Subject: [PATCH 01/12] console: do not drop output on EAGAIN when stdout is nonblocking Bun keeps a native console writer on fd 1 separate from the node-compat process.stdout stream. Materializing process.stdout dups fd 1 and sets O_NONBLOCK on the shared open file description, but the console writer kept doing a plain write(2) and silently discarded the unwritten tail on EAGAIN. On a full pipe with a slow reader this dropped most console.log output with exit code 0 and no error. fd_write_all_quiet now retries on EINTR and blocks on poll(POLLOUT) on EAGAIN, so console output is delivered in full regardless of the fd's blocking mode. --- src/sys/lib.rs | 18 +++++++++++ test/js/node/process/process-stdio.test.ts | 35 +++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 90d4cdbac33f..90d3f0e27bee 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9405,11 +9405,29 @@ fn qw_set_fd(qw: &mut bun_core::output::QuietWriter, fd: Fd) { /// Best-effort write-all loop. Returns `false` on I/O error / zero-write so /// `ScopedLogger::log` can disable the scope; "quiet" callers discard the bool. +/// +/// fd 1/2 can be flipped to `O_NONBLOCK` out from under this writer when user +/// code materializes `process.stdout`/`process.stderr` (the node-compat stream +/// dups the fd and sets the flag on the shared file description). A plain +/// `write(2)` then fails with `EAGAIN` on a full pipe, so we block on `poll` +/// until the fd drains rather than silently discarding the remaining bytes. fn fd_write_all_quiet(fd: Fd, mut bytes: &[u8]) -> bool { while !bytes.is_empty() { 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 => { + let mut pfd = [posix::PollFd { + fd: fd.native() as core::ffi::c_int, + events: posix::POLL_OUT, + revents: 0, + }]; + // A real error (e.g. EPIPE) surfaces on the next `write`. + let _ = posix::poll(&mut pfd, -1); + } 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..63036b9fe0ae 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", () => { @@ -158,4 +158,37 @@ describe.concurrent("process-stdio", () => { `hello worldhello again|😋 Get Emoji — All Emojis to ✂️ Copy and 📋 Paste 👌`.repeat(9999), ); }); + + // Materializing process.stdout dups fd 1 and flips it to O_NONBLOCK. The + // native console writer must not silently drop data on the resulting EAGAIN + // when the pipe is full: every console.log line has to survive a slow reader. + test.skipIf(isWindows)("console.log is not lossy after process.stdout is touched on a slow pipe", async () => { + const N = 1500; + const pad = Buffer.alloc(500, "x").toString(); + using dir = tempDir("stdout-nonblock-loss", { + "child.mjs": ` + if (process.argv[2] === "touch") void process.stdout.writableHighWaterMark; + const pad = ${JSON.stringify(pad)}; + for (let i = 0; i < ${N}; i++) console.log("O" + i + " " + pad); + `, + }); + // The reader starts 400ms late via a shell fifo, so the 64 KiB pipe fills + // and write(2) on the now-nonblocking fd 1 returns EAGAIN mid-run. + await using proc = Bun.spawn({ + cmd: [ + "/bin/sh", + "-c", + 'exec "$0" "$1" touch | { sleep 0.4; exec cat; }', + bunExe(), + path.join(String(dir), "child.mjs"), + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const delivered = stdout.split("\n").filter(l => /^O\d+ x+$/.test(l)).length; + expect(delivered).toBe(N); + expect(exitCode).toBe(0); + }); }); From 9cca5945bcaa76eeff0f3028cd9988954200dbf9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:21:34 +0000 Subject: [PATCH 02/12] test: drop misleading pipeline exit-code assertion The shell pipeline's exit status is cat's, not bun's, so asserting it was 0 proved nothing about the child. The delivered-line count is the real regression guard. --- test/js/node/process/process-stdio.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index 63036b9fe0ae..c686b60c34d4 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -172,8 +172,10 @@ describe.concurrent("process-stdio", () => { for (let i = 0; i < ${N}; i++) console.log("O" + i + " " + pad); `, }); - // The reader starts 400ms late via a shell fifo, so the 64 KiB pipe fills - // and write(2) on the now-nonblocking fd 1 returns EAGAIN mid-run. + // A separate `cat` reader starts 400ms late behind a shell fifo, so the + // 64 KiB pipe fills and write(2) on the now-nonblocking fd 1 returns EAGAIN + // mid-run. The pipeline's exit status is cat's, not bun's, so the delivered + // count is what proves the regression is gone. await using proc = Bun.spawn({ cmd: [ "/bin/sh", @@ -186,9 +188,8 @@ describe.concurrent("process-stdio", () => { stdout: "pipe", stderr: "inherit", }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const stdout = await proc.stdout.text(); const delivered = stdout.split("\n").filter(l => /^O\d+ x+$/.test(l)).length; expect(delivered).toBe(N); - expect(exitCode).toBe(0); }); }); From f647c866a18d7657b6da11548b9bbe3a04f384bf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:13:54 +0000 Subject: [PATCH 03/12] test: cover 1 MiB single-line shape; move cases out of concurrent block Adds the fast-reader repro (a single 1 MiB console.log piped to wc -c is truncated to one pipe-buffer without the fix) and moves both cases into their own non-concurrent describe so they don't add concurrent spawn load to the borderline process.stdin tests. --- test/js/node/process/process-stdio.test.ts | 30 +++++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index c686b60c34d4..fd90a56392a7 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -158,11 +158,14 @@ describe.concurrent("process-stdio", () => { `hello worldhello again|😋 Get Emoji — All Emojis to ✂️ Copy and 📋 Paste 👌`.repeat(9999), ); }); +}); - // Materializing process.stdout dups fd 1 and flips it to O_NONBLOCK. The - // native console writer must not silently drop data on the resulting EAGAIN - // when the pipe is full: every console.log line has to survive a slow reader. - test.skipIf(isWindows)("console.log is not lossy after process.stdout is touched on a slow pipe", async () => { +// Materializing process.stdout dups fd 1 and flips it to O_NONBLOCK. The native +// console writer must not silently drop data on the resulting EAGAIN when the +// pipe is full. Kept outside the concurrent block so the extra spawned children +// don't push the already-slow stdin tests past their default timeout. +describe.skipIf(isWindows)("console.log after process.stdout is materialized on a pipe", () => { + test("many lines survive a slow reader", async () => { const N = 1500; const pad = Buffer.alloc(500, "x").toString(); using dir = tempDir("stdout-nonblock-loss", { @@ -192,4 +195,23 @@ describe.concurrent("process-stdio", () => { const delivered = stdout.split("\n").filter(l => /^O\d+ x+$/.test(l)).length; expect(delivered).toBe(N); }); + + test("a single 1 MiB line is not truncated", async () => { + // A single 1 MiB write into even a fast `| wc -c` reader exceeds the 64 KiB + // pipe, so write(2) on the now-nonblocking fd 1 returns a partial count and + // then EAGAIN before the reader drains. + 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", + }); + const stdout = (await proc.stdout.text()).trim(); + expect(Number(stdout)).toBe((1 << 20) + 1); + }); }); From 735e32675119d2072e79a82fcc4ec41ac2978652 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:17:20 +0000 Subject: [PATCH 04/12] tighten doc comment on fd_write_all_quiet --- src/sys/lib.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 90d3f0e27bee..2f5b1abb6bd7 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9405,12 +9405,6 @@ fn qw_set_fd(qw: &mut bun_core::output::QuietWriter, fd: Fd) { /// Best-effort write-all loop. Returns `false` on I/O error / zero-write so /// `ScopedLogger::log` can disable the scope; "quiet" callers discard the bool. -/// -/// fd 1/2 can be flipped to `O_NONBLOCK` out from under this writer when user -/// code materializes `process.stdout`/`process.stderr` (the node-compat stream -/// dups the fd and sets the flag on the shared file description). A plain -/// `write(2)` then fails with `EAGAIN` on a full pipe, so we block on `poll` -/// until the fd drains rather than silently discarding the remaining bytes. fn fd_write_all_quiet(fd: Fd, mut bytes: &[u8]) -> bool { while !bytes.is_empty() { match write(fd, bytes) { @@ -9420,12 +9414,13 @@ fn fd_write_all_quiet(fd: Fd, mut bytes: &[u8]) -> bool { 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, }]; - // A real error (e.g. EPIPE) surfaces on the next `write`. let _ = posix::poll(&mut pfd, -1); } Err(_) => return false, From c993caa7ac57774ad7c043b1ef2b5adb6dba3d6e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:06:13 +0000 Subject: [PATCH 05/12] ci: retrigger From a8f370ce2afd822e31ccadf8aa5dc04b47e97605 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:29:29 +0000 Subject: [PATCH 06/12] FileSink: actually clear O_NONBLOCK in ForceFileSinkToBeSynchronous Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio was reading this.fd, which FileSink::setup never writes (it stays Fd::INVALID), so the update_nonblocking(fd, false) call was silently skipped and fd 1 stayed nonblocking after process.stdout materialization. The writer's fd lives in this.writer.get_fd(). Also covers a cross-process face: a bun child with stdio:'inherit' that touches its own process.stdout flips the parent's fd 1 via the shared open file description. With this fix the child restores blocking before returning. fd_write_all_quiet's poll-on-EAGAIN remains as the writer-side guarantee when something other than bun flips the shared description. --- src/runtime/webcore/FileSink.rs | 9 ++++--- test/js/node/process/process-stdio.test.ts | 29 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index f0a207de17bf..f3bcbd8e4b88 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -239,9 +239,12 @@ pub extern "C" fn Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio( { this.force_sync.set(true); // SAFETY(JsCell): single-field write; does not call into JS. - this.writer.with_mut(|w| w.force_sync = true); - if this.fd.get() != Fd::INVALID { - let _ = sys::update_nonblocking(this.fd.get(), false); + let fd = this.writer.with_mut(|w| { + w.force_sync = true; + w.get_fd() + }); + if fd != Fd::INVALID { + let _ = sys::update_nonblocking(fd, false); } } #[cfg(windows)] diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index fd90a56392a7..31b8786cd988 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -214,4 +214,33 @@ describe.skipIf(isWindows)("console.log after process.stdout is materialized on const stdout = (await proc.stdout.text()).trim(); expect(Number(stdout)).toBe((1 << 20) + 1); }); + + test("parent console.log survives a bun child that touches inherited stdout", async () => { + // O_NONBLOCK lives on the shared open file description. A bun child with + // stdio:'inherit' that materializes its process.stdout flips the PARENT's + // fd 1 too; the parent's console writer must still deliver every line. + using dir = tempDir("stdout-nonblock-child-inherit", { + "parent.mjs": ` + const { spawnSync } = require("node:child_process"); + spawnSync(process.execPath, ["-e", 'process.stdout.write("")'], + { stdio: ["ignore", "inherit", "ignore"] }); + const pad = Buffer.alloc(190, 120).toString(); + for (let i = 0; i < 20000; i++) console.log("O" + i + " " + pad); + `, + }); + await using proc = Bun.spawn({ + cmd: [ + "/bin/sh", + "-c", + 'exec "$0" "$1" | { sleep 1; wc -l; }', + bunExe(), + path.join(String(dir), "parent.mjs"), + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const stdout = (await proc.stdout.text()).trim(); + expect(Number(stdout)).toBe(20000); + }); }); From 2a82767f1e82cbf481daf6cbc3d8fa3b4a9a02e3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:31:51 +0000 Subject: [PATCH 07/12] [autofix.ci] apply automated fixes --- test/js/node/process/process-stdio.test.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index 31b8786cd988..aea4d4311510 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -229,13 +229,7 @@ describe.skipIf(isWindows)("console.log after process.stdout is materialized on `, }); await using proc = Bun.spawn({ - cmd: [ - "/bin/sh", - "-c", - 'exec "$0" "$1" | { sleep 1; wc -l; }', - bunExe(), - path.join(String(dir), "parent.mjs"), - ], + cmd: ["/bin/sh", "-c", 'exec "$0" "$1" | { sleep 1; wc -l; }', bunExe(), path.join(String(dir), "parent.mjs")], env: bunEnv, stdout: "pipe", stderr: "inherit", From 2023d1f8acb53f65ffb4ee2d4496dcb96edae3c4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:40:19 +0000 Subject: [PATCH 08/12] sys: bail out of fd_write_all_quiet on poll() error posix::poll already retries EINTR; any other Err means the fd is no longer pollable, so treat it like any other I/O failure instead of re-entering the write loop. --- src/sys/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 2f5b1abb6bd7..9ce2e6ab118a 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9421,7 +9421,9 @@ fn fd_write_all_quiet(fd: Fd, mut bytes: &[u8]) -> bool { events: posix::POLL_OUT, revents: 0, }]; - let _ = posix::poll(&mut pfd, -1); + if posix::poll(&mut pfd, -1).is_err() { + return false; + } } Err(_) => return false, } From fdaeb6b0cdbc69ceccba563f5173028f7a831ef1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:44:42 +0000 Subject: [PATCH 09/12] test: assert inherited-stdout child actually ran If spawnSync fails, the parent's fd 1 is never flipped and the test would spuriously pass on an unfixed build. Throw from the fixture when the child errors or exits nonzero so the regression guard can't be bypassed. --- test/js/node/process/process-stdio.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index aea4d4311510..ef4c8c0772bb 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -222,8 +222,9 @@ describe.skipIf(isWindows)("console.log after process.stdout is materialized on using dir = tempDir("stdout-nonblock-child-inherit", { "parent.mjs": ` const { spawnSync } = require("node:child_process"); - spawnSync(process.execPath, ["-e", 'process.stdout.write("")'], - { stdio: ["ignore", "inherit", "ignore"] }); + const r = spawnSync(process.execPath, ["-e", 'process.stdout.write("")'], + { stdio: ["ignore", "inherit", "ignore"] }); + if (r.error || r.status !== 0) throw new Error("child failed: " + (r.error ?? r.status)); const pad = Buffer.alloc(190, 120).toString(); for (let i = 0; i < 20000; i++) console.log("O" + i + " " + pad); `, From 73ff61ec7d8ecf82c9adf68c8f5a7a1557ea6eec Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:55:42 +0000 Subject: [PATCH 10/12] test: add isolating case for the fd_write_all_quiet EAGAIN path Bun.file(1).writer() goes through FileSink.setup (dup + O_NONBLOCK) but is not the process.stdout getter path, so ForceFileSinkToBeSynchronous never runs and fd 1 stays nonblocking. This test fails with only the FileSink fix applied (65536/1048577) and passes with the fd_write_all_quiet poll-on-EAGAIN change, giving each src/ hunk at least one test that breaks when it is reverted in isolation. --- test/js/node/process/process-stdio.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index ef4c8c0772bb..49d4f7536b71 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -238,4 +238,23 @@ describe.skipIf(isWindows)("console.log after process.stdout is materialized on const stdout = (await proc.stdout.text()).trim(); expect(Number(stdout)).toBe(20000); }); + + test("console.log survives a raw O_NONBLOCK on fd 1 (isolates the writer)", async () => { + // Bun.file(1).writer() goes through FileSink.setup (dup + O_NONBLOCK) but is + // NOT the process.stdout getter path, so ForceFileSinkToBeSynchronous never + // clears the flag. This isolates fd_write_all_quiet's EAGAIN handling. + await using proc = Bun.spawn({ + cmd: [ + "/bin/sh", + "-c", + 'exec "$0" -e "void Bun.file(1).writer(); console.log(Buffer.alloc(1<<20, 65).toString())" | wc -c', + bunExe(), + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const stdout = (await proc.stdout.text()).trim(); + expect(Number(stdout)).toBe((1 << 20) + 1); + }); }); From ebc63ae4b4e0a67cab63a215d30ded8db1c689b6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:02:53 +0000 Subject: [PATCH 11/12] test: delay the reader in the writer-isolating case A fast wc could in principle drain between partial writes and avoid EAGAIN, which would let the isolating test pass without the poll+retry on some systems. Start wc 400ms late so the 1 MiB write is guaranteed to see EAGAIN after the first pipe-buffer. --- test/js/node/process/process-stdio.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index 49d4f7536b71..a1cff7d0ea62 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -247,7 +247,7 @@ describe.skipIf(isWindows)("console.log after process.stdout is materialized on cmd: [ "/bin/sh", "-c", - 'exec "$0" -e "void Bun.file(1).writer(); console.log(Buffer.alloc(1<<20, 65).toString())" | wc -c', + 'exec "$0" -e "void Bun.file(1).writer(); console.log(Buffer.alloc(1<<20, 65).toString())" | { sleep 0.4; exec wc -c; }', bunExe(), ], env: bunEnv, From 7d5bb800d722f71e80efdb33533cd9305c51e8ea Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:24:57 +0000 Subject: [PATCH 12/12] Drop the FileSink change; pin process.stdout.write staying async Clearing O_NONBLOCK in ForceFileSinkToBeSynchronousForProcessObjectStdio made process.stdout.write() to a pipe block the JS thread and return true instead of returning false and emitting drain, which is what main and node do. The console-writer retry in fd_write_all_quiet covers every reported shape on its own, so this PR now ships only that. Tests are reworked around a single 1 MiB write, which is larger than any pipe buffer and so fails deterministically on the unfixed binary without a sleeping reader: stdout, stderr, and a parent whose fd 1 was flipped by a child with inherited stdio. A fourth test pins the async write contract so a future change to the FileSink path cannot silently make it blocking. --- src/runtime/webcore/FileSink.rs | 9 +- test/js/node/process/process-stdio.test.ts | 103 ++++++++++----------- 2 files changed, 50 insertions(+), 62 deletions(-) diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index f3bcbd8e4b88..f0a207de17bf 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -239,12 +239,9 @@ pub extern "C" fn Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio( { this.force_sync.set(true); // SAFETY(JsCell): single-field write; does not call into JS. - let fd = this.writer.with_mut(|w| { - w.force_sync = true; - w.get_fd() - }); - if fd != Fd::INVALID { - let _ = sys::update_nonblocking(fd, false); + this.writer.with_mut(|w| w.force_sync = true); + if this.fd.get() != Fd::INVALID { + let _ = sys::update_nonblocking(this.fd.get(), false); } } #[cfg(windows)] diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index a1cff7d0ea62..06599e5e6af4 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -160,101 +160,92 @@ describe.concurrent("process-stdio", () => { }); }); -// Materializing process.stdout dups fd 1 and flips it to O_NONBLOCK. The native -// console writer must not silently drop data on the resulting EAGAIN when the -// pipe is full. Kept outside the concurrent block so the extra spawned children -// don't push the already-slow stdin tests past their default timeout. -describe.skipIf(isWindows)("console.log after process.stdout is materialized on a pipe", () => { - test("many lines survive a slow reader", async () => { - const N = 1500; - const pad = Buffer.alloc(500, "x").toString(); - using dir = tempDir("stdout-nonblock-loss", { - "child.mjs": ` - if (process.argv[2] === "touch") void process.stdout.writableHighWaterMark; - const pad = ${JSON.stringify(pad)}; - for (let i = 0; i < ${N}; i++) console.log("O" + i + " " + pad); - `, - }); - // A separate `cat` reader starts 400ms late behind a shell fifo, so the - // 64 KiB pipe fills and write(2) on the now-nonblocking fd 1 returns EAGAIN - // mid-run. The pipeline's exit status is cat's, not bun's, so the delivered - // count is what proves the regression is gone. +// 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" "$1" touch | { sleep 0.4; exec cat; }', + 'exec "$0" -e "void process.stdout.isTTY; console.log(Buffer.alloc(1<<20, 65).toString())" | wc -c', bunExe(), - path.join(String(dir), "child.mjs"), ], env: bunEnv, stdout: "pipe", stderr: "inherit", }); - const stdout = await proc.stdout.text(); - const delivered = stdout.split("\n").filter(l => /^O\d+ x+$/.test(l)).length; - expect(delivered).toBe(N); + expect(Number((await proc.stdout.text()).trim())).toBe(ONE_MIB_LINE); }); - test("a single 1 MiB line is not truncated", async () => { - // A single 1 MiB write into even a fast `| wc -c` reader exceeds the 64 KiB - // pipe, so write(2) on the now-nonblocking fd 1 returns a partial count and - // then EAGAIN before the reader drains. + test("console.error after touching process.stderr", 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', + '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", }); - const stdout = (await proc.stdout.text()).trim(); - expect(Number(stdout)).toBe((1 << 20) + 1); + expect(Number((await proc.stdout.text()).trim())).toBe(ONE_MIB_LINE); }); - test("parent console.log survives a bun child that touches inherited stdout", async () => { - // O_NONBLOCK lives on the shared open file description. A bun child with - // stdio:'inherit' that materializes its process.stdout flips the PARENT's - // fd 1 too; the parent's console writer must still deliver every line. - using dir = tempDir("stdout-nonblock-child-inherit", { - "parent.mjs": ` - const { spawnSync } = require("node:child_process"); - const r = spawnSync(process.execPath, ["-e", 'process.stdout.write("")'], - { stdio: ["ignore", "inherit", "ignore"] }); - if (r.error || r.status !== 0) throw new Error("child failed: " + (r.error ?? r.status)); - const pad = Buffer.alloc(190, 120).toString(); - for (let i = 0; i < 20000; i++) console.log("O" + i + " " + pad); + 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" | { sleep 1; wc -l; }', bunExe(), path.join(String(dir), "parent.mjs")], + cmd: ["/bin/sh", "-c", 'exec "$0" "$1" | wc -c', bunExe(), path.join(String(dir), "parent.ts")], env: bunEnv, stdout: "pipe", stderr: "inherit", }); - const stdout = (await proc.stdout.text()).trim(); - expect(Number(stdout)).toBe(20000); + expect(Number((await proc.stdout.text()).trim())).toBe(ONE_MIB_LINE); }); - test("console.log survives a raw O_NONBLOCK on fd 1 (isolates the writer)", async () => { - // Bun.file(1).writer() goes through FileSink.setup (dup + O_NONBLOCK) but is - // NOT the process.stdout getter path, so ForceFileSinkToBeSynchronous never - // clears the flag. This isolates fd_write_all_quiet's EAGAIN handling. + 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: [ - "/bin/sh", - "-c", - 'exec "$0" -e "void Bun.file(1).writer(); console.log(Buffer.alloc(1<<20, 65).toString())" | { sleep 0.4; exec wc -c; }', 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: "inherit", + stderr: "pipe", }); - const stdout = (await proc.stdout.text()).trim(); - expect(Number(stdout)).toBe((1 << 20) + 1); + 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 }); }); });