shell(cat): notify each cat once, pause the stdin reader while none listens
Closed
RO
19d ago+746+746+012345678901234567890123456789-76-76-01234567890123456789
Problem
- With the builtin
cat, a secondcaton one stdin hangs (cat; cat) or aborts:panic: expected Node::Cmd at Node#2, got Subshell. The first version of this PR aborts withpanic: Cmd Node#2 is not running a builtin. - Cause:
IOReader(src/runtime/shell/IOReader.rs) keeps a listener after it notified it and never stops reading.cat | head -1; sleep 1takes 55 MB from its producer (dash: 196,608 bytes).
Fix
IOReaderremoves listeners before it notifies them and pauses the reader when none is left.start()resumes it, clearsREAD_FAILED, and hands over what was read with no listener.- Correct because an entry lives only while its
catwaits, and unread input stays in the fd as undersh. - Verified:
test/js/bun/shell/commands/cat.test.ts(61 cases on Linux, main fails 48). Self-reviewed: 14 concerns raised, 14 addressed.
Background
- One
IOReaderserves every builtincaton an fd (default on Windows,BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1on POSIX). Listeners are(NodeId, tag), and the shell reuses node ids. - Considered: stop the POSIX read that continues after a hang-up, in
bun_io. It breaksprocess.stdin.pause()followed byBun.stdin.stream(), so it waits for #43747.
Downsides
- A producer piped into a script is no longer drained once the last
catleft. It getsEPIPE, as undersh. - After a hang-up a later subprocess gets 0 of 37,856 bytes (parked for a builtin
cat). Acatthat waits on its write still reads ahead. - Cost: +40 bytes per
IOReader, +1,138 bytes of text, one moreepoll_ctlwhen acatstarts inside a chunk delivery.
Notes
What changed since the first version of this PR (d6c5eaf05b)
- The first version only paused the reader. It could abort with
panic: Cmd Node#2 is not running a builtin(Builtin::of←Cat::on_io_reader_done←dispatch_reader_done←IOReader::on_reader_done_cb←PosixBufferedReader::read_loop←on_poll). Acatthat started from inside a chunk delivery armed the poll. After a hang-up the read loop that was still running went on to EOF and completed thatcat. The armed poll then fired, and the done callback ran a second time for a list entry whose node id a subprocess had reused. - This version contains the change of #39638 (restart the reader for a later
cat, notify each listener once) and covers the case of #29986. Both PRs are still open and can be closed when this one merges. start()clearsPosixFlags::READ_FAILED, as #43900 asked ("Left open").- An intermediate version also changed the POSIX read loop in
src/io/PipeReader.rs. That change is not in this PR (see "Considered").bun_iohas one changed line, the doc comment ofREAD_FAILED. The release object ofbun_iois identical to main.
Behaviour (builtin cat, Linux, debug builds of main 017b2439a7 and of this PR)
| Script | stdin | main | this PR |
|---|---|---|---|
cat; echo ---; cat | hi, then EOF | hangs | hi, --- |
cat; (cat); echo done | /dev/null | panic: expected Node::Cmd at Node#2, got Subshell | done |
cat || echo 1; cat || echo 2; cat || echo 3; cat || echo 4 | a directory | assertion failed: n <= Self::MAX_DEPTH (debug) | 1 2 3 4 |
true | cat /; echo end | empty | panic: expected Node::Pipeline at Node#2, got Free | end |
cat | echo [$(cat)] | a directory | panic: expected Node::Cmd at Node#3, got Expansion | [] |
echo a$(cat)b; cat | hi, then EOF | hangs | ahib |
cat > /dev/full | echo [$(cat)] | hi, then EOF | [] | [hi] |
cat | true, then 300 KB written, then cat | pipe | the second cat prints nothing | 300 KB |
cat | head -1; sleep 1; echo end | a producer that writes 64 KiB lines | takes 52.7 to 57.1 MB (3 runs) | takes 196,608 to 262,144 bytes (dash: 196,608) |
How it works
- Notify once.
take_readersempties the list beforeon_reader_done_cb,on_reader_errororcomplete_listenersnotify. A notification can start the nextcat. Thatcatregisters in the empty list and is served by a read made for it. - Pause.
remove_reader(acatwhose write failed) andtake_readerspause the reader when the list is empty. On POSIX the pause unregisters the poll, so unread input stays in the fd for the next builtin or subprocess. - Resume, POSIX.
start()unpauses, clearsREAD_FAILED, and arms the poll when it is not armed. A pipe at EOF reports EOF again, and a tty blocks for the next input. - Resume, Windows. The Windows reader closes its source (and the fd) at EOF, so
start()completes a latercatat once (reached_eof). Auv_fs_readthat is out cannot be recalled:pause()does nothing while one is out, and the chunk it brings is parked. - Parked input. A chunk that arrives with an empty list goes to
State::buf.start()returns its dispatch to the nextcatas theYield. A read error that arrives with an empty list is kept inraw_err, and the nextcatgets it when its own read reports EOF. - Failed start. On POSIX a poll registration can fail inside
ReaderImpl::start()(/dev/null, a directory), and the reader reports it throughon_reader_errorwhilestart()is on the stack.IOReaderkeeps the error (starting,start_err) andstart()returns the completion of thecatas itsYield. Main ran that completion in a nestedYield::run, under the code that was still starting the command. - Chunk delivery.
delivergives a chunk to the listeners that are registered when it arrives. Before, when a listener removed itself during its dispatch, the loop skipped the listener behind it, and acatthat started during the dispatch could get the chunk in its place.remove_readernow keeps the order of the list for this.
Considered
- Stop the read that continues after a hang-up, in
bun_io. With it, the tail stays in the fd for a later subprocess too.PosixBufferedReader::read_loopis shared withprocess.stdin,Bun.stdin.stream()and file bodies ofBun.serve. A build with that rule printed nothing for aBun.stdin.stream()reader afterprocess.stdin.pause()on a hung-up 1,000,000-byte pipe. 1.4.3 reads it to EOF. The rule belongs in its own PR on top of #43747 and #44036. - Mark the reader done from the shell (
IS_DONE) to end that read. It uses an internal guard ofbun_iothat the shell reader otherwise never sets. - Run a
catthat reads stdin as a child process. It costs a process spawn percaton Windows. - Complete the next
catfromstart()with a parked error.catcancels its queued output when an error follows a chunk at once, and then does not finish (thecat.rshalf of #37743). With the error at the next EOF the output is normally written first.
Limits that stay
- POSIX, after a hang-up. The read loop reads on to EOF in the same wakeup. With a 300,000-byte pipe whose writer is gone,
cat > /dev/full; /bin/true; /bin/catgives the subprocess 0 of the 37,856 bytes that follow the first read (8 of 8 runs, also withsleep 0.3).cat > /dev/full; /bin/true; catgives the builtin all 37,856 (4 of 4). On main the subprocess also gets 0, and the builtin in its place hangs. - Windows. The one read that was out when the last
catleft (at most 64 KiB) is parked in the same way. - A
catthat waits on its write.cat | sleep 2still reads stdin into memory. The listener is alive, so the reader does not pause. This is separate work. - A read error with output that is not captured.
catcancels its queued output and does not finish. #37743 is open for it. - Regular files. Behind the flag on POSIX,
cat fileandcat < filefail as on main (#35337, #41621). - A script that rejects leaves a
catthat already listens on stdin registered, and the process waits for stdin. This is the same on main and is separate work. IOWriteralso dispatches to node ids. This PR does not change it.
Other open PRs that touch IOReader
- #42819 removes libuv on Windows and rewrites
IOReader::start(). The Windows arm here (reached_eof,is_reading, the parked read) is built on the libuv reader. If #42819 merges first, this PR keeps the list handling, the pause and the tests, and its Windows arm must be redone on the new reader. - #37743 adds its own deferral of a failed start. After this PR it reduces to its
cat.rshalf. - #43071 gives a command substitution the stdin of its command. It removes the one way two
cats listen at the same time. - #40228 changes the ownership types in
IOReaderand keeps the logic of main.
Measurements
Debug builds of main 017b2439a7 and of this PR, Linux x64, unless stated.
epoll_ctlcalls on the shell's stdin (BUN_DEBUG_SYStrace).catwith 3 lines then EOF: 2 on main, 2 here.cat | cat: 2 and 2.cat > /dev/full; cat; /bin/trueon a socket that hung up: 2 on main, 3 here.cat > /dev/full; catwith a live writer: 3 on main, 4 here.- Allocations in the EOF callback before the first notification (gdb, allocator entry points): 1 on main (the copy of the list), 0 here.
- Size (gdb
sizeof):State152 to 192 bytes,IOReader248 to 288 bytes. OneIOReaderexists per fd that builtins of a script read. - Code size (release bitcode of main f0c6cbc81a with and without this change, compiled with
llc -O3, no cross-crate LTO):bun_runtimetext 8,310,492 to 8,311,630 bytes (+1,138).bun_iois byte-identical. - Four failing starts in a row (stdin is a directory): main stops at
assertion failed: n <= Self::MAX_DEPTHwith four nestedYield::run. Here the depth stays at 1. - Executed instructions per chunk were not measured:
perf,valgrindandstraceare not installed on the machine. The chunk path has one more branch (empty list) and one more comparison per listener.
Tests
test/js/bun/shell/commands/cat.test.tsis new: 64 cases, 61 run on Linux, 1 is for Windows only, 2 aretest.todofor the limits above. A debug build of main fails 48 of the 61: by a hang, by one of the panics above, or by missing output.- Groups: a second
catafter EOF (six scripts, each with captured output, output through the event loop, andbun exec), a tty, a firstcatthat dies inside the chunk delivery, stdin that cannot be read, acatin a command substitution (two listeners), a read that fails (ECONNRESET), and a firstcatthat stops before EOF with a later builtin or subprocess as the second reader. - The cases run one after the other. Each case starts one to three bun processes. A debug build that starts several at the same time takes a single case to 4 s of the 5 s default timeout.
- Also run on the debug build of this PR:
bunshell.test.ts(451 pass), the same file with the flag exported (446 pass, 5 fail, the same 5 on main: regular files through the builtin),yield,shell-pipe-read-fault,shell-write-fault,epipe,file-io,pipeline_stack,shell-blocking-pipe,shell-cmdsub-crash,exec,throwandworker-late-completion(all pass).shell-hang.test.tsfails 6 of 7 on both builds: its 700 ms timeouts are too short for a debug build on this machine. - Windows and macOS were not run locally. The Windows arm is checked by reading
WindowsBufferedReaderonly. CI is the first run there.
Self-review
- The review raised 14 concerns against the intermediate version. All are addressed. One suggested test was not added: a comparison of the event loop's poll count between two scripts. The POSIX cases with a later subprocess already fail when the reader does not pause, and a poll count depends on sockets that have nothing to do with the shell.
- Two more read-throughs of the final file (the Windows arm, and epoll and kqueue) found the skipped listener in the chunk loop, which is fixed here with a test.