shell(cat): serve a second cat on the same stdin instead of hanging or panicking
Problem
- The builtin
cat(default on Windows,BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1on POSIX) cannot read stdin twice in one script.cat; echo ---; cathangs on POSIX and panics on Windows (Option::unwrap()onNone). 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. POSIXstart()checksis_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()checkshas_pending_read(), so a later cat reads the fd again, as a systemcatwould: 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 itsYield, so consecutive cats do not nest oneYield::runeach. - 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.$runscatinside the interpreter. All cats in a script share the oneIOReaderof 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
Yieldis the trampoline value: a step returns what runs next andYield::runloops 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