Skip to content

shell: throw instead of panic on ReadableStream redirect#33996

Open
robobun wants to merge 9 commits into
mainfrom
farm/b005df90/shell-readablestream-redirect-panic
Open

shell: throw instead of panic on ReadableStream redirect#33996
robobun wants to merge 9 commits into
mainfrom
farm/b005df90/shell-readablestream-redirect-panic

Conversation

@robobun

@robobun robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Passing a ReadableStream as a redirect target in Bun Shell aborts the whole process when the command resolves to a spawned subprocess:

import { $ } from "bun";
await $`cat < ${new Response("hello").body}`;
// panic: TODO SHELL READABLE STREAM

The builtin path (echo hi > ${stream}) already threw Unknown JS value used in shell: [object ReadableStream]. The subprocess path in Cmd::init_subproc_redirections instead had a literal panic!("TODO SHELL READABLE STREAM") that ran before any direction check, so every direction (<, >, 2>) aborted. On POSIX cat and cp are posix_disabled builtins, so they always take the subprocess path; any spawned command hits it too.

Fix

  • src/runtime/shell/states/Cmd.rs: drop the ReadableStream placeholder branch so the value falls through to the existing Unknown JS value used in shell throw, matching Builtin::init_redirections.
  • src/runtime/shell/Yield.rs, src/runtime/shell/interpreter.rs: on Yield::Failed the trampoline now calls Interpreter::reject_pending_exception and returns immediately instead of draining pipelines. That method takes the pending exception, rejects the ShellPromise with it, and releases the event-loop keepalive. It is guarded by a rejected flag on InterpreterFlags so a second Yield::Failed reached from a separate .run() invocation (e.g. an IOWriter error loop) swallows the follow-up exception instead of reporting it as unhandled. has_pending_activity is left pinned because a pipeline sibling's subprocess / glob task / builtin threadpool task may still hold a raw *mut Interpreter.
  • src/js/builtins/shell.ts: the cached reject callback was never invoked from native; repurpose it to forward the error value straight to the promise reject so the caller's catch receives the thrown error.

Known limitation

spawned-cmd | throwing-cmd (a pipeline sibling that already spawned before the throw) leaves the interpreter GC-pinned and the sibling's pipe read-end held open. If the sibling writes more than the socketpair buffer (~64KB) it blocks in write(2) and the process hangs after the catch fires. The builtin variant of this already hung on main pre-PR. Resolving it soundly needs a detach-then-release walk (close unwrapped pipe ends, kill/detach live subprocesses, cancel in-flight shell tasks, then release the pin), which belongs in a follow-up alongside #30550.

Verification

8 cases added to bunshell.test.ts (stdin / stdout / stderr to subprocess, Response.body, pipeline with Bun.gc(true), first-in-pipeline, both-sides-of-pipeline, && async re-entry). Each spawns a child that catches the error and asserts a clean exit.

USE_SYSTEM_BUN=1 bun test bunshell.test.ts -t "ReadableStream as redirect"  # 8 fail (SIGABRT / exit 134)
bun bd test bunshell.test.ts -t "ReadableStream as redirect"                # 8 pass

Full bunshell.test.ts is green (394 pass, 0 fail).

Related: #18262. #30550 is the full implementation of ReadableStream as shell stdin; this change is the minimal safety fix so the placeholder no longer crashes the process in the meantime.

…palive on sync throw

Redirecting a ReadableStream to a subprocess in Bun Shell
(`$`cmd < ${stream}``) hit an explicit `panic!("TODO SHELL READABLE
STREAM")` in the subproc redirect dispatch, aborting the whole process.
Drop the placeholder branch so the value falls through to the existing
"Unknown JS value used in shell" TypeError, matching the builtin path.

Separately, `run_from_js` returned the thrown exception without
releasing the event-loop keepalive or undoing the pending-activity
increment, so even when the error was caught the process never exited.
Mirror the cleanup from `finish()` on that path.
@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:33 AM PT - Jul 12th, 2026

@robobun, your commit 8983519 has 2 failures in Build #72113 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33996

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

bun-33996 --bun

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Shell failures now route pending JavaScript exceptions through the shell promise rejection callback, while early setup failures disable keep-alive state. ReadableStream redirects use the existing unknown-value error path and are covered across multiple redirect forms.

Changes

Shell runtime behavior

Layer / File(s) Summary
Failure rejection flow
src/runtime/shell/Yield.rs, src/runtime/shell/interpreter.rs, src/js/builtins/shell.ts
Failed yields reject pending exceptions through Interpreter::reject_pending_exception; shell promises now forward the resulting error directly, and early setup failures disable keep_alive.
ReadableStream redirect rejection
src/runtime/shell/states/Cmd.rs, test/js/bun/shell/bunshell.test.ts
ReadableStream redirects no longer reach the removed panic branch and are tested across stdin, stdout, stderr, pipeline, and re-entry cases using the unknown-value error path.
🚥 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 matches the main change: replacing a ReadableStream panic with a throw in shell redirects.
Description check ✅ Passed It explains the change and includes verification, though it uses custom headings instead of the template's exact section names.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/shell/interpreter.rs`:
- Around line 1358-1364: Remove the unconditional
Self::decr_pending_activity_flag call from the synchronous exception branch in
the interpreter flow, or guard it so it runs only when finish() will not later
execute; preserve keep_alive disabling and the thrown-error return. Ensure
async_cmd_done() → finish() remains the sole decrement path when async shell
work is still in flight.

In `@test/js/bun/shell/bunshell.test.ts`:
- Around line 1556-1562: Update the parameterized test under “ReadableStream as
redirect target throws instead of panicking” to use concurrent execution, such
as test.concurrent.each, while preserving the existing cases and assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4c93c8ad-cafa-4a7f-985c-79a0ce717398

📥 Commits

Reviewing files that changed from the base of the PR and between 8624c2b and 580a810.

📒 Files selected for processing (3)
  • src/runtime/shell/interpreter.rs
  • src/runtime/shell/states/Cmd.rs
  • test/js/bun/shell/bunshell.test.ts
💤 Files with no reviewable changes (1)
  • src/runtime/shell/states/Cmd.rs

Comment thread src/runtime/shell/interpreter.rs Outdated
Comment thread test/js/bun/shell/bunshell.test.ts
The decrement let GC collect the interpreter while a spawned pipeline
sibling's PipeReader still held a raw pointer into it, a UAF under ASAN.
Release only the keepalive; the GC pin stays until async IO drains.

Also: test.concurrent.each, add pipeline coverage with Bun.gc(true).
Comment thread src/runtime/shell/interpreter.rs Outdated
Comment thread test/js/bun/shell/bunshell.test.ts Outdated
robobun added 2 commits July 12, 2026 04:44
…line

Handling the thrown exception only in run_from_js missed every async
re-entry (Cmd::on_exit, PipeReader, IOWriter), so cmd0 && cmd1 < ${stream}
left a pending exception and hung. Yield::run now calls
Interpreter::reject_pending_exception on Failed, which takes the
exception, rejects the ShellPromise with it, and releases the keepalive.
has_pending_activity stays non-zero so GC cannot collect the interpreter
while a pipeline sibling's PipeReader still holds a raw pointer into it.

The JS-side reject callback was stored but never invoked from native;
repurpose it to forward its argument straight to the promise reject.

Drop the not.toContain('panic') assertion per repo convention; the
combined exitCode/signalCode/stdout object already fails on an abort.

@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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/runtime/shell/interpreter.rs:1358-1364d8911d6 traded the UAF for a leak: has_pending_activity is incremented at line 1350 and now never balanced on this path (the only decrement is DecrOnDrop inside finish(), which is unreachable after return Err), so hasPendingActivity() returns true forever and the JS wrapper — thus the whole Interpreter box — is permanently GC-pinned per failed invocation. The comment justifies the pin for a live PipeReader, but in 4 of the 5 new test cases (and the pre-existing builtin echo > ${stream} path) the throw happens before anything spawns, so there is nothing to drain and the pin is pure leak. The shared Yield::Failed teardown proposed in the earlier thread — kill/detach any live Exec::Subproc nodes, then decrement — resolves both this and the pipeline UAF.

    Extended reasoning...

    What the bug is

    Commit d8911d6 removed decr_pending_activity_flag from the sync-throw branch of run_from_js to avoid the pipeline UAF, leaving only keep_alive.disable(). But now the counter incremented at line 1350 is never balanced on this path: the sole decrement site in the file is the DecrOnDrop guard inside finish() (line 1276), and finish() is only reached via on_root_child_done (root Script completes) or async_cmd_done — neither of which fires when init_subproc_redirections throws before anything spawned and the trampoline returns on Yield::failed(). So has_pending_activity stays at 1 forever, hasPendingActivity() (line 1617) returns true forever, and the JS wrapper is permanently pinned against GC.

    Why the earlier comment doesn't cover this

    The existing thread on this line was written against 580a810, which did decrement here, and describes the UAF from dropping the counter to 0 while a pipeline sibling's PipeReader still holds a raw backref. d8911d6 removed the decrement, so that analysis is stale against current HEAD. This finding is the inverse failure mode: by not decrementing, the non-pipeline cases now leak. The author's reply ("has_pending_activity stays non-zero so the interpreter is pinned until async IO drains") assumes there is async IO to drain — but in the common case there isn't, so nothing ever brings the counter back to 0.

    Step-by-step proof (non-pipeline case)

    Take the first test case, ${process.execPath} -e 0 < ${stream}:

    1. run_from_js line 1350: incr_pending_activity_flag → counter = 1.
    2. Script::start(...).run(self) reaches Cmd::transition_to_exec for the single command. init_subproc_redirections sees the ReadableStream JS value, falls through to Err(global.throw("Unknown JS value used in shell: ...")), and transition_to_exec returns Yield::failed() before ShellSubprocess::spawn_async is called. No subprocess, no PipeReader, no Exec::Subprocme.exec was never assigned.
    3. Yield::run treats Failed like Suspended: it drains pipelines (none) and returns.
    4. Back in run_from_js line 1358: global_this.has_exception() is true → keep_alive.disable()return Err(JsError::Thrown). Counter still = 1.
    5. In src/js/builtins/shell.ts #run(), interp is a local (let interp = createShellInterpreter(...)) that is never stored on this. When interp.run() throws, the local goes out of scope. Nothing else roots the wrapper.
    6. Shell.classes.ts sets hasPendingActivity: true, so the GC hook calls has_pending_activity() (line 1616-1618), which returns 1 > 0 = true. The wrapper is pinned.
    7. Nothing will ever call finish(): the root Script never reached child_done (its Cmd child is stuck in Exec state with no exec), and there are no async cmds. Counter stays 1 for the life of the process.
    8. finalize()deinit_from_finalizer never runs. The Interpreter box (nodes Vec<Node>, root_shell env maps, root_io, jsobjs, plus the wrapper's cached resolve/reject values) leaks.

    The same trace applies to > ${stream}, 2> ${stream}, < ${new Response("x").body}, and the pre-existing builtin path echo > ${stream} (which threw from Builtin::init_redirections before this PR and already leaked — the PR widens the surface to the subprocess path).

    Why the pin is only sometimes needed

    The comment at 1359-1361 is correct only for the pipeline case (cmd0 | cmd1 < ${stream}), where cmd0 has already spawned and its ShellSubprocess/PipeReader holds a raw interp backref — there, dropping to 0 lets GC free the interpreter under a live callback (the ASAN UAF the author reproduced). But in the 4 non-pipeline test cases the throw happens before spawn_async, so there is no backref to protect and the pin is pure leak. An unconditional "leave it non-zero" is the wrong fix for both.

    Impact

    Per-invocation heap leak on a user-reachable error path. A long-running process that catches these errors and continues (retry loop, REPL, dev server evaluating user snippets) accumulates one full Interpreter allocation per failure until process exit. Not a crash, but the PR's stated goal is to make this error path safe instead of aborting, and unconditionally leaking the interpreter on every such error is a concrete defect in that fix.

    How to fix

    Route Yield::Failed through a shared teardown that knows whether async work is actually in flight — the same fix already proposed in the earlier thread for the async-reentry hang. Concretely: after the trampoline returns with a pending exception, walk nodes for any live Exec::Subproc and kill/detach it (nulling its interp backref), then decrement has_pending_activity. In the non-pipeline case that walk finds nothing and the decrement is safe; in the pipeline case the subprocess is torn down first so the decrement is also safe. A narrower alternative for this call site alone: decrement only when no node holds a live Exec::Subproc, and leave the pipeline case pinned as-is — but that still leaves the async-reentry hang from the earlier thread, so the shared path is the better investment.

…cess is live

reject_pending_exception now walks the node arena for any Cmd with a live
Exec::Subproc and only decrements the GC-pin counter when none is found,
so the common throw-before-spawn case no longer leaks the Interpreter
box while the pipeline-sibling case stays pinned against UAF.
@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed in 82c82c3. reject_pending_exception (called from the trampoline on Yield::Failed since c427f2c) now walks the node arena for any Cmd with a live Exec::Subproc and decrements has_pending_activity only when none is found. The throw-before-spawn case (cat < ${stream} and the other non-pipeline variants) no longer leaks; verified with heapStats().objectTypeCounts.ShellInterpreter over 50 iterations (0 leaked). The pipeline case stays pinned while the sibling's PipeReader backref is live, same as before, and still passes the Bun.gc(true) pipeline test without UAF.

A full teardown (kill/detach every live subprocess before decrementing) would let the pipeline case collect too, but that touches the subprocess lifecycle enough that it belongs with #30550 or a dedicated follow-up.

Comment thread src/runtime/shell/Yield.rs
Comment thread src/runtime/shell/interpreter.rs
robobun and others added 2 commits July 12, 2026 05:44
…ines

Continuing to drain after the promise was rejected started downstream
commands whose PipeReader could outlive the interpreter (UAF when the
failing command was first) or re-entered reject_pending_exception with
the callback already cleared (spurious uncaught error when both sides
threw). Returning immediately after reject leaves unspawned pipeline
siblings unstarted; already-spawned ones stay pinned by the
has_live_subproc check. Adds first-in-pipeline and both-sides test
cases.
@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the shell changes are green on all lanes across builds 72050, 72080, 72092, and 72113 (bunshell.test.ts 8/8 passes everywhere). Remaining reds are unrelated and tracked separately:

Yellow retries (napi GC timing, spawn timeout, bake HMR, install migration/registry, repl EPIPE, http-connect, zlib leak, serve-file, no-orphans, bun-run-dir) are pre-existing Windows/aarch64/asan flakes and passed on retry.

Ready for review.

Comment thread src/runtime/shell/interpreter.rs Outdated
…Failed

The Exec::Subproc scan missed other raw *mut Interpreter backref holders
(ShellGlobTask, builtin ShellTask, ShellCondExprStatTask), so
ls * | cmd < ${stream} decremented to 0 while a glob task was still on
the threadpool and UAF'd when it bounced back. A sound predicate would
need to enumerate every such holder; leave the GC pin in place on this
path (one Interpreter leaked per failed invocation) until per-backref
refcounting lands.

@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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/runtime/shell/interpreter.rs:1398-1399 — This comment narrates the absence of the deleted if global_this.has_exception() block, which src/CLAUDE.md prohibits ("NEVER add comments to deleted code blocks"), and the root CLAUDE.md says bug history belongs in the PR description — where this rationale already is. Delete the two lines, or if you want to keep something, trim it to just the invariant ("Yield::run rejects the promise on Yield::Failed") without the "no post-run exception check is needed here" clause.

    Extended reasoning...

    What the issue is

    The diff replaces the deleted post-run exception check with a comment explaining why it was removed:

    Script::start(self, root).run(self);
    // `Yield::run` rejects the promise and releases keepalive on
    // `Yield::Failed`, so no post-run exception check is needed here.

    Two repo conventions apply:

    • src/CLAUDE.md line 21: "NEVER add comments to deleted code blocks."
    • Root CLAUDE.md (Code style & idioms): "Comments carry only durable non-obvious content: invariants, ownership/lifetime contracts, SAFETY justifications, deliberate deviations from upstream. No narrating what the code does, no bug history — that belongs in the PR description."

    Why this comment falls under the rule

    Walk through what a reader without git history sees. Before this PR, lines 1397-1399 were:

    if global_this.has_exception() {
        return Err(crate::jsc::JsError::Thrown);
    }

    The diff deletes that block and inserts, in the same position, a comment whose second clause is "so no post-run exception check is needed here." That phrase only makes sense as a reference to the check that used to be there — it's answering "why isn't there an exception check here?", which is a question a reader only asks if they know one used to exist. That is exactly the pattern src/CLAUDE.md's rule targets: a comment standing in for deleted code, recording the fix history rather than a durable invariant of the surrounding code.

    Why the rule exists here

    The rationale ("the trampoline now handles Yield::Failed itself") is genuine, but it's already stated in three places that outlive it better than an inline comment: the PR description's Fix section, the doc comment on Interpreter::reject_pending_exception at interpreter.rs:1259-1261, and the inline comment inside the Yield::Failed arm at Yield.rs:143-145. A future reader of run_from_js who hasn't seen this diff has no reason to expect an exception check after .run(), so the comment answers a question nobody will ask — and per the root CLAUDE.md, that history belongs in the PR description, not the code.

    Impact

    None functionally — this is purely a style/convention nit with no runtime effect.

    How to fix

    Delete the two comment lines:

             Script::start(self, root).run(self);
    -        // `Yield::run` rejects the promise and releases keepalive on
    -        // `Yield::Failed`, so no post-run exception check is needed here.
    
             Ok(crate::jsc::JSValue::UNDEFINED)

    If you want to keep a pointer for readers, trim it to the invariant only — e.g. "Yield::run rejects the ShellPromise on Yield::Failed." — dropping the "so no post-run exception check is needed here" clause that references the deleted code.

Comment thread src/runtime/shell/interpreter.rs
Comment thread src/runtime/shell/Yield.rs
A second Yield::Failed reached from a separate .run() invocation
(IOWriter::on_error loops call run_yield per child) would re-enter
reject_pending_exception with the reject callback already cleared and
report the follow-up exception as unhandled. Gate on a rejected flag
and swallow subsequent exceptions once the promise has settled.

Also drop the comment in run_from_js that referenced the removed
post-run exception check.
@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

Re: the run_from_js comment narrating the deleted exception check: removed in 8983519.

Comment thread src/runtime/shell/Yield.rs
robobun added a commit that referenced this pull request Jul 24, 2026
Moving the check into parse_atom() covers every caller: parse_simple_cmd,
both parse_assign value arms (FOO=${resp} previously said 'Expected an
atom'), and the [[ ]] operand path. parse_redirect consumes JSObjRef via
r#match before calling parse_atom, so valid redirects are unaffected.

Reworded the message to name the redirect operators rather than a concrete
example, since '> ${value}' is rejected for Blob/Response and '< ${value}'
with a ReadableStream currently panics (pre-existing, #33996).

Added positive assertions for the assignment-value, glued-value, [[ ]]
operand and [[ ]] operator positions across all five types.
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