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

RO
3mo ago
+345-101

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

  • #37743 reworks the same on_io_reader_done / on_io_writer_chunk completion logic in cat.rs (covering read errors that arrive after chunks are already queued, which this PR does not touch). The one-line ExecFilepathArgs error-arm change here is a subset of it; whichever of the two lands second needs a small rebase in cat.rs and cat.test.ts.
  • #37752 fixes restarting the shared stdin IOReader for a second cat. Independent of this PR: regular-file stdin never reaches IOReader here, and non-regular stdin is unchanged.

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)

   Compiling bun_simdutf_sys v0.0.0 (/workspace/bun/src/simdutf_sys)
   Compiling bun_core v0.0.0 (/workspace/bun/src/bun_core)
   Compiling bun_errno v0.0.0 (/workspace/bun/src/errno)
   Compiling bun_ptr v0.0.0 (/workspace/bun/src/ptr)
   Compiling 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