shell(cat): restart the shared stdin reader for a later cat, notify each listener once
Problem
- With the builtin
cat(BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1on POSIX, the default on Windows), a script that reads stdin with two cats, such ascat; echo ---; cat, prints the first cat's output and then hangs forever. A real shell runs the second cat to immediate EOF and moves on. - When the first cat's read failed and the second cat is inside a subshell (
cat; (echo ---; cat)), bun crashes instead:panic: expected Node::Cmd at Node#2, got Subshell. - The hang: the shared stdin reader decided whether to start a new read by asking whether its poll was registered. A finished read (EOF or error) leaves the one-shot poll registered but already fired, so the second cat was left waiting for a wakeup that never comes.
- The panic: a cat that had been notified stayed in the reader's listener list, so the next read notified it again, by then under a node id that had been freed or reused by another node.
Fix
- The POSIX reader starts a new read whenever no read is armed, so a cat that arrives after a finished read reads the fd again, as a system
catinheriting the fd would: EOF again on a pipe, more input on a tty, the same error on a failing fd. Windows is unchanged; a second cat there needs #29986. - The EOF and error callbacks take the listener list out before notifying it, so each listener is notified exactly once with the outcome of the read it registered for, and a cat started from inside a notification registers into the empty list and gets a read of its own.
- The stored error that a later EOF used to replay to the listeners is removed: a read ends in exactly one of error or EOF, and after an error nobody is left to replay it to.
- Verification: a new test file with 20 cases, each run in a child bun. Before the fix 17 fail on Linux (16 hang, one panics); all 20 pass with it. The read-error subshell case only passes with the listener change and the EOF cases only with the restart change. The read-error cases were also checked on a Windows debug build.
Background
Bun.$is bun's shell.catcan run as a builtin inside the interpreter instead of as a subprocess (behind the flag on POSIX, always on Windows), so every cat in a script reads stdin through the interpreter rather than through its own process.IOReader(src/runtime/shell/IOReader.rs) wraps one readable fd for a whole script, either the script's stdin or the stdin a pipeline creates for a stage. A builtin that wants input registers as a listener; the reader passes chunks and the final EOF or error to every listener.- On POSIX the reader is driven by a one-shot poll: it fires once per arm and must be re-registered to fire again, so "registered" does not mean "a read is in flight".
has_pending_read()(is the poll being watched) means that, andIOWriteralready uses the same check on its writable poll. - Listeners are recorded by interpreter node id. Ids are released when a command finishes and can be handed to later nodes, so dispatching to a stale entry reaches whatever node holds that id now.
- Captured stdout (
.quiet()) versus inherited stdout decides when the next cat registers: with captured output the first cat's completion runs the rest of the script synchronously, inside the reader's callback; with inherited output the next command starts later, from a write callback. The tests cover both.
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
Original description
Repro
Builtin cat (POSIX: BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1), two of them reading the same stdin in one script:
printf 'hi\n' | BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1 bun -e 'console.log((await Bun.$`cat; echo ---; cat`.nothrow()).exitCode)'
# prints "hi" and "---", then hangs forever (same with .quiet())
BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1 bun -e 'await Bun.$`cat; (echo ---; cat)`.nothrow()' < /etc/hostname
# (stdin is a regular file, so the first cat fails with EPERM from epoll and the second one restarts the reader)
# panic: expected Node::Cmd at Node#2, got Subshell
A real shell runs the second cat to immediate EOF (or to the same read error) and moves on. The subprocess cat, and two separate $ calls, both behave; the bug needs two builtin cats on the one IOReader a script has for its stdin (the root stdin reader, or the reader a pipeline creates for a stage's stdin).
Cause
Two things in src/runtime/shell/IOReader.rs:
-
start()decided whether to (re)start the read withFilePoll::is_registered(). The reader's poll is one-shot; after it fires,PollReadablestays set andNeedsRearmis set until somebody re-registers. A read that ends in EOF or an error does not re-register, so that is exactly the state a finished read leaves behind:is_registered()is true, the poll will never fire again, and the second cat'sstart()returnssuspendedwithout doing anything.IOWriter::write()already documents and avoids the same trap on the writable poll (it usesis_watching()). -
on_reader_done_cb/on_reader_erroriterated a clone ofreadersand left the entries in place, and nothing removed a cat that had been notified. Once a restart does happen (today already on the error path, since a poll that failed to register is not registered; after (1) also on the EOF path), the next read notifies the previous cat's entry again. By then itsNodeIdhas been freed, or reused by whatever node was allocated next, hence the panic above (cat; echo ---; catonly survived because the second cat happened to get the first one's slot back andadd_reader's dedup matched the stale entry).
Fix
start()keys offBufferedReader::has_pending_read()(handleis a poll and itis_watching()), which is false for a never-registered, failed, or fired-and-not-re-armed poll and true while a read is armed. A listener that arrives after a finished read gets a new read on the same fd, which is what the systemcatdoes with the inherited fd: a pipe or socket at EOF reports EOF again right away, a tty waits for more input, a failing fd fails again with its own errno. Listeners that arrive while a read is armed attach to it as before.- The done/error callbacks take the listener list (
take_readers) before notifying it. Each listener is notified exactly once, with the outcome of the read it registered for. A cat started synchronously from inside a notification (with captured output the first cat's completion runs the rest of the script on the spot) registers into the emptied list and restarts the reader, so it is served by its own read instead of being completed by the notification loop that is still running; nothing stays behind to be dispatched under a deadNodeId. This makesState::raw_errdead. It existed so that adonefollowing an error could hand the error to the listeners again, but a read reports one or the other: on POSIX every read loop inPipeReader.rsends in exactly one ofon_error()(registration failure or read error) ordone()(EOF), both in tail position; on WindowsWindowsBufferedReader::on_readreturns right afteron_error()for an error and only reachesclose()/done()for EOF. The one way adonecan still follow an error is a Windows VM teardown closing the source of a reader that already failed, and by then the error notification has detached every listener, so there is nothing to carry the error to (before this change that teardowndonewould have re-notified the already finished cats). A listener added after an error is waiting on a new read whose outcome is its own.
Restarting from inside the EOF notification is safe with respect to the bun_io reader: every done() / on_error() dispatch in PosixBufferedReader is in tail position and copies the vtable out first, so nothing in the read loop touches the reader after the callback has re-registered it. When the second cat registers while a read is still running (first cat killed by a stdout write error in the middle of a chunk), has_pending_read() is false as well and the poll gets re-registered once more. That read still serves the new listener; the re-registration then produces one more wakeup, which either serves a cat that registered in the meantime (a third cat started from the second one's EOF notification finds has_pending_read() true and waits for it) or reads EOF again with nobody to notify. The /dev/full cases below cover both outcomes.
start() on Windows is unchanged: there the reader closes its libuv source at EOF, so a second cat needs a different answer, which #29986 provides (it currently crashes on start_with_current_pipe). Note for whichever of the two lands second: #29986 drains readers by popping until empty, which would also complete a listener that registered during the drain; on POSIX that listener now has a read in flight and must be left alone, which is why this change snapshots the list instead. #35337 (regular files are not pollable) is a separate problem; the error-path tests here use a directory as stdin so they do not depend on how that one is resolved.
Not changed here: while no cat is registered (the first one died on a write error and the next command has not started its cat yet), the reader keeps reading and throws the bytes away (on_read_chunk_cb returns should_continue regardless of whether anyone is listening), where a real shell would leave them for the next cat. Pre-existing and independent of the hang; it is the reason the tests below close stdin up front or use a tty rather than feeding more data into a pipe.
Tests
test/js/bun/shell/commands/cat.test.ts (new; the other builtins have a file here, cat did not. #37743 and #35337 create the same file for their own cat fixes; the helpers are independent, so whichever lands later just appends its cases, and I will rebase this one if it is not first). Each case spawns a child bun with the flag and checks the script's stdout plus an exit=<code> trailer:
- second cat after EOF:
cat; echo ---; cat,&&chain, three cats, and the second cat inside( )/ifso it gets a different node id than the first; each with captured stdout (restart happens inside the EOF callback) and with stdout going through an IOWriter (restart happens later, from a write callback) - 300 KB input spanning several reads, a pipeline stage's stdin (
echo hi | (cat; echo ---; cat)), and stdin as an actual pipe viash -c(Bun.spawn's pipe is a socketpair) - stdin as a tty (
Bun.Terminal):hi+ ^D, wait for---, thenmore+ ^D, expectinghi,---,more. On a pipe the new read only reports EOF again, which prints the same thing as completing the second cat on the spot would; a tty's ^D is used up by the read that sees it, so this case only passes if the second cat really reads the fd again (it hangs before the fix and would print nomorewith complete-on-the-spot semantics) - first cat failing its stdout write (
cat > /dev/full, Linux), captured output: second cat served by the still-running read; plus a third cat served by the extra wakeup, plus a trailing subprocess so the extra wakeup arrives with nobody to notify while the reader is still alive (debug assertions); and with an IOWriter, whereechocompletes later, so the read ends with nobody listening and the second cat starts a new one (this one hung before) - first cat failing to read (directory as stdin), second cat flat and inside
( )(the latter panicked before). This block also runs on Windows, where the builtin cat is the default and a failed read can be retried (only the EOF block is skipped there, see above): on a Windows x64 canary build the( )case panics with the sameexpected Node::Cmd at Node#3, got Subshell, and both cases pass with a Windows debug build of this branch
Before the fix 17 of the 20 cases fail on Linux (16 hang, the read-error ( ) case panics; the three that pass, the basic and the trailing-subprocess /dev/full cases and the flat read-error case, are guards for paths this touches). The two halves are independently load-bearing: the read-error ( ) case does not involve start() at all and only passes with take_readers, while the EOF cases only stop hanging with the start() change. With the fix all 20 pass on the debug build (also checked: the file fails the same way on a debug build with src/ stashed). For comparison, with #29986's IOReader.rs changes ported onto main instead of this change, the captured-output cases pass as a side effect of its drain loop and every case where the next cat registers after the notification has returned still hangs (see the comment below).