shell: throw instead of panic on ReadableStream redirect

Closed
RO
2mo ago
+96-12

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.