Repository navigation
Conversation
…, for builtins too `Interpreter::redirect_target` classifies a `Bun.file(fd)`, bare or as the Blob body of a Response, as `RedirectTarget::Fd`. The builtin and the spawned redirect setup both duplicate the descriptor, so a descriptor that is not open fails the command before the body is taken. A builtin had stored such a target as `BuiltinIO::Blob`, which accepted every write and stored nothing. That kind is removed. What is left in the object arm of a builtin is readable as stdin and refused as an output, also when it is the body of a Response.
|
Updated 2:49 PM PT - Oct 24th, 2026
✅ @robobun, your commit 57b5968ca927d9ae327d6196ae653ac90d76285a passed in 🧪 To try this PR locally: bunx bun-pr 46796That installs a local version of the PR into your bun-46796 --bun |
|
Status: the fix is in this PR, #46796. It is stacked on #46687. Merge #46307 first (see Downsides in the PR body). How it was reproduced (Linux x64, on bun 1.4.2, on main 6292c65 and on the head of #46687): import { $ } from "bun";
import { closeSync, openSync, readFileSync } from "node:fs";
const fd = openSync("out.txt", "w");
await $`echo from-builtin > ${Bun.file(fd)}`;
await $`sh -c 'echo from-spawned' > ${Bun.file(fd)}`;
closeSync(fd);
console.log(JSON.stringify(readFileSync("out.txt", "utf8")));
// before: "from-spawned\n"
// after: "from-builtin\nfrom-spawned\n"Both commands exit 0. In CI on 57b5968: no job failed. |
There was a problem hiding this comment.
Beyond the inline findings, I also checked ownership of the dup'd descriptor on every path in both redirect setups: the Fd arm dups before consume_body, and the dup is handed straight to CowFd (closed in deref) or to IOReader/IOWriter (which own and close it), with the only early returns coming before the dup, so no descriptor leaks on the EBADF or ambiguous-redirect paths. The ruled-out wrong-direction Bun.stdin/Bun.stdout case and the dup-lands-on-a-closed-stdio-number case were examined too and are pre-existing behaviour of the spawned path, not introduced here.
Extended reasoning...
The change touches the shell interpreter's redirect classifier (src/runtime/shell/interpreter.rs), the builtin redirect setup (src/runtime/shell/Builtin.rs), and the spawned-command redirect setup (src/runtime/shell/states/Cmd.rs), adding a RedirectTarget::Fd variant that dups a JS-owned descriptor, removing the no-op BuiltinIO::Blob output arm, and adding a 41-case test matrix. It touches no auth, crypto or injection surface; the sensitive surface is fd ownership and blocking I/O on the JS thread. Two confirmed findings (a JS-thread hang on a blocking pipe with more than the free room, and a builtin cat < Bun.file(fd) on a regular file now exiting 1) already mark this for human review, so this note only records the fd-ownership audit that came back clean.
Findings marked 🟡 are optional suggestions and need no follow-up push.
A nonblocking descriptor takes the whole write while a reader on the same thread drains the pipe. The blocking descriptor is a todo row: on Linux that write waits in write(2), and the one-slot write of #46307 ends the wait.
|
Replies are on the two review threads. In short:
|
There was a problem hiding this comment.
Code review found no new issues
No new issues were found in this update; 1 finding from earlier reviews is still open above.
Still open from earlier reviews (1):
- 🔴
src/runtime/shell/Builtin.rs:605—A builtin writing more than a blocking pipe's free room to a Bun.file(fd) can hang the JS thread for good when the only…
If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.
|
I resolved the thread at |
Stacked on #46687 (the base branch). Merge #46307 first.
Problem
echo x > ${Bun.file(fd)}or> ${Bun.stdout}exits 0 and writes nothing. The docs listBun.file(fd)as an output target.Builtin::init_redirectionsstores it asBuiltinIO::Blob, whose write arm only returnsOk(buf.len())(src/runtime/shell/Builtin.rs:370).Responsecheck at:684is inverted. Anew Blobbody is lost. ABun.file(fd)body throwsCannot redirect stdout/stderr to an immutable blob. Expected a file.Fix
Interpreter::redirect_targetclassifies aBun.file(fd), bare or as aResponsebody, as the newRedirectTarget::Fd. Both redirect setups use a duplicate of it.BuiltinIO::Blobis removed. A builtin refuses any other Blob or body as an output, unread.test/js/bun/shell/file-io.test.ts(42 new tests, 30 fail on the base) and 24 other shell suites.Background
${Bun.file(path)}is path text since template time. OnlyBun.file(fd)arrives as an object.> ${Response(...)}redirect #35318): the two setups stay different.Downsides
write(2)on the JS thread (Linux). Before: nothing written.> ${new Response(blob)}now throws. A descriptor that is not open gives exit 1.Bun.file(fd)addsdupandclose..textgrows 256 bytes.Notes
How to land it
Interpreter::redirect_target, which only that PR has.file-io.test.ts.Repro (bun 1.4.2, main 6292c65, and the head of #46687):
docs/runtime/shell.mdx:159listsBun.file(path),Bun.file(fd)as output targets ("writes to the file").Bun.stdoutandBun.stderrare such files. The docs are correct after this PR, so there is no docs change.Responseas an input only. shell: open a Response over Bun.file(path) as a file redirect #46687 asks if aResponseover a file is also an output target. This PR gives theBun.file(fd)form the answer that shell: open a Response over Bun.file(path) as a file redirect #46687 gives theBun.file(path)form: theResponseis the file, on both routes.> ${Response(...)}redirect #35318 fixed the builtin setup. The stale sweep closed it on 2026-10-23 with no maintainer comment. This PR keeps its result, and the classification moves into the classifier of shell: open a Response over Bun.file(path) as a file redirect #46687.Behaviour, before and after (Linux x64, release builds of cd5cbee and of this PR)
>,>>,2>,&>to${Bun.file(fd)}${new Response(Bun.file(fd))}bodyUsedis true${new Response(new Blob(["x"]))}bodyUsedis true< ${Bun.file(fd)}, the descriptor is a pipecat< ${Bun.file(fd)}, the descriptor is a regular filecaton POSIX (experimental flag)cat < fileandcat filethere (#35337)&> ${Bun.file(fd)}&> ${new Response(Bun.file(fd))}Blobs are immutable, and cannot be used for stdout/stderr>or<to${Bun.file(fd)}, the descriptor is not openbun: Bad file descriptor: <fd>, exit 1Responsebun: Bad file descriptor: /usr/bin/sh, exit 1, body usedbun: Bad file descriptor: <fd>, exit 1, body unreadEvery other cell of a spawned command is the same before and after. The bare
> ${new Blob(["x"])}threw before and throws now.What the shell does with the descriptor
dup) and closes the duplicate when the command ends. The typeJsFdgives out a duplicate and the number, not the descriptor.>does not truncate and>>does not seek. A spawned command did the same before.open_for_writing_implwould set O_NONBLOCK, and a duplicate shares that flag with the descriptor of the caller and with each process that holds it. A spawned command clears O_NONBLOCK on the descriptor, as each child stdio does since process.stdout/stderr: clear O_NONBLOCK for spawned children, don't drop console output on EAGAIN #43868. That is not new.Responsebody is taken.setup_io_before_run,interpreter.rs:1262):shell_dup, thenis_pollable. So> ${Bun.stdout}writes as a command with no redirect does.Blocking pipe, in full
The target is the blocking write end of a FIFO, passed as
Bun.file(wfd). The only reader is on the JS thread. The FIFO holds 65,536 bytes. Linux 6.1. The last column is a debug build of this branch merged with #46307.echo, 65,536 bytesecho, 65,537 bytesecho, 200,001 bytesecho, 1,000,001 bytesecho, 200,001 bytes, write end opened with O_NONBLOCKsh, 200,001 bytesecho.write_to_blocking_pipe(src/io/PipeWriter.rs:252): onewrite(2)of the whole chunk after POLLOUT.PosixPipeWriter::try_write: a blocking pipe or FIFO gets one pipe slot per POLLOUT, and it reads O_NONBLOCK before each write. This PR has no copy of that change. The writer is shared with the stdout and stderr writers of the shell, and a copy here would conflict with shell: write one pipe slot per POLLOUT to a blocking stdout or stderr (Linux) #46307.pipe(2)pipe before Linux 6.4, and to a socket before Linux 6.5.pwritev2(RWF_NOWAIT)from process.stdout/stderr: clear O_NONBLOCK for spawned children, don't drop console output on EAGAIN #43868 covers the rest. macOS was not run.Left out on purpose, with the owner
Responseover a stream of aBun.file(fd)(.stream(), a read.body,.clone()) stays a JS object. The Blob caches that stream, and taking the body spends it (process.stdin.resume() throws "ReadableStream is locked" after Bun.spawn takes Bun.stdin.stream() (regression in 1.4.3) #45068). Handing Bun.stdin.stream() to a child process leaves it readable #45431 fixes that in the object arm, and its shell test keeps its path.Response: shell: reject a stream, used, failed or S3-backed Response body as stdin #42786. An S3 file: Refuse an S3-backed Blob at spawn stdin, in the shell, inBun.buildfiles and in redis #46673.>>to a Buffer/ArrayBuffer instead of silently overwriting from offset 0 #36480, shell: keep both streams when&>redirects to a buffer or a Bun.file(fd) #43131, shell: fail a command whose> ${buf}redirect overflows the target buffer #43196, shell: stop an external command when its> ${buf}target is full #43225.catcannot read a regular file on POSIX:cat < fileandcat fileexit 1 there. It is a builtin on POSIX only withBUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS.cat < ${Bun.file(fd)}now fails the same way. Before, it exited 0 and read nothing. shell(cat): read regular files synchronously in the builtin on POSIX #35337 fixes the reader incat.rsfor each form. Twotest.todoIf(isPosix)rows wait for it.> ${new Response(blob)}takes the body and then throws aTypeErrorwith another text. Atest.todorow records the difference.Bun.file(fd)is the whole file, as on main. spawn: send a sliced Bun.file() at stdin in chunks of its window #44301 (draft) fixes the slice inStdio::extract_blob, which aRedirectTarget::Fddoes not reach.Other open PRs on the same lines
&>redirects to a buffer or a Bun.file(fd) #43131 gives both streams the descriptor for a spawned&> ${Bun.file(fd)}, in the Blob branch of the object arm. The two merge with no conflict insrc/, and the results agree. After both, aBun.file(fd)does not reach that branch.new Response(Bun.file(path))on the spawned route. It conflicts with shell: open a Response over Bun.file(path) as a file redirect #46687 already (shell_body.rs,Cmd.rs). This commit adds no conflict there.Builtin.rs) and with shell: write one pipe slot per POLLOUT to a blocking stdout or stderr (Linux) #46307 (the import line). Handing Bun.stdin.stream() to a child process leaves it readable #45431 merges clean.&>redirects to a buffer or a Bun.file(fd) #43131, spawn: one open-flags table for Bun.file(path) stdio, stdout and stderr replace the file #46665, Handing Bun.stdin.stream() to a child process leaves it readable #45431 and spawn: send a sliced Bun.file() at stdin in chunks of its window #44301. This PR does not make them. Each of the four has its own review, and this list is the notice.Weighed and not done
open_for_writing_implon the descriptor: see the flags rule above./proc/self/fd/Nagain: Linux only. It gives a new offset, blocks on a FIFO with no reader, fails with ENXIO on a socket, and makes a read-only descriptor writable.bun: Bad file descriptor: <fd>: the path errors of the same function have none, and the tests of shell: open a Response over Bun.file(path) as a file redirect #46687 pin that.Cost (release builds of cd5cbee and of this PR, Linux x64)
.text: 82,258,761 to 82,259,017 bytes (+256).BuiltinIO64,BuiltinInput56,Builtin280,Cmd176,RedirectTarget40.Cmd::transition_to_exec, callees included (gdb single-step, warm), for a builtin:echo hiecho hi > pathecho hi < pathecho hi > ${u8}echo hi < ${blob}echo hi < ${new Response("x")}echo hi < ${Bun.file(fd)}echo hi > ${Bun.file(fd)}transition_to_exec,redirect_targetandShellSubprocess::refonly, because a single-step acrossvforkdoes not end./bin/true1,002 to 1,006.> path1,107 to 1,116.> ${u8}1,092 to 1,113.< ${blob}1,155 to 1,188.> ${Bun.file(fd)}1,140 to 1,148.< ${Bun.file(fd)}1,113 to 1,123.> ${Bun.file(fd)}goes from 6/8 to 8/10.Bun.file(fd).< ${Bun.file(fd)}goes from 5 to 7 (dup,close).> ${Bun.file(fd)}goes from 5 to 10 (dup,fstat,ioctl,write,close, noF_SETFL).Bun.file(fd).>goes from 11 to 13 and<from 14 to 16 (dup,close).perf,valgrindandstraceare not in the container. gdb scripts made the counts.Tests
file-io.test.ts, block "redirect to a Bun.file(fd)": 42 tests and 4 todo rows. The debug build (ASAN) gives 69 pass, 4 todo, 0 fail for the file. On a release build of cd5cbee 30 of the 42 fail.cat <on POSIX, and the builtin refusal of aResponsewhose.bodywas read.echo,basename) and for a spawned twin that writes the same bytes, for>,>>,2>and both streams of&>, with the bare file and with theResponse./dev/fdentries over 20 rounds of nine redirects and compares the status flags of three descriptors with the flags at open (Linux). One fixture fills a FIFO and reads it on the JS thread.test.todorow that names shell: write one pipe slot per POLLOUT to a blocking stdout or stderr (Linux) #46307. With shell: write one pipe slot per POLLOUT to a blocking stdout or stderr (Linux) #46307 merged in, that row passes.bunshell.test.ts(510 pass),yield,bunshell-instance,exec,throw,shell-worker-terminate-leak,bunshell-file,lazy,shelloutput,epipe,pipeline_stack,assignments-in-pipeline,shell-pipe-read-fault,shell-write-fault,shell-blocking-pipe, and the command suitescat,echo,basename,rm,mv,which,seq,yes,ls. One test inexecand one inlsgo over their 5 s limit in a full debug run and pass alone.BUN_JSC_validateExceptionChecks=1passes on the new block.cargo clippyandrustfmtare clean.cargo check -p bun_runtimepasses forx86_64-pc-windows-msvcandaarch64-apple-darwin.Self-review
redirect_target. A path case, an S3 case and a docs edit went out.JsFdtype, the stream forms stay objects (Handing Bun.stdin.stream() to a child process leaves it readable #45431), both routes in each test, the flags test reads the flags at open.test.todorow for a blocking descriptor, the build with shell: write one pipe slot per POLLOUT to a blocking stdout or stderr (Linux) #46307, and thecatrow in the table above.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/file-io.test.ts