shell(cat): notify each cat once, pause the stdin reader while none listens

Closed
RO
19d ago
+746-76

Problem

  • With the builtin cat, a second cat on one stdin hangs (cat; cat) or aborts: panic: expected Node::Cmd at Node#2, got Subshell. The first version of this PR aborts with panic: 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 1 takes 55 MB from its producer (dash: 196,608 bytes).

Fix

  • IOReader removes listeners before it notifies them and pauses the reader when none is left. start() resumes it, clears READ_FAILED, and hands over what was read with no listener.
  • Correct because an entry lives only while its cat waits, and unread input stays in the fd as under sh.
  • 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 IOReader serves every builtin cat on an fd (default on Windows, BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1 on 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 breaks process.stdin.pause() followed by Bun.stdin.stream(), so it waits for #43747.

Downsides

  • A producer piped into a script is no longer drained once the last cat left. It gets EPIPE, as under sh.
  • After a hang-up a later subprocess gets 0 of 37,856 bytes (parked for a builtin cat). A cat that waits on its write still reads ahead.
  • Cost: +40 bytes per IOReader, +1,138 bytes of text, one more epoll_ctl when a cat starts 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). A cat that 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 that cat. 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() clears PosixFlags::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_io has one changed line, the doc comment of READ_FAILED. The release object of bun_io is identical to main.

Behaviour (builtin cat, Linux, debug builds of main 017b2439a7 and of this PR)

Scriptstdinmainthis PR
cat; echo ---; cathi, then EOFhangshi, ---
cat; (cat); echo done/dev/nullpanic: expected Node::Cmd at Node#2, got Subshelldone
cat || echo 1; cat || echo 2; cat || echo 3; cat || echo 4a directoryassertion failed: n <= Self::MAX_DEPTH (debug)1 2 3 4
true | cat /; echo endemptypanic: expected Node::Pipeline at Node#2, got Freeend
cat | echo [$(cat)]a directorypanic: expected Node::Cmd at Node#3, got Expansion[]
echo a$(cat)b; cathi, then EOFhangsahib
cat > /dev/full | echo [$(cat)]hi, then EOF[][hi]
cat | true, then 300 KB written, then catpipethe second cat prints nothing300 KB
cat | head -1; sleep 1; echo enda producer that writes 64 KiB linestakes 52.7 to 57.1 MB (3 runs)takes 196,608 to 262,144 bytes (dash: 196,608)

How it works

  • Notify once. take_readers empties the list before on_reader_done_cb, on_reader_error or complete_listeners notify. A notification can start the next cat. That cat registers in the empty list and is served by a read made for it.
  • Pause. remove_reader (a cat whose write failed) and take_readers pause 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, clears READ_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 later cat at once (reached_eof). A uv_fs_read that 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 next cat as the Yield. A read error that arrives with an empty list is kept in raw_err, and the next cat gets 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 through on_reader_error while start() is on the stack. IOReader keeps the error (starting, start_err) and start() returns the completion of the cat as its Yield. Main ran that completion in a nested Yield::run, under the code that was still starting the command.
  • Chunk delivery. deliver gives 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 a cat that started during the dispatch could get the chunk in its place. remove_reader now 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_loop is shared with process.stdin, Bun.stdin.stream() and file bodies of Bun.serve. A build with that rule printed nothing for a Bun.stdin.stream() reader after process.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 of bun_io that the shell reader otherwise never sets.
  • Run a cat that reads stdin as a child process. It costs a process spawn per cat on Windows.
  • Complete the next cat from start() with a parked error. cat cancels its queued output when an error follows a chunk at once, and then does not finish (the cat.rs half 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/cat gives the subprocess 0 of the 37,856 bytes that follow the first read (8 of 8 runs, also with sleep 0.3). cat > /dev/full; /bin/true; cat gives 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 cat left (at most 64 KiB) is parked in the same way.
  • A cat that waits on its write. cat | sleep 2 still 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. cat cancels its queued output and does not finish. #37743 is open for it.
  • Regular files. Behind the flag on POSIX, cat file and cat < file fail as on main (#35337, #41621).
  • A script that rejects leaves a cat that already listens on stdin registered, and the process waits for stdin. This is the same on main and is separate work.
  • IOWriter also 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.rs half.
  • #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 IOReader and keeps the logic of main.

Measurements

Debug builds of main 017b2439a7 and of this PR, Linux x64, unless stated.

  • epoll_ctl calls on the shell's stdin (BUN_DEBUG_SYS trace). cat with 3 lines then EOF: 2 on main, 2 here. cat | cat: 2 and 2. cat > /dev/full; cat; /bin/true on a socket that hung up: 2 on main, 3 here. cat > /dev/full; cat with 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): State 152 to 192 bytes, IOReader 248 to 288 bytes. One IOReader exists 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_runtime text 8,310,492 to 8,311,630 bytes (+1,138). bun_io is byte-identical.
  • Four failing starts in a row (stdin is a directory): main stops at assertion failed: n <= Self::MAX_DEPTH with four nested Yield::run. Here the depth stays at 1.
  • Executed instructions per chunk were not measured: perf, valgrind and strace are 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.ts is new: 64 cases, 61 run on Linux, 1 is for Windows only, 2 are test.todo for 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 cat after EOF (six scripts, each with captured output, output through the event loop, and bun exec), a tty, a first cat that dies inside the chunk delivery, stdin that cannot be read, a cat in a command substitution (two listeners), a read that fails (ECONNRESET), and a first cat that 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, throw and worker-late-completion (all pass). shell-hang.test.ts fails 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 WindowsBufferedReader only. 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.