shell: a command substitution inherits the stdin of its command
RO
2mo ago+78+78+01234567890123456789-19-19-01234567890123456789
Problem
- A
$(...)inside a pipeline member reads the stdin of the bun process, not the pipe.echo FROM_PROCESS_STDIN | bun -e 'await Bun.$echo hi | echo "[$(cat)]"'prints[FROM_PROCESS_STDIN]. bash and dash print[hi]. On a terminal the script blocks, becausecatwaits for terminal input. - The cause is in
Expansion::next(src/runtime/shell/states/Expansion.rs:204). It builds the IO of the substitution frominterp.root_io(). The root IO is the IO of the whole$call. AnExpansionnode did not know the IO of the command it expands in. - On Windows the shell
IOReaderclosed its fd at the first EOF. A second read of the same stdin panicked (Option::unwrap()onNone), and a spawn after$(cat)failed withBad file descriptor. The released bun has this forecho hi | (cat; cat)andecho hi | (cat; prog). With the pipe as stdin of the substitution,echo hi | prog "$(cat)"would hit it too.
Fix
Expansiongets anio: IOfield. Each caller passes the IO of its own node:Cmd.iofor arguments and redirect targets,CondExpr.iofor[[ ]]operands, and a newAssigns.iofor assignment values. The substitution'sScriptruns with thatstdinandstderr.Interpreter::root_io()has no callers left and is removed.- On Windows the
IOReadernow borrows the fd as it does on POSIX (CLOSE_HANDLEcleared), attaches a newFilewhen a read starts after EOF, and closes the fd inDrop. - A done or error notification takes the
readerslist. Acatthat finished no longer stays registered, so the next EOF cannot reach its freed node. - Verified:
test/js/bun/shell/bunshell.test.ts(seven new cases under "command substitution", one per expansion site plus a second read after the substitution, all fail on the released bun). Also all oftest/js/bun/shell/on Linux andbunshell.test.tson Windows x64.
Background
- The shell interpreter is a tree of state nodes (
Script,Stmt,Pipeline,Cmd,Expansion, ...). Each node that runs something carries anIO(src/runtime/shell/IO.rs):stdin,stdout,stderr.Pipelinegives each member anIOwhosestdinis the read end of the pipe, wrapped in one sharedIOReader. Expansionturns one word (anast::Atom) into argv strings. For a$(...)it spawns a childScriptwithstdout: Pipeand collects the output.IOReader(src/runtime/shell/IOReader.rs) is the shell's async stdin reader. Builtins such ascatregister aChildPtron it and get chunk and done callbacks. On POSIX it already borrowed the fd and closed it inDrop. On Windows it used a libuvFilethat owned the fd.
Notes
- Probes against the debug build with
echo FROM_PROCESS_STDIN |on stdin, all match bash: top-levelecho "[$(cat)]"prints[FROM_PROCESS_STDIN],echo hi | echo "[$(cat)]" < f.txtprints[hi](expansion runs before redirections),echo hi | [[ $(cat) == hi ]] && echo yesprintsyes,echo hi | echo "[$(cat)]" | catprints[hi],echo hi | if true; then echo "[$(cat)]"; fiprints[hi],echo hi | (x=$(cat); cat)exits 0 with empty stdout. - Windows x64 debug build probes:
echo hi | (cat; cat; echo after)printshi\nafter,echo hi | (cat; bun -e ...)printshi\nprog,echo "[$(cat)]"; cat; echo afterprints[FROM_PROC]\nafter,echo hi | (A=$(cat); cat; echo "[$A]")prints[hi]. All of these crash or fail with EBADF on the released Windows bun. - On Linux the stale
readersentry never fired because the freed node id was reused by the nextcat, so the dispatch reached the right node by chance. - A bare assignment as a pipeline member (
echo hi | A=$(cat)) is still skipped byPipeline.rs, as before this change. The header comment inAssigns.rsdescribes that. - The state node is dropped in
Interpreter::free_node(n[id.idx()] = Node::Free), so the extraArcrefs on the pipe reader held byExpansion.ioare released when the expansion finishes. - Self-reviewed: the review asked to lead the body with the bash/dash divergence (done) and to reword the
Assigns.rsheader (kept, it is still accurate). - In
test/js/bun/shell/on the Linux machine, 15 pre-existing tests fail on the debug+ASAN build: 5s and 100s timeouts inleak.test.ts,shell-load.test.ts,shell-leak-args.test.ts,commands/rm.test.ts, andlspermission tests that cannot fail as root. On the Windows machine, 5tilde_expansiontests fail with the released bun too. None use a command substitution.
no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/shell/bunshell.test.ts