Repository navigation
Conversation
|
Updated 12:05 AM PT - Oct 17th, 2026
❌ @robobun, your commit b7f6855 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 45339That installs a local version of the PR into your bun-45339 --bun |
|
Status: ready for review. Stacked on #44752. Reproduced with (release build of main, bash 5.2 beside it): bun exec "echo \"a\$(printf ' ')b\""
# main: ab. bash and this PR: a b
bun exec "printf 'a\tb\n' | cut -d\"\$(printf '\t')\" -f2"
# main: cut: the delimiter must be a single character. bash and this PR: bPR: #45339 |
There was a problem hiding this comment.
Beyond the inline findings, I also checked whether the unquoted $(cmd) path changed: post_subshell_expansion (Expansion.rs:569-572) still trims spaces, tabs, CR and LF at both ends before field splitting, so only the quoted/assignment arm gains trailing blanks. The intentional CR-before-LF divergence from bash is documented in the helper comment, docs/runtime/shell.mdx, and pinned by the new "substitution removes trailing line ends" tests.
Extended reasoning...
The change replaces the whitespace trim in the quoted/assignment command-substitution arm of src/runtime/shell/states/Expansion.rs with a shared strip of trailing LF/CR runs, and makes the shell exit builtin accept whitespace around its number; no security-sensitive surface is touched. Three confirmed findings are posted inline, so a human should weigh the behavioral tradeoffs for unquoted variables and Windows .cmd arguments before merging.
Findings marked 🟡 are optional suggestions and need no follow-up push.
A quoted command substitution and an assignment value lost every trailing space, tab and CR of the output. The shell now removes only the trailing newlines and the CRs directly before them. Output that ends in CRLF gives the same word as before on every platform. `exit` accepts whitespace around its number, as bash and dash do, so a captured status with a trailing blank still works.
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline nit, I checked the follow-up commit: the assign_ctx branch in src/runtime/shell/states/Expansion.rs now applies the same trailing SP/TAB/CR/LF trim as the base for A=$(...) and B="$(...)", post_subshell_expansion is byte-identical to the base, and parse_exit_code in src/runtime/shell/builtin/exit.rs trims only space and tab, so 3\r, 3\n and 3\f are still rejected (pinned in exit.test.ts). The remaining behavior change is confined to "$(...)" in a command word; since that is a user-visible Bun Shell semantics change, a human look at that choice is still worthwhile.
Extended reasoning...
The PR now changes only the quoted command-substitution path in Expansion::child_done (keeps trailing blanks and a lone CR, strips trailing LF/CRLF runs) and lets the exit builtin accept space/tab around its number; assignment and unquoted paths match the base. No security-sensitive surface. The follow-up commit addressed the two substantive prior findings (assignment values handing a trailing blank to unquoted $X, and exit accepting \f/\v/\r); the open inline finding this run is a style nit, but the change is a user-facing shell semantics decision authored by a bot, so a maintainer should still weigh it.
Still open from earlier reviews (1):
- Unresolved: 1 minor or pre-existing.
An unquoted variable is not split into fields. So an assignment value still drops the trailing whitespace of a command substitution, and `prog $X` gets what bash passes after it splits `$X`. A quoted substitution in a command word, a redirect target or a `[[ ]]` operand keeps its trailing spaces and tabs. `exit` accepts a space or a tab around its number, as bash does, and no other byte.
bbb2bc2 to
b7f6855
Compare
|
Review follow-up, at b7f6855 (rebased on the new head of #44752):
The title and the body now describe the narrower scope. The choice that is left for a maintainer: a quoted substitution in a command word now gives the text of bash, so |
Stacked on #44752.
Problem
echo "a$(printf ' ')b"printsab, bash printsa b.echo hi > "$(echo 'out ')"writesout, andcut -d"$(printf '\t')"fails.Expansion::child_done(src/runtime/shell/states/Expansion.rs:650). POSIX 2.6.3 removes only trailing newlines.Fix
child_doneremoves only the trailing LFs, and each CR directly before them, from a quoted substitution in a command word, a redirect target or a[[ ]]operand. bash keeps that CR.$X.exitaccepts spaces and tabs around its number, as bash does.test/js/bun/shell/bunshell.test.ts,exec.test.ts,commands/exit.test.ts(16 of 24 new tests fail without the change). Also all oftest/js/bun/shell/. Self-reviewed: 22 concerns raised, 21 addressed.Background
child_donereads the stdout of a substitution's child script. A quoted substitution or an assignment appends it to the word, an unquoted one splits it.X=$(cmd); prog $Xthen passes a trailing blank that main and bash do not pass.Downsides
ping "$(hostname -I)"now gets the trailing space, as in bash.X="$(cmd)"; [ "$X" = "$(cmd)" ]is now false for such output (main, bash: true)..text+256 bytes.Notes
Rule for a quoted substitution in a word. If the output ends in LF, the shell removes the trailing run of CR and LF bytes. It keeps spaces, tabs, and a CR with no LF after it. An interior CRLF stays.
a<SP>aa<SP>a<SP>a<TAB><LF>aa<TAB>a<TAB>a<LF><SP><LF>aa<LF><SP>a<LF><SP>a<CR>aa<CR>a<CR>a<LF><LF><LF>aaaa<CR><LF>aaa<CR>a<CR><CR><LF>aaa<CR><CR>a<SP><CR><LF>aa<SP>a<SP><CR>What does not change. An assignment value (
X=$(cmd),X="$(cmd)",export X=...,X=... prog) and an unquoted substitution give the same words as before. Measured over seven forms (below): only the quoted word changes.Why assignment values keep the trim. bash stores the exact value and drops the blanks later, when it splits an unquoted
$Xinto fields. Bun Shell does not split a variable (Expansion.rs,SimpleAtom::Var), and it has no${X% }to cut the blank. The first version of this PR stored the exact value. ThenIP=$(hostname -I); ping $IP,D=$(cmd); cd $D,touch $Xandseq $Ngot a trailing blank that neither main nor bash pass. So the exact value waits for field splitting of an unquoted variable. Until thenX=$(printf 'a '); echo "[$X]"prints[a]where bash prints[a ]. To keep the blank today, use the substitution in the word:echo "[$(printf 'a ')]".Why the CR goes on every platform. The shell has removed a CR before a trailing LF on Linux, macOS and Windows since #7748. Output that ends in CRLF also reaches Linux and macOS with no Windows program involved: HTTP headers, pty output, CRLF files.
echo "[$(printf 'Location: /x\r\n\r\n' | cut -d' ' -f2)]"prints[/x]on main and here, and bash keeps<CR><LF><CR>after/x. An LF-only strip gives that word back its CR and makes"$(hostname)"end in CR on Windows. For output without NUL bytes, this CR rule is the only place where the end of a quoted substitution in a word differs from bash, apart from the empty word below. Related to #29669 (CR in a script file), which this PR does not change. Windows was not run locally. The tests feed the substitution from theechobuiltin, so they run the same code on Windows CI.A lone CR stays.
"$(printf '\r')"is the way to get a CR in a script, as"$(printf '\t')"is for a tab. bash keeps it too.What changes for a Windows program. A word changes only when a blank or a lone CR stands before the last line end.
a<SP><CR><LF>wasaand is nowa<SP>. Programs that pad their lines with spaces (table output,wmic) give such output. I did not run these programs.exit.parse_exit_codetrims spaces and tabs.exit "3 ",exit " 3"andexit "3<TAB>"give 3, as in bash and dash (main:exit: numeric argument required). A trailing CR, LF or FF is still an error, as in bash. The strip is what exposes this: on main the trim hid the blank inexit "$(echo '3 ')".Costs, with outputs (bash / main / PR).
./argv "$(hostname -I)"passes172.30.0.2<SP>/172.30.0.2/172.30.0.2<SP>. A quoted word is now the text of bash, so a command that failed on the blank in bash fails here too../argv a "$(true)" b "$(printf ' ')" cpasses<a><><b>< ><c>/<a><b><c>/<a><b>< ><c>."$(printf ' ')" echo xranechoon main. It now fails withcommand not found, and bash fails too.exit "$(printf '3\r')"gives 2 / 3 / 1: the lone CR stays in the word, andexitrejects it as bash does.X="$(echo 'a ')"; [ "$X" = "$(echo 'a ')" ] && echo T || echo FprintsT/T/F.F="$(echo 'out ')"; echo hi > "$(echo 'out ')"; cat "$F"printshi/hi/cat: out: No such file or directory.[[ "$(echo 'a ')" == $(echo 'a ') ]]is true / true / false, because an unquoted[[ ]]operand is split.Not changed here, on purpose.
todotest recordsX="$(echo 'a ')"; echo "[$X]"with the result of bash.test -n "$(true)"is true (bash: false). shell: keep an empty word that has a double-quoted expansion #44904 covers it.exportoperand whose name is not literal text (export $N="$(cmd)",export "X"="$(cmd)") is a command word, not an assignment (Cmd.rs,is_assignment_word). A quoted substitution in it keeps its blanks, so the exported value has them.exit 1_0exits 10 on main, because the number parser accepts_. bash rejects it. With the blank trim,exit '1_0 'does the same..cmdor.batcommand refuses an argument that contains CR or LF (Cmd.rs,batch_arg_has_cmd_metachars). A kept lone CR, or an LF that is no longer at the end because a blank follows it, now reaches that check in a quoted word. The tree has no test for that refusal today.Scope of the gain. The fix matters for straight-line scripts: a tab or a blank from
printfused in a word, a file name with a trailing blank,[[ "$(cmd)" == "x " ]]. The comparison with bash is in the table and the measurements here and not in the tree.Measurements. Release builds, linux x64, #44752 vs this PR. The base binary is d66d352. The later commit on #44752 changes a comment and a test.
mi_mallocandmi_free, slope of 200 vs 400 builtin substitutions)Interpreter::child_doneper substitution: quoted word 1,434 -> 1,440, quoted assignment 1,531 -> 1,533, unquoted assignment 1,534 -> 1,536, unquoted word 1,681 -> 1,682 (gdb single-step, callees included)size);Interpreter::child_done8,651 -> 8,735 bytes,Exit::start466 -> 615 bytes,post_subshell_expansion2,001 -> 2,001 bytes (nm -S)catch syscall, slope of 200 vs 400)Reviews. The first version kept the blanks in assignment values too. The review of that version found the
X=$(cmd); prog $Xregression, and this version keeps the trim there. A second review of this version raised 11 points. 10 are done: the unsortedlsin a test, the test names and comments, the docs wording, and the two limits above (the variable against a quoted substitution,exportwith a name that is not literal). Not done:exit 1_0, which is base behaviour. A later review comment asked forstrings::trim_rightin both trims. Not done: with the helper an assignment substitution costs +36 instructions inchild_done(1,531 -> 1,567), against +2 with the loop of the base, and this PR does not change assignment values.Tests.
todo. 16 fail without the change. The 8 controls pin what must stay: every LF, every CRLF and every CR before the LF are removed, an assignment value (X=$(cmd),X="$(cmd)",export X="$(cmd)") still drops the trailing space,exitrejects a trailing CR, LF and FF, andexittakes a captured status.test/js/bun/shell/(43 files) andtest/cli/run/run-quote.test.tson release builds with and without the change: the same results in every file except the 16 tests above.commands/ls.test.tshas 2 failures on both (permission tests, the container runs as root).bunshell.test.ts(464 pass),exec.test.ts(21 pass) andcommands/exit.test.ts(14 pass). Thestdin redirect from a zero-length buffercells ofbunshell.test.tssometimes time out at 5 s on a debug build, with and without the change.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/bunshell.test.ts