Skip to content

shell: split the output of an unquoted $(...) at a tab and at the whitespace at its ends - #47738

Draft
robobun wants to merge 1 commit into
robobun/954767e1/shell-param-expansionfrom
robobun/dfc8a929/shell-cmdsubst-split-edges
Draft

robobun wants to merge 1 commit into
robobun/954767e1/shell-param-expansionfrom
robobun/dfc8a929/shell-cmdsubst-split-edges

Conversation

@robobun

@robobun robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Draft, stacked on #46691. It lands after #45968 and the per-field change (Notes). No user reported this.

Problem

  • Bun Shell ends no field at whitespace at the ends of an unquoted $(...) output, or at a tab. args x$(echo " a") passes <xa>. sh passes <x><a>.
  • Cause: Expansion::post_subshell_expansion (src/runtime/shell/states/Expansion.rs:866) trims both ends, then splits only at spaces.

Fix

  • post_subshell_expansion is one scan. Each run of space, tab or newline ends a field through end_field, also at both ends.
  • The CRs before a newline go with it. Every other CR is data, as in sh.
  • transition_to_glob_state gives no empty field to the glob walk.
  • Verified: test/js/bun/shell/bunshell.test.ts (18 of 23 new tests fail on the base), the rest of test/js/bun/shell/ (Notes). Self-reviewed: 38 concerns raised, 28 addressed.

Background

Downsides

  • Scripts that run today get other arguments: a tab splits, x$(echo ' a') is two arguments, > out$(echo ' x') is ambiguous redirect. Notes lists each for a yes or no.
  • On shell: expand ${NAME} and the ${NAME<op>word} forms #46691 alone, 5 of 77 probe words get worse ({a,b}$(echo 'c ') gives {a,b}c), and a [[ ]] operand loses a tab.
  • Cost: .text -1,280 bytes. $(echo abc) -51 instructions. Whitespace at an end: +5 to +61.
Notes

Where this comes from

Decisions for a maintainer. Each one changes the arguments of a script that runs today. Released 1.4.2 gives the "before" column.

Change Before After (bash 5.2, dash)
Whitespace at an end of the output ends a field x$(echo ' a') is xa, --n=$(echo ' 5') is --n=5 x, a and --n=, 5
A tab ends a field $(printf 'a\tb') is one argument a, b
A redirect target of two fields echo hi > x$(echo ' foo') writes xfoo ambiguous redirect, exit code 1
The CRs before a newline go with it $(printf 'a\r\nb\r\n') is a\r, b a, b (bash: a\r, b\r)
Every other CR is data x$(printf 'a\r')y is xay xa\ry

If the edge split is not wanted, the tab alone is two comparisons in the old loop. It needs #45968 first for the same reason as this PR.

The CR rule. No shell gives a precedent, because bash and dash keep every CR. Rows are every output of up to 5 bytes over a, space, tab, CR and LF, in 9 contexts. The last column is every output with a line end, printed with CR LF, compared with its LF form.

Rule Equal to bash, of 35,154 CR LF output gives the LF arguments, of 39,312
1.4.2 and the base: trim CR at both ends 4,901 31,986
a CR before a newline is a line end only at the two ends of the output 30,375 31,986
a CR before a newline is a line end everywhere (this PR) 30,024 39,312

The rule is the same on every platform. The base has removed these CRs on every platform. CR LF output also reaches Linux and macOS with no Windows program: a CRLF file, HTTP headers, a pty.

Order of the stack

  1. shell: expand ${NAME} and the ${NAME<op>word} forms #46691. It adds end_field.
  2. shell: expand a [[ ]] operand to one word and match * on the right of == and != #45968. It makes a [[ ]] operand one word. Today CondExpr::child_done joins the fields of an operand, so the scan removes a tab or a CR LF from an operand: [[ $(printf 'a\tb') == "$(printf 'a\tb')" ]] is false here, and true on the base and in bash. 1,348 of 9,372 operand results move away from bash. One test.failing row holds it.
  3. shell: remove only trailing line ends from a quoted command substitution #45339. It has the same strip_trailing_line_ends. The copy here has the same name and body, so that the second one to land keeps one.
  4. The change for each field (9fa2b2c on robobun/954767e1/shell-field-glob). With it under the first cut of this scan, a local build gives the bash result for {a,b}$(echo " c"), {a,b}$(echo 'c '), *.txt$(echo ' '), {a,$(echo ' c')}, {a,$(echo ' c')}{d,e}, {$(echo 'c '),b} and {a$(echo ' '),b}.
  5. This PR. The four test.failing rows for a brace group and a glob then become plain tests.

Worse than the base until step 4

  • A field that whitespace of the output ends goes to the arguments as text. A brace group or a * in it does not expand. On the base, the trim kept the word in one piece.
  • {a,b}$(echo 'c ') gives {a,b}c (base: ac, bc). *.txt$(echo ' ') gives *.txt (base: the match). {a,$(echo ' c')} gives {a, and c} (base: a, c). {a,$(echo ' c')}{d,e} and {$(echo 'c '),b} are two more of the same kind.
  • x*$(echo ' a') is the error no matches found: a. On the base it is no matches found: x*a. The last field of a word with a * goes to the glob walk, as for *$(echo a b) on main.
  • A plain test holds these results as they are. Four test.failing rows hold the bash results.

Not changed here

Rows inside ${NAME<op>word}. No release reads that form, so pre${U:-$(echo ' a ')}post changes no released result. The scan is the same for every context.

Arguments against bash 5.2.37 (debug builds, output from the echo builtin)

  • Every output of up to 6 bytes over a, space, tab and LF, in 9 contexts (nothing, x or "" before it, and nothing, y or "" after it): 49,149 rows. Equal to bash: 1.4.2 and the base 14,157, this PR 49,149.
  • 77 probe words: equal to bash: 1.4.2 21, the base 22, this PR 54. 5 move away from bash (the brace and glob rows above).
  • With the CR alphabet (table above): 105 of 35,154 rows equal bash on 1.4.2 and not here. Each has a CR LF inside the output.

Reach

  • A sample of 3,097 package.json files on the build machine (17,277 scripts): 11 scripts (6 distinct) put text next to an unquoted $(...) in an argument. Each prints one token: a tag, a hash, a version. None is in a redirect target, a [[ ]] operand or a word with a brace group or a glob.
  • The npm corpus of shell: expand ${NAME} and the ${NAME<op>word} forms #46691 (332,632 scripts) was not available for this PR.

Measurements (release builds of 3797331 and of this PR, Linux x64, bun-profile)

Instructions in shell code for one more unit. qemu-user 10.0.13, one instruction per block, scripts of 1,000 and 2,000 units. Each count was the same in two runs.

Unit Base This PR Arguments
a literal word 960 960 same
"$(echo a)" 6,742 6,742 same
A=$(echo a) 8,354 8,354 same
[[ -n ${U:-a} ]] 3,269 3,269 same
$(true) 5,330 5,329 same
$(echo abc) 6,916 6,865 same
$(echo a b c) 9,415 9,322 same
$(echo -n -e 'a\r\n') 8,942 8,888 same
[[ -n $(echo a) ]] 8,447 8,404 same
$(echo ' a') 6,889 6,894 same
$(echo 'a ') 6,892 6,906 same
$(echo ' ') 6,718 6,779 same
x$(echo ' a ')y 7,213 7,578 1 to 3
$(echo -e 'a\tb') 7,959 8,147 1 to 2
$(echo -n -e 'a\r\nb\r\n') 9,331 9,252 a\r, b to a, b
  • For each output byte (true $(cat f), 1 MiB and 2 MiB): 16.2 to 14.0 with no whitespace, 57.1 to 50.5 with lines of three words.
  • Allocations and frees: the same on both builds for each unit with the same arguments ($(echo abc): 28 and 24). The two units with more arguments allocate for them: 28 to 35 and 31 to 36.
  • Syscalls for each $(echo abc) and each x$(echo ' a ')y: 2 on both builds (qemu -strace).
  • .text 110,661,145 to 110,659,865 bytes. post_subshell_expansion 1,821 to 546 bytes. transition_to_glob_state 4,840 to 4,862. end_field is one symbol of 826 bytes on both.
  • No struct changes: the size asserts of Expansion (168) and Node (176) compile as they are.

Tests

  • 23 new tests in the describe word splitting. On a debug build of the base, 18 fail.
  • Two of them run a generated table against a model of the rule in the test file: every output of up to 4 bytes over a, space, tab and newline in 9 contexts (3,069 rows), and every output of up to 5 bytes with \r in 2 contexts (7,812 rows).
  • A port of the scan to JavaScript, with one clause changed at a time: 11 of 13 changes give a row of a table another result. The other two call end_field for each whitespace byte of a run, which gives the same fields.
  • Without the check in transition_to_glob_state, *.txt$(echo ' ') is the error no matches found: with an empty name. A test fails then.
  • 40 other test files of test/js/bun/shell/ and test/cli/run/run-shell.test.ts pass on the debug build. On the release builds, leak.test.ts passes on both, and shell-load.test.ts fails on both.
  • Not run: Windows, and bun run rust:mordant (the tool is not on the build machine). The tests use only the echo builtin for the output, so the same code runs on Windows CI.

Self-review: what is not done, and why


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

…hitespace at its ends

`Expansion::post_subshell_expansion` is one scan. Each run of space, tab or
newline in the output ends a field through `end_field`, also at the start and
at the end of the output. So `x$(echo " a")` is `x` and `a`, and
`$(printf 'a\tb')` is `a` and `b`, as in sh.

`strip_trailing_line_ends` removes the newlines at the end of the output, and
the CRs before them, before the split. In the output, the CRs directly before
a newline are a part of that line end. Every other CR is data.

`transition_to_glob_state` does not give a field with no bytes to the glob
walk.
@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:02 AM PT - Nov 2nd, 2026

❌ @robobun, your commit 43f85db has 66 failures in Build #136865 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47738

That installs a local version of the PR into your bun-47738 executable, so you can run:

bun-47738 --bun

@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on Bun 1.4.2 and on the head of #46691. args is a script on PATH that prints each argument as <arg>:

bun exec 'args x$(echo " a")'      # <xa>, one argument. bash and dash: <x><a>
bun exec 'args $(printf "a\tb")'   # one argument with a tab. bash and dash: <a><b>

With this PR both commands give the bash result.

CI (build 136865): test/js/bun/shell/bunshell.test.ts passes on every lane: Linux, Windows and macOS (macOS: 588 pass, 0 fail. Windows: 573 pass, 0 fail). No shell test fails. The red files are 27 files under test/aot/ and test/bundler/compile-aot-size.test.ts. This diff does not change that code.

This PR stays a draft. It waits for three things:

PR: #47738

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant