shell(cat): serve a second cat on the same stdin instead of hanging or panicking

Closed
RO
18d ago
+259-47

Problem

  • The builtin cat (default on Windows, BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1 on POSIX) cannot read stdin twice in one script. cat; echo ---; cat hangs on POSIX and panics on Windows (Option::unwrap() on None). After a failed read, cat; (echo ---; cat) panics everywhere: expected Node::Cmd at Node#3, got Subshell (Sentry BUN-4AVS).
  • IOReader (src/runtime/shell/IOReader.rs) keeps notified listeners, so the next read notifies the first cat again under a freed or reused node id. POSIX start() checks is_registered(), which stays true after the one-shot poll fired. Windows closes the source at EOF.

Fix

  • The EOF and error callbacks take the listener list before they notify it. Each listener is notified once.
  • POSIX start() checks has_pending_read(), so a later cat reads the fd again, as a system cat would: EOF again on a pipe, new input on a tty, the same error on a bad fd.
  • Windows start() after EOF completes the new listeners with that EOF. It returns the last completion as its Yield, so consecutive cats do not nest one Yield::run each.
  • Verified: test/js/bun/shell/commands/cat.test.ts (new, 22 cases). Unfixed: 19 fail on Linux, the EOF cases panic on Windows. Fixed: all pass on both, as do the suites in the notes.

Background

  • Bun.$ runs cat inside the interpreter. All cats in a script share the one IOReader of the script's stdin (or of a pipeline stage) and register on it by node id. It sends chunks, then one EOF or error, to each listener.
  • A finished node's id goes to a free list and is reused by later nodes.
  • On POSIX the reader is a one-shot poll, re-armed after each wakeup. On Windows it reads through libuv and closes the source at EOF.
  • A Yield is the trampoline value: a step returns what runs next and Yield::run loops over it.
Notes

Takes over #37752 at a maintainer's request. Its five commits are cherry-picked onto the current main unchanged (the resulting IOReader.rs is identical to a clean merge). The last commit adds the Windows EOF case, which #37752 had left to #29986. #29986 drains the list under a re-entrancy flag and completes late registrants from inside the drain. That does not fit the POSIX restart (a listener that registers during a notification now has a read of its own), so the Windows case is handled in start() instead, keyed on a reached_eof flag that the done callback sets before it notifies anyone.

The stored error that a later EOF used to replay is removed: a read ends in exactly one of EOF or error, and after an error the listeners are gone.

Why the last completion is returned: with captured output, cat N+1 starts from inside cat N's completion. Running its completion with run_yield there nests one Yield::run per cat. With the six-cat case a Windows debug build of that variant panics with assertion failed: n <= Self::MAX_DEPTH (captured and inherited stdout). Returned as the start() Yield, the completions follow each other on the trampoline.

After a read error on Windows the source stays open, so a later cat restarts the read and gets the error again. That path already worked and the last two cases cover it on every platform.

The POSIX restart from inside an EOF notification is safe with respect to PosixBufferedReader: read_loop (src/io/PipeReader.rs) dispatches done() and on_error() in tail position and copies the vtable out first, so nothing touches the reader after the callback re-armed it. The /dev/full cases cover a second cat that registers while the read that served the first cat is still running: the poll is re-registered once more and the extra wakeup either serves a third cat or reads EOF with nobody to notify.

The tty case distinguishes "read the fd again" from "complete on the spot" on POSIX: the second cat only prints more if it reads again. On Windows the fd is closed at the first EOF, so that case stays POSIX only, as do the sh -c pipe case and the /dev/full cases.

Suites run on a Linux debug build: cat.test.ts, bunshell.test.ts, pipeline_stack.test.ts, file-io.test.ts, yield.test.ts, shell-pipe-read-fault.test.ts, shell-blocking-pipe.test.ts. On a Windows debug build: cat.test.ts, bunshell.test.ts, pipeline_stack.test.ts, file-io.test.ts (the five tilde expansion cases fail on that machine with and without this change, it has no HOME set).

Fail-before on Linux was measured with the unfixed release build: the 18 hanging cases time out, cat || echo first-failed; (echo ---; cat) || echo second-failed panics with the BUN-4AVS message, and the other three cases pass and act as guards. On the Windows canary (32e87032b) the EOF cases panic as described and the subshell read-error case panics with the BUN-4AVS message.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/shell/commands/cat.test.ts