shell(cat): read regular files synchronously in the builtin on POSIX
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 < fileexited 1 with no output: theEPERMfromepoll_ctlreachedCat::on_io_reader_done(ExecStdinarm) and became the exit code.cat filehung: theExecFilepathArgserror arm ofCat::on_io_reader_donegated completion onout_donerather thanchunks_done >= chunks_queued, so an error that arrives before any chunk is queued suspends waiting on writer callbacks that will never fire (and the failedFilePoll'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_chunkcompletion logic incat.rs(covering read errors that arrive after chunks are already queued, which this PR does not touch). The one-lineExecFilepathArgserror-arm change here is a subset of it; whichever of the two lands second needs a small rebase incat.rsandcat.test.ts. - #37752 fixes restarting the shared stdin
IOReaderfor a secondcat. Independent of this PR: regular-file stdin never reachesIOReaderhere, 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)
[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