Repository navigation
shell(which): render the output once so captured and streamed stdout print the same not-found line - #39163
shell(which): render the output once so captured and streamed stdout print the same not-found line#39163robobun wants to merge 1 commit into
Conversation
|
Warning Review limit reached
Next review available in: 1 minute Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
|
Updated 4:05 PM PT - Aug 15th, 2026
❌ @robobun, your commit a8fbd4c has 2 failures in
🧪 To try this PR locally: bunx bun-pr 39163That installs a local version of the PR into your bun-39163 --bun |
|
Status
|
There was a problem hiding this comment.
LGTM — small, well-scoped consistency fix with thorough test coverage.
What was reviewed:
- Verified
Which::linepreserves the semantics of both collapsed branches:had_not_found |= !foundmatches the old per-branch assignments,waiting_writeis still set on both outcomes, andChildPtr::new(a pureconst fn) is safely moved into the enqueue branch. - Confirmed
BuiltinIO::enqueue(child, &[u8], safeguard)matches the new call site, and the unprefixed<arg> not foundform is whatbunshell.test.ts:1187already asserts for the streaming path. - Checked that switching from
format_args!("{}", bstr::BStr::new(...))to raw bytes only affects non-UTF-8 paths/args, where writing raw bytes is the correct shell behavior anyway. - Tests cover both the buffer-backed and fd-backed paths with exact-string assertions, use
test.concurrent/tempDir/bunEnvper harness conventions, and the existing long-path test's prefix-stripping workaround is removed rather than left behind.
Extended reasoning...
Overview
This PR fixes an output inconsistency in the shell's which builtin: the not-found line was which: <arg> not found when stdout was a captured buffer (.quiet(), .text(), command substitution, > ${buf}) but <arg> not found when stdout was an fd (tty, pipe, file redirect). The cause was two independent formatting sites — the sync path in start() passed Some(Kind::Which) to fmt_error_arena (adding the prefix), the streaming path in next() passed None.
The fix extracts a single Which::line(path_env, cwd, arg) -> (Vec<u8>, bool) helper that both paths call, and both write exactly those bytes. The unprefixed form is kept, which matches the existing bunshell.test.ts assertion (line 1187), zsh's builtin which, and the repo convention that the <builtin>: prefix is for stderr diagnostics — this line is stdout output.
Net diff: ~70 lines removed, ~30 added in which.rs; 6 new test cases covering every stdout collection mode plus the existing long-path test tightened to no longer strip an optional prefix.
Security risks
None. This is output formatting for a shell builtin; no parsing of untrusted input, no auth/crypto/permissions, no new syscalls.
Level of scrutiny
Low-to-medium. The change is a mechanical dedup of two near-identical code paths into one helper, in a leaf builtin with no cross-module reach. I verified each piece of the collapsed control flow against the original:
had_not_found |= !foundin bothstart()andnext()reproduces the old= trueon the not-found branch and no-op on the found branch.waiting_write = trueis now set unconditionally innext()— previously set in both match arms, so identical.ChildPtr::newis a pureconst fn(IOWriter.rs:57), so moving it inside theneeds_io()branch has no side effect.BuiltinIO::enqueue(Builtin.rs:406) takes&[u8]and the safeguard, matching the new call replacingenqueue_fmt.- The now-unused
Kindimport is removed.
The only semantic change beyond dropping the prefix is that resolved paths and args are now written as raw bytes rather than through bstr::BStr's Display impl. That only differs for non-UTF-8 byte sequences, and for a shell path-printing builtin, raw bytes is the correct behavior.
Other factors
- Tests follow harness conventions:
test.concurrentfor independent cases,tempDirwithusing,.env(bunEnv), exacttoBeassertions on stdout before exit code. TheBun.which(bunExe())reference pattern is the same onebunshell.test.ts:1184-1187already relies on. - The PR description reports 6/9 new cases fail on the unfixed build and all pass with the fix, plus
bunshell.test.ts -t which,pipeline_stack.test.ts,exec.test.ts, and source-lints all pass. - No CODEOWNERS entry covers
src/runtime/shell/. - No prior human review comments or outstanding requests on the PR.
There was a problem hiding this comment.
LGTM — the doc-comment nit from the earlier pass is resolved in e2ef46f (both comments are gone), and no new issues.
What was reviewed:
- Both write paths (
start()sync-buffer,next()streaming) now go throughSelf::line; control flow is preserved —had_not_found/waiting_writestill updated,arg_completestill called on the no-io fallback. BuiltinIO::enqueuecopies its&[u8]into the IOWriter's buffer (extend_from_slice), so passing the localline: Vec<u8>is sound.- Tests cover the capture-mode matrix (
.quiet(),.text(),$(),> ${Buffer},> file) plus all-not-found; the existing long-path test drops itswhich:workaround.
Extended reasoning...
Overview
Two-file change: src/runtime/shell/builtin/which.rs collapses the two duplicated formatting paths (sync captured-buffer in start(), streaming fd in next()) into one Which::line(arg, resolved) -> Vec<u8> helper that both paths write verbatim, and test/js/bun/shell/commands/which.test.ts adds a describe block asserting identical bytes across every stdout collection mode. Net: ~90 lines removed from the Rust, ~45 lines of tests added, and the unused Kind import dropped.
Deltas since the last review
My previous inline comment flagged two redundant doc-comment clauses. In e2ef46f the author dropped the doc comments entirely (commit message: "take the resolve result in line() and drop the doc comments") and simplified line() to take the already-computed Option<Vec<u8>> instead of re-resolving. The current file has no module-doc addition and no doc on line(), so the nit is fully addressed. The comment-cop bot threads are marked resolved.
Security risks
None. This is stdout formatting for a shell builtin; no parsing of untrusted input beyond what bun_which::which already handled, no new syscalls, no auth/crypto surface.
Level of scrutiny
Low-to-medium. It is a small refactor of a single builtin's output path with a well-defined behavioral contract (zsh's <arg> not found). The one nontrivial thing to verify was that swapping enqueue_fmt for enqueue with a local Vec<u8> is lifetime-safe — IOWriter::enqueue does s.buf.extend_from_slice(buf) before returning, so the caller's buffer is copied and can drop immediately. Control flow is byte-for-byte preserved: had_not_found |= resolved.is_none() replaces the per-arm = true, waiting_write is still set before enqueue, and the no-io fallback in next() still calls arg_complete.
Other factors
The test additions follow harness conventions (test.concurrent, tempDir, bunEnv, exit-code asserted last) and cover the variant matrix REVIEW.md asks for. The existing long-path test was tightened rather than left with a lenient regex strip. The PR description documents that 6/9 new cases fail on the unfixed build and that bunshell.test.ts -t which still passes, so the choice of unprefixed output is consistent with pre-existing streaming-path assertions.
…refix on captured stdout
The builtin had two implementations of its output: a synchronous loop for
a captured stdout (.quiet(), .text(), $(...), > ${buf}) and a per-argument
state machine for an fd (tty, pipe, file redirect). They had drifted: the
captured one printed "which: <arg> not found", the fd one "<arg> not
found". Replace both with one render() that builds every line (and the
no-argument newline) into a single buffer, which start() then either
enqueues on the IOWriter or appends to the captured buffer, the same
shape pwd, basename and dirname use. The only state left is the exit code
to report once the write completes. Lines are now written as the raw
bytes of the path or argument instead of through a lossy UTF-8 display,
like the other builtins. Which no longer needs to be boxed.
e2ef46f to
a8fbd4c
Compare
There was a problem hiding this comment.
LGTM — the collapsed render() in a8fbd4c is simpler than the two-path version I commented on earlier, and the doc-comment nit is moot now.
What was reviewed:
which.rs: singlerender()builds the whole output; both fd and captured-buffer paths write those bytes verbatim, so the format cannot drift.on_io_writer_chunknow just reads back the storedexit_code.Builtin.rs:Whichmoved boxed→inline — the struct is now oneExitCode, so noImpl/Nodesize concern.- Tests: cover every capture mode plus a file redirect (fd path), no-args, all-not-found, and non-UTF-8 arg bytes on both paths; the long-path test no longer strips the prefix. Checked that
Bun.which(bunExe())and the builtin agree on an absolute-path arg.
Extended reasoning...
Overview
The shell which builtin printed which: <arg> not found on the captured-buffer stdout path and <arg> not found on the fd path, because the two paths formatted the line separately and only one passed Some(Kind::Which) to fmt_error_arena. The fix collapses the ~175-line state machine in src/runtime/shell/builtin/which.rs into a ~70-line implementation: render() resolves every arg and returns (Vec<u8>, ExitCode); start() either enqueues that buffer once on the IOWriter or writes it via write_no_io. Which shrinks to { exit_code: ExitCode } and moves from the boxed to the inline group in shell_builtins!. Nine new tests in which.test.ts pin the exact bytes across .quiet(), .text(), command substitution, > ${Buffer}, > file, and non-UTF-8 args.
Security risks
None. No new unsafe blocks, no untrusted-input parsing beyond what bun_which::which already did, no new syscalls or fd handling. The output buffer is bounded by argc × PATH_MAX.
Level of scrutiny
Moderate — this rewrites a builtin's control flow, not just the format string. I checked: (1) BuiltinIO::enqueue copies its &[u8] argument (other callers pass locals the same way), so dropping out after enqueue is fine; (2) render() holds only shared borrows via Builtin::of/Builtin::shell and arg_bytes's lifetime is decoupled from &self per its doc, so no aliasing with the interior-mutability accessors; (3) moving Which to inline is safe since it's now one ExitCode — smaller than several existing inline variants; (4) the fd path now writes one chunk instead of one per arg, which matches pwd/basename/dirname/echo and is fine for which's bounded output. The PR description's "control flow is unchanged" line is stale relative to a8fbd4c, but the head commit's own message documents the single-buffer change.
Other factors
My earlier inline nit (redundant doc-comment clauses) targeted an intermediate revision and is resolved — those comments and the line() helper they described no longer exist. All comment-cop threads are resolved. CI on e2ef46f was 178/179 green with the one failure unrelated (bake dev-server on win2019); a8fbd4c is a pure simplification on top of that. Tests follow harness conventions (tempDir, bunEnv, test.concurrent, no sleeps, no network) and assert exact bytes including the exit code.
|
Closing: superseded by #43055. That PR moves the not-found line to stderr and formats it in one place, so the captured and the streamed text are equal there too. The raw-bytes change from this PR (no U+FFFD for invalid UTF-8 in the output) is not in #43055 and can be filed on its own. If the maintainers prefer the line on stdout, reopen this PR and close #43055. |
Problem
whichbuiltin prints two different lines for an arg it cannot resolve, depending on how stdout is collected:.quiet(),.text(),$(which ...),which ... > ${buf}:which: nope not found| cmd,> file:nope not foundsrc/runtime/shell/builtin/which.rshad two implementations of its output. When stdout is a captured buffer,startresolved every arg in a loop and formatted the line withfmt_error_arena(.., Some(Kind::Which), ..), which adds the prefix. When stdout is an fd, a per-arg state machine (State::MultiArgs,next,arg_complete) formatted the line withenqueue_fmt(.., None, ..). The two had disagreed since the builtin was added;which.test.tson main papers over it by stripping an optionalwhich:with a regex.Fix
whichnow renders its whole output once.Which::renderbuilds one buffer with a line per arg (the resolved path, or<arg> not found) and the exit code;starteither enqueues that buffer on the IOWriter or appends it to the captured buffer. This is the shapepwd,basenameanddirnamealready use, and with one formatting site the two output modes cannot drift again. The only state left is the exit code to report when the write completes, soWhichmoves from theboxed:to theinline:row of the builtin table inBuiltin.rs.State::{Idle, OneArg, MultiArgs},next,arg_complete, thearghelper and both formatter call sites.SearchEnvfrom shell: return which's PATH and cwd lookup as a struct instead of a tuple #39131 is unchanged and is now loaded once per command in both modes (the fd mode used to reload it per arg). which.rs goes from 227 lines to 101.bunshell.test.ts"which" has asserted since the builtin was added, it is zsh's wording for its builtinwhich, and in this codebase the"<builtin>: "prefix (Kind::as_str) is the convention for stderr diagnostics, while this line is regular stdout output.err.errnoas the exit code exactly as before (the write-error exit convention is shell: exit 1 on builtin failure instead of raw errno; fix cd ENOENT message #32278's subject).bstr::BStr'sDisplay, which replaces invalid UTF-8 with U+FFFD; the other builtins write their stdout byte for byte and so did this one before the Rust port.test/js/bun/shell/commands/which.test.ts: a describe block asserts the same line for every buffer-backed mode (.quiet(),.text(), command substitution,> ${Buffer}) and for a file redirect (IOWriter mode), plus the all-not-found and no-arg cases; a second block feeds a0xffbyte in through$(cat arg.bin)and compares hex output in both modes, which pins the raw-bytes change (the unfixed build producesef bf bdin both modes). The long-path test no longer strips an optional prefix, and the comment in shell: return which's PATH and cwd lookup as a struct instead of a tuple #39131's streamed-stdout test is updated since there is no longer a one-write-per-arg path. 8 of 14 cases fail on the unfixed build; all 14 pass with the fix.bunshell.test.ts(423 pass),pipeline_stack.test.ts,exec.test.ts, the rest oftest/js/bun/shell/commands/, andtest/internal/source-lints/.Background
needs_io()returns a safeguard and the builtin enqueues bytes on anIOWriter, which copies them and later calls the builtin'son_io_writer_chunk; when stdout is a captured buffer (.quiet(), command substitution, a> ${buf}redirect), the builtin appends synchronously withwrite_no_ioand finishes on the spot. Small builtins build their output first and then pick one of the two writes; until nowwhichinstead had a separate implementation per write.Builtin::fmt_error_arena(.., Option<Kind>, ..)andBuiltinIO::enqueue_fmt(.., Option<Kind>, ..)prepend"<builtin name>: "when given aKind; builtins use that for their error messages on stderr.shell_builtins!table inBuiltin.rsstoresinline:builtins' state directly in the per-command enum andboxed:ones behind aBox, to keep the common node small;Whichis now two bytes of state.Repro on 1.4.0
Earlier version of this PR
The first revision kept both output paths and only routed them through a shared line-building helper (changing
Some(Kind::Which)to the unprefixed form). Self-review pointed out that the two paths are themselves the mechanism of the bug and that the one-buffer shape used by the neighbouring builtins is both smaller and removes the drift structurally, and #39131 landed on main in the meantime, so the branch was rebased and the src side replaced with the rewrite described above. The tests from that revision are kept and extended with the byte-level cases.