From 544b6df7ffac7f25551eecb0d43d4ff89f3e631f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:17:52 +0000 Subject: [PATCH 1/4] shell: route FIFO redirect targets through the pollable IOWriter path Redirecting a builtin's stdout to a named pipe (echo ... > fifo) opened the target via open_for_writing_impl, which correctly reported pollable=true for S_IFIFO and set O_NONBLOCK on the fd, but Builtin then discarded that result and hardcoded pollable=false for the IOWriter on POSIX. The writer therefore took the synchronous file path, whose drain loop treats EAGAIN as unreachable. With a slow or paused reader the pipe buffer fills, write(2) returns EAGAIN, and the process aborts with: panic: internal error: entered unreachable code: drainBufferedData returning .pending in IOWriter.doFileWrite should not happen Pass the computed pollable flag through so FIFO/socket redirect targets use the pollable writer (same as pipeline stdout already does), and replace the unreachable in do_file_write with an in-place upgrade to the pollable path so any remaining misclassification surfaces as backpressure rather than an abort. --- src/runtime/shell/Builtin.rs | 13 ++--- src/runtime/shell/IOWriter.rs | 24 ++++++++-- test/js/bun/shell/file-io.test.ts | 80 ++++++++++++++++++++++++++++++- 3 files changed, 103 insertions(+), 14 deletions(-) diff --git a/src/runtime/shell/Builtin.rs b/src/runtime/shell/Builtin.rs index 983e0a83b166..9be291a1f006 100644 --- a/src/runtime/shell/Builtin.rs +++ b/src/runtime/shell/Builtin.rs @@ -578,9 +578,6 @@ impl Builtin { let cwd_fd = Self::cwd(interp, cmd); let evtloop = interp.event_loop; - // Regular files are not pollable on linux/macos. - let is_pollable_default: bool = cfg!(windows); - let mut pollable = false; let mut is_socket = false; let mut is_nonblocking = false; @@ -671,15 +668,13 @@ impl Builtin { return None; } - // The IOWriter receives the hardcoded platform const - // `is_pollable` (false on POSIX, true on Windows); the - // `pollable` out-param populated by `open_for_writing_impl` - // is intentionally ignored. - let _ = pollable; + // Honor the `pollable` computed by `open_for_writing_impl` on + // POSIX so a FIFO/socket target (whose fd is now O_NONBLOCK) + // takes the pollable path; Windows keeps the async writer. let redirect_writer = IOWriter::init( redirfd, io_writer::Flags { - pollable: is_pollable_default, + pollable: if cfg!(windows) { true } else { pollable }, nonblock: is_nonblocking, is_socket, ..Default::default() diff --git a/src/runtime/shell/IOWriter.rs b/src/runtime/shell/IOWriter.rs index c9b3b44bff16..3b34be16e33a 100644 --- a/src/runtime/shell/IOWriter.rs +++ b/src/runtime/shell/IOWriter.rs @@ -716,10 +716,26 @@ impl IOWriter { // holding a stale `&mut`. let amt = match result { bun_io::WriteResult::Done(amt) | bun_io::WriteResult::Wrote(amt) => amt, - bun_io::WriteResult::Pending(_) => { - unreachable!( - "drainBufferedData returning .pending in IOWriter.doFileWrite should not happen" - ); + bun_io::WriteResult::Pending(amt) => { + // EAGAIN from a target that was classified non-pollable (a + // FIFO or chardev opened by path with O_NONBLOCK). Record the + // partial write and restart this writer on the pollable path. + let s = self.state(); + let lo = s.total_bytes_written; + s.writers[idx].tee(&s.buf[lo..lo + amt]); + s.total_bytes_written += amt; + s.writers[idx].written += amt; + s.flags.pollable = true; + s.flags.nonblock = true; + s.started = false; + return match self.write() { + WriteOutcome::Suspended => Yield::suspended(), + WriteOutcome::IsActuallyFile => self.on_sync_error( + child, + &sys::Error::from_code(E::EAGAIN, sys::Tag::write), + ), + WriteOutcome::Failed(e) => self.on_sync_error(child, &e), + }; } // The caller is inside the enqueuing child's trampoline, so the // error completion is returned, not `Yield::run` from here. diff --git a/test/js/bun/shell/file-io.test.ts b/test/js/bun/shell/file-io.test.ts index 13c1889f9ff1..5b10f0351a8e 100644 --- a/test/js/bun/shell/file-io.test.ts +++ b/test/js/bun/shell/file-io.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isPosix, tempDir } from "harness"; +import * as fs from "node:fs"; +import { join } from "node:path"; import { createTestBuilder } from "./test_builder"; const TestBuilder = createTestBuilder(import.meta.path); @@ -47,6 +49,82 @@ describe("IOWriter file output redirection", () => { describe("special file types", () => { TestBuilder.command`echo "disappear" > /dev/null`.exitCode(0).stdout("").runAsTest("write to /dev/null"); + + // A FIFO opened by path for `>` must take the pollable path; once the pipe + // buffer fills, write(2) returns EAGAIN and the writer has to poll for + // writability instead of treating the fd as an EAGAIN-free regular file. + test.concurrent.skipIf(!isPosix)("redirect to a FIFO whose reader drains slowly applies backpressure", async () => { + const payloadLen = 200 * 1024; + using dir = tempDir("shell-fifo-redirect", { + "fixture.ts": /* ts */ ` + import { $ } from "bun"; + import * as fs from "node:fs"; + const fifo = process.env.FIFO!; + // Reader (nonblocking so open succeeds with no writer yet). + const rfd = fs.openSync(fifo, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK); + // Writer used to pre-fill the pipe buffer so the shell's first + // write is guaranteed to hit EAGAIN regardless of drain timing. + const wfd = fs.openSync(fifo, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK); + const fill = Buffer.alloc(4096, "y"); + try { while (true) fs.writeSync(wfd, fill); } catch {} + + const big = Buffer.alloc(${payloadLen}, "z").toString(); + let shell: Awaited> | undefined; + const pending = $\`echo \${big} > \${fifo}\`.quiet().nothrow().then(r => (shell = r)); + + const buf = Buffer.alloc(65536); + let zs = 0; + while (!shell) { + let n = 0; + try { n = fs.readSync(rfd, buf); } catch (e: any) { if (e.code !== "EAGAIN") throw e; } + for (let i = 0; i < n; i++) if (buf[i] === 0x7a) zs++; + await new Promise(r => setImmediate(r)); + } + await pending; + fs.closeSync(wfd); + // Drain whatever is left now that all writers are closed. + while (true) { + let n = 0; + try { n = fs.readSync(rfd, buf); } catch (e: any) { if (e.code !== "EAGAIN") throw e; n = 0; } + if (n === 0) break; + for (let i = 0; i < n; i++) if (buf[i] === 0x7a) zs++; + } + fs.closeSync(rfd); + console.log(JSON.stringify({ exitCode: shell!.exitCode, zs })); + `, + }); + const fifo = join(String(dir), "out.fifo"); + await using mk = Bun.spawn({ cmd: [Bun.which("mkfifo")!, fifo], env: bunEnv }); + await mk.exited; + + // Hold a read end in the parent so the FIFO survives a child abort + // without the tempDir teardown racing an open writer. + const holder = fs.openSync(fifo, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK); + try { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--debug-crash-handler-use-trace-string", "fixture.ts"], + cwd: String(dir), + env: { ...bunEnv, FIFO: fifo }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + let parsed: unknown; + try { + parsed = JSON.parse(stdout.trim().split("\n").pop() ?? ""); + } catch { + parsed = stdout; + } + // echo appends "\n" (0x0a, not 'z'), so the 'z' count equals payloadLen. + expect({ parsed, stderr, exitCode }).toEqual({ + parsed: { exitCode: 0, zs: payloadLen }, + stderr: expect.any(String), + exitCode: 0, + }); + } finally { + fs.closeSync(holder); + } + }); }); describe("writer queue and bump behavior", () => { From cf1f15a8708c2d2d609f9b738878ddc85cc2c35e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:20:13 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- src/runtime/shell/IOWriter.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/runtime/shell/IOWriter.rs b/src/runtime/shell/IOWriter.rs index 3b34be16e33a..fd6a92053527 100644 --- a/src/runtime/shell/IOWriter.rs +++ b/src/runtime/shell/IOWriter.rs @@ -730,10 +730,8 @@ impl IOWriter { s.started = false; return match self.write() { WriteOutcome::Suspended => Yield::suspended(), - WriteOutcome::IsActuallyFile => self.on_sync_error( - child, - &sys::Error::from_code(E::EAGAIN, sys::Tag::write), - ), + WriteOutcome::IsActuallyFile => self + .on_sync_error(child, &sys::Error::from_code(E::EAGAIN, sys::Tag::write)), WriteOutcome::Failed(e) => self.on_sync_error(child, &e), }; } From 85c00e9f8aceff7f9f1ba42e8d5e0afad11a3f1e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:27:04 +0000 Subject: [PATCH 3/4] shell: share write-accounting bump between do_file_write outcomes --- src/runtime/shell/IOWriter.rs | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/runtime/shell/IOWriter.rs b/src/runtime/shell/IOWriter.rs index fd6a92053527..e35aee952fa0 100644 --- a/src/runtime/shell/IOWriter.rs +++ b/src/runtime/shell/IOWriter.rs @@ -693,6 +693,17 @@ impl IOWriter { // ── file write (non-pollable sync path) ───────────────────────────── + /// Tee `amt` bytes from the current buffer position into `writers[idx]`'s + /// capture and advance its `written` / `total_bytes_written` counters. + #[cfg(not(windows))] + fn record_write_progress(&self, idx: usize, amt: usize) { + let s = self.state(); + let lo = s.total_bytes_written; + s.writers[idx].tee(&s.buf[lo..lo + amt]); + s.total_bytes_written += amt; + s.writers[idx].written += amt; + } + /// POSIX-only. `child` is the writer being enqueued (see `on_sync_error`). #[cfg(not(windows))] fn do_file_write(&self, child: ChildPtr) -> Yield { @@ -720,11 +731,8 @@ impl IOWriter { // EAGAIN from a target that was classified non-pollable (a // FIFO or chardev opened by path with O_NONBLOCK). Record the // partial write and restart this writer on the pollable path. + self.record_write_progress(idx, amt); let s = self.state(); - let lo = s.total_bytes_written; - s.writers[idx].tee(&s.buf[lo..lo + amt]); - s.total_bytes_written += amt; - s.writers[idx].written += amt; s.flags.pollable = true; s.flags.nonblock = true; s.started = false; @@ -739,12 +747,8 @@ impl IOWriter { // error completion is returned, not `Yield::run` from here. bun_io::WriteResult::Err(e) => return self.on_sync_error(child, &e), }; - let s = self.state(); - let lo = s.total_bytes_written; - s.writers[idx].tee(&s.buf[lo..lo + amt]); - s.total_bytes_written += amt; - s.writers[idx].written += amt; - if !s.writers[idx].wrote_everything() { + self.record_write_progress(idx, amt); + if !self.state().writers[idx].wrote_everything() { // The only case where we get partial writes is when an error is // encountered, which returns above. unreachable!( From eaec13dc0819e9043a1c45e83f564859b06e94d2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:10:23 +0000 Subject: [PATCH 4/4] ci: retrigger