shell: throw on Response/Request stream body used as redirect instead of delivering zero bytes

Closed
RO
2mo ago
+140-8

What does this PR do?

A Response whose body is a JS-driven ReadableStream (a start/pull source, TransformStream#readable, an async generator, pipeThrough(new CompressionStream(...))) used as a shell stdin redirect runs the command on zero bytes and reports success:

import { $ } from "bun";
const P = "hello-shell-stdin-0123456789";
const body = new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode(P)); c.close(); } });
console.log(await $`cat < ${new Response(body)} | wc -c`.text()); // "0", exit 0
console.log(await $`cat < ${new Response(new Blob([P]))} | wc -c`.text()); // "28"

The same Response's .text() returns the full payload. "Response as stdin" is the docs' headline shell example; the breakage is only the composed-stream case (string / Blob / blob.stream() / Bun.file / real fetch() bodies work).

Cause

Cmd::init_subproc_redirections (src/runtime/shell/states/Cmd.rs) and Builtin::init_redirections (src/runtime/shell/Builtin.rs) call Body::use_as_any_blob() / Body::use_() on the wrapped body. For a Value::Locked body whose stream is not backed by a synchronously-readable native source, to_any_blob_allow_promise() returns None and use_as_any_blob() falls back to .unwrap_or(Blob::default()) (src/runtime/webcore/Body.rs:1249-1251). Stdio::extract_blob then maps the empty blob to Stdio::Ignore. Value::Used and Value::Error took the same empty-blob fallback.

Fix

A new shell::util::check_body_for_redirect(body, global, stdin) runs to_blob_if_possible() (which converts native-backed streams to a Blob) and then throws instead of letting the caller substitute an empty blob:

  • Locked: ERR_INVALID_ARG_TYPE with Request/Response body is a ReadableStream, which cannot be redirected in Bun Shell yet. A stdin redirect appends Read it first: $`cmd < ${await response.bytes()}`; stdout/stderr get the bare sentence since a stream body can never be a write target.
  • Used: ERR_BODY_ALREADY_USED (Body already used), matching Bun.spawn({ stdin: response }) and the body mixin.
  • Error: the body's stored error is rethrown.

Both the spawned-subprocess path (used for cat on POSIX) and the builtin path (used for cat on Windows) call the helper. The branch now uses Body::from_request_or_response() so a Request is accepted alongside Response, matching what Builtin.rs and Bun.spawn already did (previously cat < ${request} threw Invalid JS object used in shell: [object Request]).

Body::use_as_any_blob() itself is unchanged; its other callers (fetch request bodies, wasm streaming, Response init) all sit after an explicit Locked/stream check of their own.

Known trade-off: $`echo ${request}` (a Request in argument position, not a redirect) now emits the parser's expected a command or assignment but got: "JSObjRef" instead of Invalid JS object used in shell: [object Request]. handle_template_value runs before parsing and cannot see the position, so registering Request as a redirect-capable value necessarily routes it there. Response, Blob, ReadableStream and ArrayBuffer already produce that parser message on main; a follow-up can improve the parser's JSObjRef-in-argument-position error for all of them at once.

Related

  • #30550 implements true ReadableStream pumping into shell stdin; once it lands this branch can route the Locked case through the same Stdio::ReadableStream path instead of throwing.
  • #33996 turns the bare ReadableStream redirect placeholder panic! into a throw and fixes the Yield::Failed trampoline so the builtin-path throw does not leave the interpreter keepalive pinned in a standalone script. (The test runner exits cleanly regardless.)
  • #18262 tracks the bare ReadableStream stdin case.

How did you verify your code works?

Added to test/js/bun/shell/bunshell.test.ts under deno_task > redirects > stdin from Request/Response, parameterized over Response and Request:

  • ReadableStream start(), TransformStream#readable, async-generator bodies as stdin each reject with /body is a ReadableStream/
  • an already-consumed body rejects with /already used/
  • string and Blob body controls still deliver the full payload
  • echo hi > ${new Response(stream)} rejects with the stream-body message and without the stdin hint
USE_SYSTEM_BUN=1 bun test bunshell.test.ts -t "stdin from Request/Response"   # 11 fail
bun bd test bunshell.test.ts -t "stdin from Request/Response"                 # 13 pass
bun bd test bunshell.test.ts                                                  # 427 pass, 0 fail

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