shell: route FIFO redirect targets through the pollable IOWriter path

Merged
RO
4mo ago
+111-20

Repro

import { $ } from "bun";
import { execSync } from "node:child_process";
import * as fs from "node:fs";
const fifo = `/tmp/fifo-${process.pid}.fifo`;
execSync(`mkfifo ${fifo}`);
// a reader exists so open(O_WRONLY) succeeds, but it never drains
const rfd = fs.openSync(fifo, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
$.nothrow();
await $`echo ${"z".repeat(200 * 1024)} > ${fifo}`.quiet();
// before: rc=134 abort
// panic: internal error: entered unreachable code: drainBufferedData returning
//        .pending in IOWriter.doFileWrite should not happen

Deterministic on Linux and macOS.

Cause

Builtin opens a >-redirect target via open_for_writing_impl, which fstats the fd, reports pollable = true for S_IFIFO/S_IFSOCK, and sets O_NONBLOCK. On POSIX the caller then discarded that result and passed a hardcoded pollable = false to IOWriter::init, so the writer took the synchronous file path (do_file_write). That path drives write(2) in a loop and treats WriteResult::Pending (EAGAIN) as unreachable!() on the assumption that a "file" never returns EAGAIN. A FIFO with a slow reader does: once the pipe buffer fills, the next write returns EAGAIN and the process aborts. Pipeline stdout (echo big | slow) already went through the pollable writer and handled the same fd shape correctly.

Fix

  • Builtin.rs: pass the pollable value computed by open_for_writing_impl to the redirect IOWriter on POSIX (Windows keeps its unconditional async writer). FIFO/socket redirect targets now take the pollable path and poll for writability under backpressure.
  • IOWriter::do_file_write: replace the unreachable!() for Pending with an in-place upgrade: record the partial write, flip flags.pollable/nonblock, and restart via write() so the remainder drains through the poll machinery. If the fd turns out to also be un-epollable, the EAGAIN is surfaced as the chunk's write error instead of aborting.

Verification

$ USE_SYSTEM_BUN=1 bun test test/js/bun/shell/file-io.test.ts -t FIFO
(fail) redirect to a FIFO whose reader drains slowly applies backpressure
  exitCode: 134
  stderr: panic: internal error: entered unreachable code: drainBufferedData
          returning .pending in IOWriter.doFileWrite should not happen

$ bun bd test test/js/bun/shell/file-io.test.ts -t FIFO
(pass) redirect to a FIFO whose reader drains slowly applies backpressure

The full file-io.test.ts (27 tests) and bunshell.test.ts (412 tests) suites pass.


[review] gate passed · iteration 2 · 3 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/shell/file-io.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (eaec13dc0)

test/js/bun/shell/file-io.test.ts:
(pass) IOWriter file output redirection > basic file redirection > simple echo to file [59.66ms]
(pass) IOWriter file output redirection > basic file redirection > empty output to file [7.65ms]
(pass) IOWriter file output redirection > basic file redirection > zero-length write should trigger onIOWriterChunk callback [6.36ms]
(pass) IOWriter file output redirection > drainBufferedData edge cases > large single write [17.95ms]
(pass) IOWriter file output redirection > drainBufferedData edge cases > write to subdirectory [8.22ms]
bun: Not a directory: /dev/null/invalid/path(pass) IOWriter file output redirection > file system error conditions > write to invalid path should fail [22.73ms]

... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (85c00e9f8)

test/js/bun/shell/file-io.test.ts:
(pass) IOWriter file output redirection > basic file redirection > simple echo to file [1.35ms]
(pass) IOWriter file output redirection > basic file redirection > empty output to file [0.37ms]
(pass) IOWriter file output redirection > basic file redirection > zero-length write should trigger onIOWriterChunk callback [0.25ms]
(pass) IOWriter file output redirection > drainBufferedData edge cases > large single write [0.48ms]
(pass) IOWriter file output redirection > drainBufferedData edge cases > write to subdirectory [0.30ms]
bun: Not a directory: /dev/null/invalid/path(pass) IOWriter file output redirection > file system error conditions > write to invalid path should fail [0.25ms]
bun: No such file or directory: /nonexistent/file.txt(pass) IOWriter file output redirection > file system error conditions > write to non-existent directory should fail [0.11ms]
(pass) IOWriter file output redirection > special file types > write to /dev/null [0.07ms]
(pass) IOWriter file output redirection > special file types > redirect to a FIFO whose reader drains slowly applies backpressure [32.02ms]
(pass) IO
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/shell/file-io.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (eaec13dc0)

test/js/bun/shell/file-io.test.ts:
(pass) IOWriter file output redirection > basic file redirection > simple echo to file [58.79ms]
(pass) IOWriter file output redirection > basic file redirection > empty output to file [7.43ms]
(pass) IOWriter file output redirection > basic file redirection > zero-length write should trigger onIOWriterChunk callback [6.06ms]
(pass) IOWriter file output redirection > drainBufferedData edge cases > large single write [5.85ms]
(pass) IOWriter file output redirection > drainBufferedData edge cases > write to subdirectory [22.60ms]
bun: Not a directory: /dev/null/invalid/path(pass) IOWriter file output redirection > file system error conditions > write to invalid path should fail [16.45ms]

... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 707ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version: 1.29.0)
   Compiling bun_core v0.0.0 (/workspace/bun/src/bun_core)
   Compiling bun_errno v0.0.0 (/workspace/bun/src/errno)

... (truncated)
diff hotspot
src/runtime/shell/Builtin.rs      | 13 ++-----
 src/runtime/shell/IOWriter.rs     | 38 ++++++++++++++-----
 test/js/bun/shell/file-io.test.ts | 80 ++++++++++++++++++++++++++++++++++++++-
 3 files changed, 111 insertions(+), 20 deletions(-)

gate history · 2 passed · 0 rejected · iteration 2

evidence per changed file
file                               reads  edits  tests
src/runtime/shell/Builtin.rs           3      2      0
src/runtime/shell/IOWriter.rs          3      4      0
test/js/bun/shell/file-io.test.ts      1      6      0