Skip to content

shell: keep stderr written inside $(...) when the shell output is captured - #43063

Open
robobun wants to merge 1 commit into
mainfrom
robobun/848995ee/shell-cmdsubst-stderr-captured
Open

robobun wants to merge 1 commit into
mainfrom
robobun/848995ee/shell-cmdsubst-stderr-captured

Conversation

@robobun

@robobun robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Under .quiet() or .text(), the Bun Shell drops the stderr of each command inside a command substitution $(...). (await $`echo "[$(ls missing)]"`.quiet()).stderr is empty, and so is ShellError.stderr. Without .quiet() the script prints and captures ls: missing: No such file or directory, as bash does.
  • The cause is ShellExecEnv::dupe_for_subshell (src/runtime/shell/interpreter.rs:1911). It gave a command substitution an owned buffer for stdout and for stderr. Expansion::child_done reads only the stdout buffer. deinit_impl frees the stderr buffer unread.

Fix

  • The env of a command substitution owns only its stdout buffer, which is the value of the substitution. Its stderr borrows the buffer of the parent env, as a subshell env and a pipeline env do.
  • Correct because, under .quiet(), each env borrows the stderr buffer of the root env, which result.stderr returns. Without .quiet() the substitution already borrowed that buffer.
  • Deleted: the ShellExecEnvKind::Normal variant. No caller passed it.
  • Verified: test/js/bun/shell/bunshell.test.ts (quiet > cmd subst stderr, 11 cases, 10 fail without the fix). Also all of test/js/bun/shell/. Self-review: see Notes.

Background

  • A ShellExecEnv holds the variables, the cwd, and two capture buffers for one scope of the shell. dupe_for_subshell copies it for a subshell, a pipeline member, or a command substitution.
  • A capture buffer is a Bufio: Owned(Vec<u8>) or Borrowed(*mut Vec<u8>). A borrowed buffer points at the buffer of an ancestor env, so the child output lands in the result of the script.
  • OutKind::Pipe means: append to the capture buffer of the current env. .quiet() sets it for the root stdout and stderr.
Notes

Left out on purpose: the stdin of a substitution (#43052). Expansion::next builds the IO of a substitution from the root IO (src/runtime/shell/states/Expansion.rs:204). So echo hi | echo "[$(cat)]" reads the stdin of the process. bash prints [hi]. That defect is about where the IO comes from. This PR is about who owns the stderr buffer. A fix for stdin must pass the IO of the enclosing command to Expansion, and it does not touch dupe_for_subshell. This PR does not change stdin.

Self-review.

  • The stdin site above is the only sibling site that the review found. It is tracked in Shell: a command substitution in a pipeline reads the stdin of the process, not the pipe #43052.
  • One new test used VAR=$(ls missing) && echo .... In bash the assignment takes the exit status of the substitution, so echo does not run. Bun runs it (Assigns::next always reports 0). The test now uses ; and does not depend on that difference.
  • The stdout arm is an exhaustive match kind, so a new kind must make a decision.
  • Not changed: the comments in src/runtime/shell/states/Cmd.rs:165 and :184 call the capture buffer a "command-substitution aggregate buffer". That wording was already loose before this change (the same code path serves a root command under .quiet()).

Why the parent buffer is the right target. IO is built at three sites only: the root (interpreter.rs:568), Pipeline.rs:219 (clones its own stderr), and Expansion.rs:204 (the stderr of the root). Under .quiet() each of them is OutKind::Pipe, and buffered_stderr() resolves a borrow to the owner, so borrows do not chain. Each env points at the Vec of the root env, which lives in the boxed Interpreter. A child env is freed before its parent resumes (Expansion::child_done, Pipeline::child_done, Subshell::deinit). The finalizer path only drops boxes and does not write through a borrowed pointer.

Behavior I compared, fixed build against bash.

Script (under .quiet()) result.stderr before after
echo "[$(ls missing)]" empty ls: missing: No such file or directory
echo "[$(sh -c 'echo err >&2')]" empty err
echo "[$(echo hi 1>&2)]" empty hi
echo "[$(ls missing 2>&1)]" empty (text is in the value) same
echo "[$(ls missing 2>/dev/null)]" empty same
echo "[$(ls missing)]" 2> file empty the ls line (bash also sends it to the stderr of the shell, not to the file)
$(ls missing) with .throws(true) ShellError.stderr empty the ls line
300000 bytes of stderr from a subprocess in $(...) 0 bytes 300000 bytes

The same scripts without .quiet() capture the same stderr before and after.

Not a regression. The Zig dupeForSubshell had the same .normal, .cmd_subst => .owned arm for both streams.

Related open work. #40228 rewrites Bufio as CapturedBuf and keeps the owned stderr for a command substitution. The PR that lands second needs a small rebase of this one function.

What I ran. Debug build with ASAN on Linux x64.

  • The 11 new cases pass. On the released build 10 of them fail. The 2>&1 case passes on both builds on purpose: it guards the value of the substitution.
  • All of test/js/bun/shell/bunshell.test.ts. On the final run the host was under heavy load, and two cases of stdin redirect from a zero-length buffer hit the 5 s timeout (445 pass, 2 fail). They spawn seven debug processes at once and do not use a command substitution. The same file was fully green (446 pass) on the first shape of this change, before I replaced the owns_pipe flag with the match kind and deleted Normal.
  • Each other file in test/js/bun/shell/ and test/js/bun/shell/commands/, and test/regression/issue/27099.test.ts. The failures there were timeouts under the debug build (shell-hang with its 700 ms budget, shell-leak-args, memleak_*, #11816 external, shell-load) and the two ls permission tests, which also fail on the released build because the container runs as root. None of the failed tests uses a command substitution. The shell-hang fixtures give the right exit codes when I run them directly. leak.test.ts and shell-load.test.ts ran in full on the first shape only. On the final shape I ran the three leak.test.ts cases that use $(...), and they pass.
  • An ASAN and LSAN probe that terminates a worker while a subprocess inside $(...) under .quiet() is still alive. It reported nothing.
  • I did not run Windows or macOS. The new cases use the commands and the messages that existing cases in the same file already assert on all platforms.

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

…tured

A command substitution env owned a fresh stderr buffer when the root
stderr was captured (.quiet(), .text()). Nothing read that buffer, so the
stderr of every command inside $(...) was dropped.

The env now owns only its stdout buffer, which is the value of the
substitution. Its stderr borrows the buffer of the parent env, like a
subshell or a pipeline member does.

Delete ShellExecEnvKind::Normal. No caller passed it.
@robobun

robobun commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:17 AM PT - Sep 17th, 2026

✅ @robobun, your commit ae31978b9cfc964d7292605a415736f1c37b37e0 passed in Build #117005! 🎉


🧪   To try this PR locally:

bunx bun-pr 43063

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

bun-43063 --bun

@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Status

How I reproduced it, on 1.4.3-canary.1+c6b7fcb5b and on main at 5c26a6c (Linux x64):

import { $ } from "bun";
$.nothrow();
const r = await $`echo "[$(ls /nonexistent-dir-zz)]"`.quiet();
console.log(JSON.stringify(r.stderr.toString()));
// before: ""
// after:  "ls: /nonexistent-dir-zz: No such file or directory\n"
  • Without the fix: USE_SYSTEM_BUN=1 bun test test/js/bun/shell/bunshell.test.ts -t "cmd subst stderr" fails 10 of 11 cases.
  • With the fix: bun bd test test/js/bun/shell/bunshell.test.ts -t "cmd subst stderr" passes 11 of 11.

PR: #43063

@coderabbitai

coderabbitai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: b960ff8c-c097-4b46-992e-d8d10bde020d

📥 Commits

Reviewing files that changed from the base of the PR and between b52d513 and ae31978.

📒 Files selected for processing (2)
  • src/runtime/shell/interpreter.rs
  • test/js/bun/shell/bunshell.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.


Walkthrough

Changes

Shell command substitution buffering

Layer / File(s) Summary
Environment and buffer contract
src/runtime/shell/interpreter.rs
ShellExecEnvKind removes Default and Normal. Command substitution owns stdout, while stderr and subshell or pipeline outputs follow output-specific buffering rules.
Command substitution behavior tests
test/js/bun/shell/bunshell.test.ts
Tests cover stderr forwarding, nested substitutions, pipelines, assignments, redirection, quiet execution, and ShellError output and exit codes.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to ae319

The command-substitution buffering change has no identified merge-blocking issue.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly states the main change: preserving stderr from command substitutions when shell output is captured. It is concise and specific.
Description check ✅ Passed The description explains the problem, fix, scope, testing, known limitations, and environment-specific results. It does not use the exact template headings, but it provides the required information in…

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — a small, well-scoped change to dupe_for_subshell that makes a cmd-subst env borrow the parent's stderr buffer instead of dropping an owned one, with tests covering the variant matrix.

What was reviewed:

  • The refactored bufio_for/stdout split against the old arms: Subshell/Pipeline stdout and all stderr paths resolve to the same Bufio as before; only cmd-subst stderr under Pipe changes (Owned -> Borrowed). The removed Normal variant had no callers.
  • The Bufio::Borrowed lifetime contract for cmd-subst stderr: it now points at the root env's buffer the same way subshell/pipeline children already did under .quiet(); child envs are freed in Expansion::child_done before the parent resumes, and the finalizer frees owned envs before deinit_embedded, so no new ordering is introduced.
  • The .exitCode(1) ordering test relies on TestBuilder's pre-existing catch-path doChecks (not awaited); the expects run synchronously so a failure still surfaces as a rejected promise — same shape as the existing exitCode(1) case at line 262.
Extended reasoning...

Overview

The PR changes one function in src/runtime/shell/interpreter.rs (ShellExecEnv::dupe_for_subshell) and adds a describe("cmd subst stderr") block to test/js/bun/shell/bunshell.test.ts. Previously the OutKind::Pipe arm of the shared bufio_for closure gave a CmdSubst child an owned Vec for both stdout and stderr; only stdout was ever read (Expansion::child_done), so stderr written inside $(...) was silently dropped whenever the root stderr was Pipe (.quiet(), .text(), ShellError.stderr). The fix hoists the cmd-subst special case out of the closure so it applies to stdout only, and makes Pipe always borrow the parent's buffer. The unused ShellExecEnvKind::Normal variant and its Default derive are deleted; the three call sites (Expansion.rs, Subshell.rs, Pipeline.rs) are unchanged.

Security risks

None identified. The change does not touch input parsing, path handling, credentials, or process spawning. The one memory-safety-relevant piece is a *mut Vec<u8> borrow, discussed below; it is a reuse of an existing pattern, not a new one.

Level of scrutiny

Moderate: it is native code with raw-pointer borrowing, so I traced the old and new arms explicitly. For Subshell/Pipeline kinds, stdout still goes through bufio_for(&io.stdout, ...) and every OutKind arm produces the same Bufio as before (Fd captured -> Borrowed, Fd uncaptured / Ignore -> Owned, Pipe -> Borrowed). For CmdSubst, stdout was Owned before (since the only caller passes OutKind::Pipe) and is unconditionally Owned now. stderr for CmdSubst under Pipe is the sole behavior change. The borrowed pointer targets the root env's _buffered_stderr (via buffered_stderr() which resolves through a borrow, so borrows do not chain); the root env is embedded in the boxed Interpreter, child envs are freed in Expansion::child_done, and the finalizer path frees all owned child envs before calling deinit_embedded(true). Subshell and pipeline children under .quiet() already relied on exactly this ordering, so the fix does not introduce a new lifetime obligation. The candidate concerns from the hunt (a write into a freed root buffer while a subprocess inside $(...) is alive; memory growth from retained stderr; the io argument being ignored for CmdSubst stdout) all resolve to either pre-existing behavior shared with pipelines or to the only caller's actual inputs.

Other factors

The tests cover builtin, subprocess, command-not-found, nested substitution, assignment, subshell, pipeline, ordering with surrounding writes, 2>&1 inside the substitution (guarding that the value is unaffected), and both the .quiet() result and ShellError.stderr from .throws(true).text(). They assert exact strings on {stdout, stderr, exitCode}. The messages asserted (ls: X: No such file or directory, bun: command not found: X) match strings already asserted elsewhere in the same file. The .exitCode(1) case goes through TestBuilder.run's catch path where doChecks is not awaited; that is a pre-existing harness quirk shared with the existing exitCode(1) test at line 262, and since the expects execute before the first await inside doChecks, a failure surfaces as a rejected promise that the test runner reports. No CODEOWNERS entry covers the changed files, no third-party objections are recorded in the timeline, and the bug hunt exited on dry_streak with zero findings.

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