shell(cat): finish after a read error instead of cancelling the queued output and hanging

RO
1mo ago
+487-144

Problem

  • The shell's builtin cat hangs forever when its input fails to read while output is still queued on stdout. The $ promise never settles. POSIX needs BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1. Windows always uses the builtin.
  • Cause: on a read error cat cancelled its queued chunks and waited (cat.rs, on_io_reader_done). A cancelled chunk completes without a callback, so nothing finished the command.
  • A reader that cannot start was completed from inside the start call. true | cat some_dir dies with panic: expected Node::Pipeline at Node#2, got Free.

Fix

  • A read error ends the input like EOF, plus an errno. The queued output drains, then the command finishes with that errno. Cancelling the queue instead would drop bytes cat already read. The exit code stays the errno (#32278).
  • IOReader::start returns a start failure instead of dispatching it. cat finishes from its own frame, the shape IOWriter::on_sync_error already uses.
  • Verified: test/js/bun/shell/commands/cat.test.ts (new, every error case times out on main), shell-pipe-read-fault.test.ts, yield.test.ts, bunshell.test.ts.

Background

  • IOReader is the shell's async input reader. It hands cat data, then EOF or an error. Epoll refusing a directory surfaces synchronously from inside its start call.
  • IOWriter is the shared writer for a builtin's stdout. It calls cat back once per written chunk.
  • A shell step returns a Yield that a driver loop runs. Completing a command from inside another step frees a node the driver still holds: the panic above.

Downsides

  • Regular files still cannot be read by the builtin cat on Linux (epoll refuses them). The failure is now a clean non-zero exit instead of a hang or panic. #35337 adds the file read.
  • A second cat on the shell's shared stdin still hangs after the first reached EOF. #39638 fixes that listener-list defect.
Notes

Tracked siblings

  • #39638 (open): a later cat on the same stdin reader is never served, and notified listeners linger in IOReader::readers under a freed node id. It re-reads the fd on a new listener and drains the listener list in the reader. This PR leaves both alone so the two changes do not compete in IOReader::start.
  • #37719 changes the write-error side of the same builtin (IOWriter failing every queued chunk) and leaves this read-side path as is.
  • #35337 makes the builtin read regular files synchronously and keeps the cancel-and-suspend branch for queued chunks.

Verification detail

  • Without the first commit, every error case times out. With only the first, the pipeline cases crash on the panic above and the sequence cases trip a re-entrancy assertion (the debug build asserts at three nested trampolines). With both, the three files pass on a Linux debug build and loop without flakes.
  • bunshell.test.ts with BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1: the same 5 failures before and after, all regular-file reads through the builtin (#35337). redirect Bun.File turns from a 5 s timeout into an immediate failure.
  • cargo clippy -p bun_runtime clean. cargo check -p bun_runtime --target x86_64-pc-windows-msvc passes. For Windows the crate was compiled, not run.

Repro (Linux)

The child's stdin is a pty master whose slave wrote some bytes and closed, so read() returns the bytes and then EIO:

// parent: openpty(); write(slave, "read before the error"); close(slave);
//         Bun.spawn([bun, "-e", script], { stdin: master, env: { BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS: "1" } })
const r = await $`cat`.nothrow();
console.log(r.exitCode, r.stdout.toString());

Before: nothing is printed and the process stays alive until it is killed. With .quiet() (no IOWriter involved) the same script prints 5 read before the error, which is also what the non-quiet form prints after this change.

Cause in detail

Cat::on_io_reader_done, in both the stdin and the file-argument state: when the reader reports an error and chunks_done < chunks_queued, it called cancel_chunks on stdout and returned Suspend. IOWriter::bump completes a cancelled chunk with Yield::done() and never calls the child's on_io_writer_chunk, so chunks_done never advanced. With stdout on a pipe or tty, a chunk read in the same poll wake as the error is always still queued. The file-argument state checked out_done, which only a completed chunk ever sets, so a read error before the first chunk hung as well. The Zig version had the same logic.

out_done is removed: once set it stayed set, so a chunk queued after it could still be in flight when the reader finished and cat moved on to the next file.

Start-time failures

When the reader cannot start, bun_io reports the error synchronously from inside IOReader::start(), and IOReader dispatched cat's completion right there, nested inside the trampoline frame that was starting the command. That nested completion tears down the pipeline node the outer trampoline still holds. cat some_dir; cat some_dir; cat some_dir nested one trampoline per statement. true | cat < regular_file panics the same way on main. IOReader::start() now returns the failure (an error reported through on_reader_error while start() is on the stack is parked and returned too), and Cat::start_reader finishes the input in tail position. IOReader::start() has no other callers.

Tests

  • cat.test.ts: EOF through a pipe; the pty read error with nothing queued, with the data still queued, and with an 8 KiB payload (three reads in the wake that delivers the error, so three chunk completions have to be counted); cat <directory> alone, in pipelines, and four times in a row followed by echo. The directory case asserts the exact errno (EPERM) on Linux.
  • yield.test.ts: the read-side counterpart of "synchronous write errors in sequential statements".
  • shell-pipe-read-fault.test.ts: the LD_PRELOAD shim gains SHELL_FAIL_EPOLL_REARM_INO and ..._REARM_FROM, which fail the poll re-arm of the FIFO the fixture hands to cat. cat fifo and cat < fifo must print the chunk and exit with ENOMEM. A third case fails the re-arm one wake later, with one chunk completed and another queued; a sticky "drained" flag reports only the first chunk, the counter waits for the second.

no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/shell/yield.test.ts, test/js/bun/shell/shell-pipe-read-fault.test.ts, test/js/bun/shell/commands/cat.test.ts