Skip to content

shell(cat): read regular files synchronously in the builtin on POSIX - #35337

Open
robobun wants to merge 1 commit into
mainfrom
farm/0ea30161/shell-ioreader-regular-file
Open

shell(cat): read regular files synchronously in the builtin on POSIX#35337
robobun wants to merge 1 commit into
mainfrom
farm/0ea30161/shell-ioreader-regular-file

Conversation

@robobun

@robobun robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

On POSIX, IOReader::start() always registers the fd with epoll. Linux returns EPERM for regular files, which register_poll() surfaces through on_reader_error as a spurious read failure.

With BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1 (so cat runs as a builtin rather than a subprocess on POSIX):

$ echo SRC > /tmp/s.txt
$ BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1 bun -e \
    'import {$} from "bun"; const r = await $`cat < /tmp/s.txt`.nothrow(); console.log(r.exitCode, r.stdout.toString())'
1 ""
# and `cat /tmp/s.txt` hangs forever

Two things were going on:

  • cat < file exited 1 with no output: the EPERM from epoll_ctl reached Cat::on_io_reader_done (ExecStdin arm) and became the exit code.
  • cat file hung: the ExecFilepathArgs error arm of Cat::on_io_reader_done gated completion on out_done rather than chunks_done >= chunks_queued, so an error that arrives before any chunk is queued suspends waiting on writer callbacks that will never fire (and the failed FilePoll's keep-alive pin kept the process up).

Builtin cat is in Kind::DISABLED_ON_POSIX, so the broken path was only reachable via the env flag (or on Windows, where reads go through libuv and this branch isn't taken). The same hardcoded is_pollable = true is in the pre-Rust IOReader.zig, so this has never worked.

Fix

Cat::next now fstats the fd before handing it to IOReader::start. For S_IFREG only, it reads the file to EOF with bun_sys::read and reuses the existing in-memory-stdin shape (write_buf_to_stdout: one chunk enqueued on fd-backed stdout, write_no_io otherwise), so completion is returned to the enclosing Yield::run trampoline instead of being driven inline. This is the read-side analogue of IOWriter::do_file_write.

Pipes, sockets, TTYs, and character devices keep the unchanged IOReader::start() path, so echo x | cat and cat < /dev/zero behave exactly as before. Cat::next's Branch::FileArg is wrapped in a loop so many file arguments with captured stdout iterate rather than recurse. The ExecFilepathArgs error arm is aligned with the ExecStdin arm so a read error before any chunk is queued finishes rather than suspending.

Why is this fix correct?

read(2) on a regular file is finite and never returns EAGAIN, so the synchronous slurp is bounded. It hands one buffer to the same enqueue/write_no_io path the existing !stdin_needs_io branch already uses, so the downstream state machine is unchanged; each file becomes either one Yield::OnIoWriterChunk trampoline iteration (fd-backed stdout) or one loop iteration inside Cat::next (captured stdout), and sequential commands stay at DbgDepthGuard depth 1. Windows returns None from slurp_regular_file and is untouched.

Related

How did you verify your code works?

Added test/js/bun/shell/commands/cat.test.ts, which spawns a child with BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1 so the builtin path is taken on every platform. It covers cat file, cat < file, multi-file args, a multi-chunk 300 KB file, echo | cat (pollable control), cat file > out, four sequential cat commands (captured and redirected), an empty file, a directory argument with fd-backed stdout (pins the error-arm change), and a 100-file-argument case.

USE_SYSTEM_BUN=1 bun test test/js/bun/shell/commands/cat.test.ts   # 1 pass, 10 fail (arg cases time out)
bun bd test test/js/bun/shell/commands/cat.test.ts                 # 11 pass
bun bd test test/js/bun/shell/bunshell.test.ts                     # 418 pass, 0 fail

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

fails on main (without fix)
ASAN without fix: 10 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/commands/cat.test.ts
bun test v1.4.0 (fa328bcc1)

test/js/bun/shell/commands/cat.test.ts:
35 | 
36 |   test("reads a regular file via stdin redirect", async () => {
37 |     using dir = tempDir("shell-cat-stdin", { "a.txt": "hello from stdin\n" });
38 |     const { stdout, stderr, exitCode } = await runShell(String(dir), "cat < a.txt");
39 |     expect(stderr).toBe("");
40 |     expect(stdout).toBe("hello from stdin\n");
                        ^
error: expect(received).toBe(expected)

- "hello from stdin
- "
+ ""

- Expected  - 2
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/bun/shell/commands/cat.test.ts:40:20)
(fail) cat (builtin) > reads a regular file via stdin redirect [1274.29ms]
55 |       "a.txt": "first\n",
56 |       "b.txt": "second\n",
57 |     });
58 |     const { stdout, stderr, exitCode } = await runShell(String(dir), "cat a.txt b.txt");
59 |     expect(stderr).toBe("");
60 |     expect(stdout).toBe("first\nsecond\n");
                        ^
error: expect(received).toBe(expected)

- 
... (truncated)

release without fix: 10 FAILED
bun test v1.4.0-canary.1 (da3851e57)

test/js/bun/shell/commands/cat.test.ts:
27 | describe.concurrent("cat (builtin)", () => {
28 |   test("reads a regular file given as an argument", async () => {
29 |     using dir = tempDir("shell-cat-arg", { "a.txt": "hello from a\n" });
30 |     const { stdout, stderr, exitCode } = await runShell(String(dir), "cat a.txt");
31 |     expect(stderr).toBe("");
32 |     expect(stdout).toBe("hello from a\n");
                        ^
error: expect(received).toBe(expected)

- "hello from a
- "
+ ""

- Expected  - 2
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/bun/shell/commands/cat.test.ts:32:20)
(fail) cat (builtin) > reads a regular file given as an argument [24.34ms]
55 |       "a.txt": "first\n",
56 |       "b.txt": "second\n",
57 |     });
58 |     const { stdout, stderr, exitCode } = await runShell(String(dir), "cat a.txt b.txt");
59 |     expect(stderr).toBe("");
60 |     expect(stdout).toBe("first\nsecond\n");
                        ^
error: expect(received).toBe(expected)

- "first
- second
- "
+ ""

- Expected  - 3
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/bun/shell/commands/cat.test.t
... (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/commands/cat.test.ts
bun test v1.4.0 (fa328bcc1)

test/js/bun/shell/commands/cat.test.ts:
(pass) cat (builtin) > reads a regular file given as an argument [1253.56ms]
(pass) cat (builtin) > reads a regular file via stdin redirect [1221.44ms]
(pass) cat (builtin) > concatenates multiple file arguments [1219.92ms]
(pass) cat (builtin) > empty regular file [1256.61ms]
(pass) cat (builtin) > reads a multi-chunk regular file [1254.61ms]
(pass) cat (builtin) > still works through a pipe (pollable stdin) [1227.23ms]
(pass) cat (builtin) > sequential cat commands [1266.05ms]
(pass) cat (builtin) > file redirect out still works [1296.06ms]
(pass) cat (builtin) > directory as file argument with fd stdout does not hang [1221.06ms]
(pass) cat (builtin) > sequential cat redirected to files [1249.79ms]
(pass) cat (builtin) > many file arguments [1299.89ms]

 11 pass
 0 fail
 38 expect() calls
Ran 11 tests across 1 file. [6.18s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 994ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/46] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[2/46] gen cpp.rs (cppbind)
[3/46] gen JS modules (bundle-modules)
Preprocess modules (9306ms)
Bundle modules (46ms)
Postprocesss modules (240ms)
Bundle Functions (803ms)
Generate Code (43ms)

[10.46s] Bundled "src/js" for production
  2625 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[3/37] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_simdutf_sys v0.0.0 (/workspace/bun/src/simdutf_sys)
�[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�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sy
... (truncated)
diff hotspot
src/runtime/shell/builtin/cat.rs       | 295 ++++++++++++++++++++++-----------
 test/js/bun/shell/commands/cat.test.ts | 151 +++++++++++++++++
 2 files changed, 345 insertions(+), 101 deletions(-)

gate history · 2 passed · 0 rejected · iteration 3

evidence per changed file
file                                    reads  edits  tests
src/runtime/shell/builtin/cat.rs            7     15      0
test/js/bun/shell/commands/cat.test.ts      3     10      0

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Cat builtin I/O flow

Layer / File(s) Summary
Regular-file reading and stdout chunking
src/runtime/shell/builtin/cat.rs
Adds synchronous regular-file slurping, complete-buffer stdout writes, retryable-read handling, and allocation error conversion.
Cat execution and completion
src/runtime/shell/builtin/cat.rs
Refactors stdin and file dispatch, resets per-file state, preserves asynchronous handling for non-regular inputs, and waits for queued output after reader errors.
Shell behavior coverage
test/js/bun/shell/commands/cat.test.ts
Adds subprocess tests for files, stdin, pipes, redirection, sequential commands, large inputs, empty files, many arguments, and directory input.

Possibly related PRs

  • oven-sh/bun#37743: Extends the same cat.rs reader-error and queued-output completion logic.
  • oven-sh/bun#37752: Modifies related cat stdin-reading and sequential-command behavior.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely describes the main POSIX cat change: synchronous reading of regular files.
Description check ✅ Passed The description includes both required sections and provides detailed rationale, implementation details, and verification results.

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

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:11 PM PT - Aug 12th, 2026

@robobun, your commit fa328bc has 5 failures in Build #93576 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35337

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

bun-35337 --bun

Comment thread src/runtime/shell/IOReader.rs Outdated
Comment thread src/runtime/shell/IOReader.rs Outdated
Comment thread src/runtime/shell/builtin/cat.rs
@robobun robobun changed the title shell(cat): don't poll regular files in builtin IOReader on POSIX shell(cat): read regular files synchronously in the builtin on POSIX Jul 24, 2026

@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: 2

🤖 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/builtin/cat.rs`:
- Around line 471-516: Replace the whole-file buffering in slurp_regular_file
with bounded chunk reads and incremental writes through the existing
caller/writer path, mirroring IOReader’s streaming behavior. Ensure regular
files do not create a full-file Vec before write_buf_to_stdout enqueues data,
while preserving EOF handling and catchable ENOMEM propagation.

In `@test/js/bun/shell/commands/cat.test.ts`:
- Around line 27-125: Extend the cat (builtin) suite with tests for an empty
regular file and a non-regular file argument such as /dev/null. Use runShell and
assert the command completes successfully with empty stdout and stderr, covering
both the zero-length regular-file path and the IOReader fallback.
🪄 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: 7be27ae8-4a1a-4558-8d7b-7816ec9a82e7

📥 Commits

Reviewing files that changed from the base of the PR and between 43372bd and 3a27e4f.

📒 Files selected for processing (2)
  • src/runtime/shell/builtin/cat.rs
  • test/js/bun/shell/commands/cat.test.ts

Comment thread src/runtime/shell/builtin/cat.rs Outdated
Comment thread test/js/bun/shell/commands/cat.test.ts
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 9b10466: the new cat.test.ts passes on every lane. Remaining red is test/js/bun/spawn/spawn.test.ts on ubuntu/debian x64, which is a pre-existing break on main (already reported for triage) and unrelated to this diff; everything else passed on retry. Ready for review.

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

LGTM — the redesign addresses the earlier recursion/char-device concerns by keeping the slurp inside Cat::next and gating on S_IFREG only.

What was reviewed:

  • fd ownership in both arms: stdin fd stays owned by Builtin::stdin; the file-arg fd is closed after slurp or handed to IOReader::init.
  • Empty-buffer path: IOWriter::enqueue short-circuits to Yield::OnIoWriterChunk on buf.is_empty(), so the empty-file → fd-stdout case completes.
  • The loop in Cat::next only iterates for file-arg + captured stdout (write_buf_to_stdoutNonecontinue); every other branch returns a Yield to the enclosing trampoline, so the 100-arg and 4-sequential-command tests stay at depth 1.
  • slurp_regular_file uses try_reserve (ENOMEM as a catchable error, not a panic), is_retry() for EINTR, and the bun_core::vec spare-capacity helpers with matching SAFETY contracts.
Extended reasoning...

Overview

This PR fixes the experimental builtin cat on POSIX, which previously either exited 1 (cat < file) or hung forever (cat file) because IOReader::start() unconditionally registers the fd with epoll and Linux returns EPERM for regular files. The fix adds slurp_regular_file() in src/runtime/shell/builtin/cat.rs: on POSIX, if fstat reports S_IFREG, the file is read to EOF with bun_sys::read and handed to the existing whole-buffer output path (write_buf_to_stdout, extracted from the pre-existing !stdin_needs_io shape). Non-regular fds and Windows fall through to the unchanged IOReader::start() path. A secondary fix aligns the ExecFilepathArgs error arm of on_io_reader_done with the ExecStdin arm so a read error before any chunk is queued finishes rather than suspending. Cat::next's file-arg branch is wrapped in a loop so many file args with captured stdout iterate rather than recurse.

The only files touched are src/runtime/shell/builtin/cat.rs and a new test/js/bun/shell/commands/cat.test.ts (11 subprocess tests, all under describe.concurrent).

Prior review round

My earlier review was on a different design that modified IOReader::start() to call r.read() synchronously for non-pollable fds. That had two real problems: (1) completion callbacks fired inside the caller's stack frame, causing per-file native recursion / nested Yield::run trampolines; (2) is_pollable() returns false for char devices, so cat /dev/zero would spin forever. Both were addressed in 65869f4 by reverting the IOReader change and moving the slurp into Cat::next, gated strictly on S_IFREG. The added "sequential cat commands" (4 cmds), "sequential cat redirected to files" (3 cmds), and "many file arguments" (100 files) tests directly cover the recursion shapes I flagged. The directory-arg test (393d59b) covers the chunks_done >= chunks_queued error-arm clause: on Linux a directory opens O_RDONLY, ISREG is false so it falls through to IOReader::start, epoll registration fails, and on_io_reader_done fires with errno != 0, chunks_queued == 0, fd-backed stdout — exactly the previously-hanging path.

Security risks

None. This is read-side I/O in a shell builtin gated behind BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1 (Kind::DISABLED_ON_POSIX). No parsing of untrusted headers, no size arithmetic on adversarial input beyond usize::try_from(st.st_size).unwrap_or(0) with saturating_add and try_reserve (OOM surfaces as catchable ENOMEM, not a panic). The two unsafe blocks wrap bun_core::vec::spare_bytes_mut/commit_spare around bun_sys::read, whose contracts I checked in bun_core/lib.rs:468/:514 — the SAFETY comments match.

Level of scrutiny

Low-to-medium. The builtin is disabled on POSIX by default (delegates to system cat), so this only affects users who set the experimental env flag, and Windows (where slurp_regular_file returns None). The change reuses existing machinery: write_buf_to_stdout is the pre-existing in-memory-stdin path extracted into a helper, and the IOWriter::enqueue empty-buffer short-circuit at IOWriter.rs:1033 means the empty-file → fd-stdout case returns Yield::OnIoWriterChunk immediately and completes via on_io_writer_chunk. The known limitation (whole-file buffering, no streaming/backpressure) was raised by CodeRabbit and correctly rebutted as not a regression — this path never worked before; bounded streaming is a follow-up before the DISABLED_ON_POSIX gate can be lifted.

Other factors

fd ownership traced through both arms: the stdin arm reads via reader.fd() and leaves ownership with Builtin::stdin (comment at cat.rs states this; no close is issued). The file-arg arm owns the fd from shell_openat, closes it after slurp_regular_file returns Some, and hands it to IOReader::init on None. The per-file bookkeeping reset (chunks_done/queued, in_done/out_done) was moved earlier so it applies before the slurp path too. Tests follow harness conventions (spread bunEnv, tempDir with using, drain pipes concurrently, assert stderr/stdout before exit code, describe.concurrent). The evidence block confirms 11 pass with fix / 10 fail without on both ASAN-debug and release.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun rebase

@robobun
robobun force-pushed the farm/0ea30161/shell-ioreader-regular-file branch from 9b10466 to ecd40d7 Compare August 12, 2026 20:28
Comment thread src/runtime/shell/builtin/cat.rs Outdated
Comment thread src/runtime/shell/builtin/cat.rs
Comment thread src/runtime/shell/builtin/cat.rs
Comment thread src/runtime/shell/builtin/cat.rs
Comment thread src/runtime/shell/builtin/cat.rs Outdated
Comment thread src/runtime/shell/builtin/cat.rs Outdated
Comment thread src/runtime/shell/builtin/cat.rs Outdated
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (ecd40d7), squashed to a single commit. Three conflict hunks in cat.rs from #36184 (pub(crate) visibility) and #37229 (removed CatState::Done), resolved by keeping main's versions. Rebuilt and re-ran: cat.test.ts 11/11, bunshell.test.ts 418 pass / 0 fail, rust:check-all clean on all targets.

@robobun
robobun force-pushed the farm/0ea30161/shell-ioreader-regular-file branch from ecd40d7 to 7ca0e91 Compare August 12, 2026 20:33
Comment thread src/runtime/shell/builtin/cat.rs Outdated
@robobun
robobun force-pushed the farm/0ea30161/shell-ioreader-regular-file branch from 7ca0e91 to dcc885a Compare August 12, 2026 20:35
Comment thread src/runtime/shell/builtin/cat.rs Outdated
@robobun
robobun force-pushed the farm/0ea30161/shell-ioreader-regular-file branch from dcc885a to 472d7f5 Compare August 12, 2026 20:37
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased and squashed to 472d7f5. The last three CI builds (93556, 93559, 93563) all failed in the build step because the runners could not download vendored deps (WebKit, mimalloc, c-ares, lol-html: "Failed to download after 5 attempts"); every recent build on the pipeline including main (93528) is failing the same way, so this is an infra outage rather than anything in the diff. Locally on the rebased branch: cat.test.ts 11/11, bunshell.test.ts 418 pass / 0 fail, rust:check-all clean. Will leave it here until CI recovers; a re-run on 472d7f5 should be enough once downloads work again.

@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: 2

🤖 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/builtin/cat.rs`:
- Around line 458-461: Update the fstat handling in the surrounding cat
descriptor setup to propagate syscall failures instead of converting them to
None. Reserve None for successful fstat results whose mode is not regular, and
preserve the existing regular-file path and caller error reporting.

In `@test/js/bun/shell/commands/cat.test.ts`:
- Around line 124-128: Strengthen the test “directory as file argument with fd
stdout does not hang” by capturing stderr and reading the redirected out.txt
after runShell. Assert the platform-specific cat error text and expected nonzero
exit status, and verify out.txt is empty, so the test specifically validates the
directory-input failure and redirect behavior rather than any arbitrary command
failure.
🪄 Autofix

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: 7813261e-b83d-4d60-9cad-02a3a9c4a653

📥 Commits

Reviewing files that changed from the base of the PR and between 315136d and 472d7f5.

📒 Files selected for processing (2)
  • src/runtime/shell/builtin/cat.rs
  • test/js/bun/shell/commands/cat.test.ts

Comment thread src/runtime/shell/builtin/cat.rs Outdated
Comment thread test/js/bun/shell/commands/cat.test.ts
Comment thread test/js/bun/shell/commands/cat.test.ts
IOReader::start() on POSIX always registers the fd with epoll. Linux
returns EPERM for regular files, which register_poll() surfaces through
on_reader_error as a spurious read failure. With
BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1 (so cat runs as a builtin):

- `cat < file` exited 1 with no output (EPERM became the exit code)
- `cat file` hung forever: the ExecFilepathArgs error arm of
  Cat::on_io_reader_done gated completion on out_done instead of
  chunks_done >= chunks_queued, so an error before any chunk was queued
  suspended waiting on writer callbacks that never fire

Cat::next now fstat()s the fd before handing it to IOReader::start. For
S_IFREG only, it reads the file to EOF and reuses the existing
in-memory-stdin shape (one enqueue on fd-backed stdout, write_no_io
otherwise), so completion is returned to the enclosing trampoline
rather than driven inline. Pipes, sockets, TTYs, and character devices
keep the unchanged IOReader path. The file-arg branch is wrapped in a
loop so many arguments with captured stdout iterate instead of
recursing. The ExecFilepathArgs error arm is aligned with ExecStdin.
@robobun
robobun force-pushed the farm/0ea30161/shell-ioreader-regular-file branch from 472d7f5 to fa328bc Compare August 12, 2026 20:57

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

LGTM — the earlier assertion-ordering nit and the two coderabbit points (fstat error propagation, directory-test strengthening) are all addressed in fa328bc.

What was reviewed:

  • fd ownership across the new slurp path: file-arg fd is closed after slurp, stdin fd stays owned by Builtin::stdin, and the None branch still hands ownership to IOReader::init — no leak or double-close.
  • write_buf_to_stdout now increments chunks_queued where the old !stdin_needs_io branch did not; traced on_io_writer_chunk for both ExecStdin and ExecFilepathArgs and the completion condition is preserved.
  • State-counter reset moved before shell_openat: equivalent for the IOReader path, and required for the slurp path so the increments land on zeroed counters.
  • Windows: slurp_regular_file returns None so the loop never continues; the only behavior change is the on_io_reader_done hang fix, which is a strict improvement.
Extended reasoning...

Overview

Two files: src/runtime/shell/builtin/cat.rs (~200 lines net) and a new test/js/bun/shell/commands/cat.test.ts (11 tests). The Rust change (1) adds slurp_regular_file, an fstat-gated synchronous read for S_IFREG fds on POSIX, (2) extracts the one-buffer stdout write into write_buf_to_stdout, (3) wraps Cat::next's FileArg handling in a loop so many captured-stdout file args iterate rather than recurse, and (4) adds chunks_done >= chunks_queued to the ExecFilepathArgs error arm of on_io_reader_done so a reader error before any chunk is queued completes instead of suspending.

Security risks

None. The change reads regular files the shell script already named; there is no new path handling, no external input parsing beyond what shell_openat already did, and the two new unsafe blocks are the standard spare_bytes_mut/commit_spare read-into-uninit-capacity idiom with accurate SAFETY comments. Allocation goes through try_reserve and surfaces ENOMEM as a catchable shell error rather than aborting.

Level of scrutiny

Moderate for the diff itself (state machine + fd lifecycle), but the blast radius is small: builtin cat is in Kind::DISABLED_ON_POSIX (Builtin.rs:210-215), so on POSIX the whole file is only reachable via BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1. On Windows slurp_regular_file returns None, so the only production-visible change there is the on_io_reader_done hang fix and the behavior-preserving refactor around it. I traced each Windows-reachable change: the chunks_queued increment in write_buf_to_stdout keeps on_io_writer_chunk at Done (0≥0 → 1≥1), the loop is always single-iteration, and moving the counter reset before shell_openat is equivalent because a failed open transitions to WaitingWriteErr and never reads the counters.

Other factors

This PR has been through three review rounds. The initial IOReader change was backed out per review; comment-cop's paragraph-comment flags were tightened; coderabbit's whole-file-buffering concern was withdrawn (there was no working streaming baseline to regress from) and its two remaining points (propagate fstat error, strengthen the directory test) landed in fa328bc; my prior exitCode-ordering nit was also fixed there. The test suite is comprehensive (arg, stdin redirect, empty, multi-arg, 300 KB, pipe control, redirect-out, sequential ×2, directory error, 100 args), spawns with the env flag so it exercises the builtin on every platform, and was verified fails-before/passes-after on both ASAN and release. No bugs were found in the current bug-hunting pass. No CODEOWNERS entry for src/runtime/shell/.

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