Skip to content

shell: route FIFO redirect targets through the pollable IOWriter path - #34696

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/c8f6757c/shell-fifo-redirect-panic
Jul 19, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
farm/c8f6757c/shell-fifo-redirect-panic

Conversation

@robobun

@robobun robobun commented Jul 19, 2026 •

Copy link
Copy Markdown
Collaborator

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)
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�
... (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

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.
@robobun

robobun commented Jul 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on 1.4.0 and main. PR #34696 routes FIFO redirect targets through the pollable writer and replaces the do_file_write panic with a pollable upgrade. New test in test/js/bun/shell/file-io.test.ts pre-fills the pipe so the shell's first write hits EAGAIN deterministically; fails on the release binary, passes with the fix.

CI: the diff is green. The new test passed on every lane across builds #75740 and #75742. Remaining reds are unrelated: test-net-connect-memleak.js on alpine is pre-existing on main (tracked separately); the rest are single-lane flakes that passed on retry (install/webview/terminal/http tests on lanes this diff does not touch). Ready for review.

@robobun

robobun commented Jul 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:52 AM PT - Jul 19th, 2026

❌ @robobun, your commit eaec13d has 1 failures in Build #75742 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34696

That installs a local version of the PR into your bun-34696 executable, so you can run:

bun-34696 --bun

@coderabbitai

coderabbitai Bot commented Jul 19, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1f5d433a-8ae2-4f23-9eca-47cf6020d2e9

📥 Commits

Reviewing files that changed from the base of the PR and between cf1f15a and 85c00e9.

📒 Files selected for processing (1)
  • src/runtime/shell/IOWriter.rs

Walkthrough

Changes

The shell now preserves computed file-writer pollability on POSIX, retries pending synchronous writes through a nonblocking path, and adds a POSIX FIFO backpressure test.

FIFO redirection handling

Layer / File(s) Summary
Redirection writer pollability
src/runtime/shell/Builtin.rs
File redirect initialization passes computed pollability on POSIX and forces pollability on Windows.
Pending write retry and FIFO validation
src/runtime/shell/IOWriter.rs, test/js/bun/shell/file-io.test.ts
Pending writes record progress, switch to the pollable path, retry, and are validated with a POSIX FIFO test.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: routing FIFO redirect targets through the pollable IOWriter path.
Description check ✅ Passed It covers the PR purpose, root cause, fix, and verification, even though it uses custom headings instead of the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/shell/IOWriter.rs`:
- Around line 719-738: Extract the duplicated partial-write accounting sequence
from the Pending branch and the nearby Done/Wrote path into a small helper on
the IOWriter implementation. Have both paths use the helper to update the tee
buffer, total_bytes_written, and per-writer written count consistently, while
preserving their existing control flow and outcome handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2572505e-a686-4e3a-a0e8-977c0212ce76

📥 Commits

Reviewing files that changed from the base of the PR and between 98f6649 and 544b6df.

📒 Files selected for processing (3)
  • src/runtime/shell/Builtin.rs
  • src/runtime/shell/IOWriter.rs
  • test/js/bun/shell/file-io.test.ts

Comment thread src/runtime/shell/IOWriter.rs Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found, but this touches the shell IOWriter's state machine (a new mid-write upgrade from the sync file path to the pollable path, with UnsafeCell re-entrancy and Stacked Borrows discipline in play), so a human should sign off.

What was reviewed:

  • Builtin.rs: verified open_for_writing_impl already sets pollable/is_nonblocking for S_IFIFO/S_IFSOCK and that no other IOWriter::init caller hardcodes pollable=false for a nonblocking fd (Pipeline uses true, interpreter uses is_pollable(fd)).
  • do_file_write Pending arm: traced that record_write_progress advances total_bytes_written/written so the subsequent get_buffer() slices the correct remainder; Pending(amt) guarantees amt < len so the debug_assert!(len != written) in get_buffer_impl holds; the __start() EINVAL/EPERM fallback resets flags.pollable=false before IsActuallyFile is returned, so its debug_assert!(!pollable) still holds.
  • Test: pre-fill via wfd guarantees the shell's first write hits EAGAIN; final drain runs after await pending + closeSync(wfd) so all 'z' bytes are countable; parent holder fd is read-only and never drained, so it can't steal bytes from the child reader.
Extended reasoning...

Overview

Three files: Builtin.rs (pass computed pollable instead of hardcoded false to the redirect IOWriter on POSIX), IOWriter.rs (replace the unreachable!() on WriteResult::Pending in do_file_write with an in-place upgrade to the pollable path, plus a record_write_progress helper extracted per CodeRabbit feedback), and a new POSIX-gated FIFO backpressure test in file-io.test.ts.

Security risks

None. This is internal shell fd-classification and write-path selection; no user input parsing, auth, or crypto.

Level of scrutiny

Medium-high. IOWriter is a hand-rolled UnsafeCell<State> state machine with explicit Stacked Borrows re-derive discipline and re-entrancy through Yield::run. The new Pending arm introduces a state transition (sync-file → pollable) that did not exist before: it mutates flags.pollable/nonblock/started mid-do_file_write and calls self.write() while the scopeguard::defer for set_writing(false) is still armed. On POSIX set_writing is a no-op so this is benign, and the existing __start() EINVAL/EPERM fallback correctly resets flags.pollable=false before IsActuallyFile is returned (so the debug_assert!(!s.flags.pollable) in write() still holds and the caller surfaces EAGAIN via on_sync_error rather than looping). I traced these paths and they look correct, but the interaction surface is subtle enough that a maintainer familiar with the IOWriter re-entrancy model should confirm.

Other factors

  • The Builtin.rs one-liner is the actual root-cause fix; the IOWriter.rs change is defense-in-depth (with the Builtin fix, a FIFO redirect target now takes the pollable path from the start and never enters do_file_write). I checked the other three IOWriter::init call sites (interpreter.rs stdout/stderr, Pipeline.rs) — none hardcode pollable=false for a nonblocking fd, so this was the only site with the bug.
  • Pending(amt) from drain_buffered_data guarantees amt < len (the inner try_write_with_write_fn loop exits with Wrote once offset == buf.len()), so after record_write_progress the writer is not yet complete and the follow-on get_buffer_impl slice is non-empty and its debug_assert!(len != written) holds.
  • Test looks solid: pre-fills the pipe so EAGAIN is deterministic, counts exact 'z' bytes rather than length, drains concurrently via setImmediate (no sleep), and holds a parent-side read fd so a crashing child doesn't wedge tempDir teardown. Verified USE_SYSTEM_BUN=1 fails per the PR body.
  • CodeRabbit's dedup nit was addressed in 85c00e9 and the thread is resolved; no outstanding review comments.

@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

The error-path-drops-live-owner audit hardening items (keepalive in on_write_pollable and both POSIX _on_error impls, the drain_buffered_data Wrote-after-error fold, the macOS dns machport register error, and the two comment corrections) are in #34697. The Builtin.rs change overlaps with this PR; happy to rebase either way.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found, but this reworks the shell IOWriter's non-pollable → pollable transition (poll re-registration, UnsafeCell state re-derives, sync-error completion routing) and the author flagged an overlapping change in #34697 — worth a human maintainer confirming the merge order and the started=false/__start() restart interaction.

What was reviewed:

  • Builtin.rs now forwards open_for_writing_impl's computed pollable instead of hardcoding false; regular files still get pollable=false from is_pollable_from_mode.
  • do_file_write's new Pending arm records progress before the restart, so get_buffer() returns the unwritten remainder; record_write_progress re-derives state() fresh so no overlapping &mut State.
  • The IsActuallyFile fallback surfaces EAGAIN via on_sync_error (returned to the enqueuing trampoline) rather than looping or panicking.
  • Test is POSIX-gated, pre-fills the FIFO to force EAGAIN on the shell's first write, drains concurrently, asserts exact 'z' count; cleanup via using/try-finally.
Extended reasoning...

Overview

Fixes a deterministic panic when a shell builtin redirects to a FIFO/socket by path (echo big > fifo). Two changes: (1) Builtin.rs stops discarding the pollable out-param from open_for_writing_impl and passes it through to IOWriter::init, so FIFO/socket targets take the poll-driven path from the start; (2) IOWriter::do_file_write replaces the unreachable!() on WriteResult::Pending with an in-place upgrade to the pollable path (record partial write, flip flags.pollable/nonblock, reset started, call write()), degrading to an EAGAIN error via on_sync_error if the fd also refuses epoll registration. A shared record_write_progress helper was extracted per CodeRabbit's nit. A new test in file-io.test.ts pre-fills a FIFO's pipe buffer so the shell's first write hits EAGAIN deterministically, then drains and counts payload bytes.

Security risks

None identified. No untrusted-input parsing, no auth/crypto, no path traversal surface. The change only affects how an already-opened redirect fd is written to.

Level of scrutiny

Medium-high. This is native runtime code in the shell's I/O state machine, sitting on top of UnsafeCell<State> with hand-managed Stacked Borrows discipline (multiple explicit re-derive comments in the surrounding code). The Pending → pollable upgrade introduces a new state transition (started=false after having been true, then __start() re-registers a poll on an fd whose writer.handle was never previously set by the non-pollable path). It looks correct against PosixBufferedWriter::start, and the test exercises it end-to-end, but the interaction between enqueue_file (which sets started=true without touching writer.handle) and the restart deserves a maintainer's confirmation. Given the primary fix in Builtin.rs means FIFOs no longer reach do_file_write in the first place, the upgrade is defense-in-depth — but it's still new control flow in a subtle area.

Other factors

  • The bug hunting system found no issues; CodeRabbit's only comment (dedup the accounting bump) was addressed in 85c00e9.
  • Evidence gate shows the test fails on the unfixed build (panic, exit 134) and passes with the fix on both ASAN debug and release.
  • The author noted overlapping Builtin.rs changes in PR #34697 (error-path keepalive audit) and offered to rebase either way — a human should decide merge order.
  • Test follows harness conventions: tempDir, bunEnv spread, concurrent pipe drain via Promise.all, using/try-finally cleanup, POSIX skip guard, no timing sleeps.
  • Not approving because this is not a simple/mechanical change: it modifies a core I/O state transition in native code with explicit aliasing invariants.

@Jarred-Sumner
Jarred-Sumner merged commit 5a23eb9 into main Jul 19, 2026
79 of 80 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/c8f6757c/shell-fifo-redirect-panic branch July 19, 2026 21:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants