Skip to content

cli: run pipe/FIFO entry points (process substitution) instead of failing on 'pipe:[...]' - #35463

Open
robobun wants to merge 10 commits into
mainfrom
farm/565c3d75/run-pipe-entrypoint
Open

robobun wants to merge 10 commits into
mainfrom
farm/565c3d75/run-pipe-entrypoint

Conversation

@robobun

@robobun robobun commented Jul 24, 2026 •

Copy link
Copy Markdown
Collaborator

What

$ bun <(echo "console.log('hello')")
error: Module not found 'pipe:[3220920898]'

On macOS the same invocation printed error: script not found "/dev/fd/13". The expected output is hello.

Cause

maybe_open_with_bun_js opens the path (/dev/fd/N), fstats the fd, then calls get_fd_path to derive the canonical absolute path before booting. For a pipe that readlink resolves to pipe:[inode] on Linux and fails on macOS (no F_GETPATH for a pipe), and the subsequent boot cannot load the module.

Fix

When fstat on the already-open fd says the entry point is a FIFO, read it right there and route through the existing cwd/[stdin] virtual-source path that bun run - already uses. The path the user typed is prepended to passthrough so process.argv[1] is still /dev/fd/N (or the FIFO path). Regular-file entry points are untouched and still go through get_fd_path so symlinked scripts keep resolving to their real location.

The check is deliberately only S_ISFIFO: sockets cannot be re-opened via /dev/fd/N on Linux so open already fails before fstat, and reading a char/block device there (e.g. bun /dev/zero) would hang.

Why the [stdin] path and not a new virtual-source mechanism

An earlier revision kept /dev/fd/N as the entry path and widened the eval_source match in the resolver/loader to accept an exact match on the stored source path. That broke bun run -e foo file.js (where -e foo is only recorded in process.execArgv and file.js is the entry), because boot() stores the -e string in eval_source with path.text = file.js regardless, and the widened match then served foo instead of reading the file. Reverted in 81d6190.

Routing through [stdin] means a pipe entry behaves exactly like bun run - does today: the built-in Node module globals are exposed, process._eval is set, and the loader is tsx. That is consistent (a pipe you read once is semantically stdin, not a file you can reopen) and keeps the change to a single function in run_command.rs. A dedicated pre-read virtual-source slot that keeps file-entry semantics would have to re-gate five separate eval_source.is_some() consumers and is not obviously the right semantics anyway.

Verification

New tests in test/cli/run/run_command.test.ts (POSIX only) cover a named FIFO entry point, a bash <() process-substitution entry point, and the error output. All three fail on the current release with Module not found 'pipe:[...]' and pass with this change. process.execArgv in test/js/node/process/process.test.js and all of test/cli/run/run-eval.test.ts pass unchanged.

Fixes #7102

The bun-as-node shim (exec_as_if_node) is left unchanged: real Node's default also fails on <() on Linux because its CJS loader realpaths the entry, so the shim matching that is node-parity.


no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/run/run_command.test.ts

…..]'

bun <(echo '...') and bun /path/to/fifo opened the fd, readlink'd it via
get_fd_path (which yields 'pipe:[inode]' on Linux and fails on macOS for
a pipe), then tried to boot with that non-path.

When fstat on the open fd says the entry point is not a regular file,
read it into the eval_source right there and boot with the path as
given, so process.argv[1], __filename and import.meta reflect what the
user typed. The eval_source matching in the resolver/loader is widened
to also accept an exact match on the stored source path, not only the
/[eval] and /[stdin] suffixes, so the virtual source is served for
whatever path the entry point was read from.

Fixes #7102
@coderabbitai

coderabbitai Bot commented Jul 24, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The CLI now reads FIFO and process-substitution scripts directly from open file descriptors, boots them as synthetic stdin scripts, and preserves the original entry-point path in process.argv. POSIX tests cover successful execution and error output.

FIFO entry-point execution

Layer / File(s) Summary
FIFO detection and synthetic boot
src/runtime/cli/run_command.rs
fstat identifies FIFO inputs, whose contents are read from the existing descriptor and passed to boot_stdin_script; the supplied target is preserved in argv and boot errors.
FIFO and process-substitution coverage
test/cli/run/run_command.test.ts
POSIX-only tests cover named FIFOs and Bash process substitution, including argv values, successful output, and errors without pipe:[...] paths.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The code and tests address #7102 by correctly reading FIFO/file-descriptor entry points and executing process substitution content.
Out of Scope Changes check ✅ Passed The changes stay focused on FIFO/process-substitution entry-point handling and related tests, with no clear unrelated additions.
Title check ✅ Passed The title clearly summarizes the core change: handling pipe/FIFO entry points instead of failing on pipe paths.
Description check ✅ Passed The description covers the change, cause, fix, and verification, so it sufficiently matches the template intent.

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

@robobun

robobun commented Jul 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:23 PM PT - Jul 24th, 2026

✅ @robobun, your commit 76b92db1466dad47e32d23e7f6693b78a80759ba passed in Build #79986! 🎉


🧪   To try this PR locally:

bunx bun-pr 35463

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

bun-35463 --bun

autofix-ci Bot and others added 2 commits July 24, 2026 22:48
/dev/zero and other char devices would read forever; keep them on the
existing error path. Process substitution and named FIFOs are the only
shapes that need this.
Comment thread test/regression/issue/07102.test.ts Outdated
robobun and others added 3 commits July 24, 2026 23:13
The exact-match eval_source extension broke 'bun run -e foo file.js',
where -e foo is only stored as execArgv and the file is the entry: the
loader started serving the -e string in place of the file's source.

Revert the resolver/loader/Bun.main changes and instead read the FIFO
into eval.script and boot with cwd/[stdin] exactly like 'bun run -'.
process.argv[1] still carries the path the user typed because it is
prepended to passthrough. Tests move to test/cli/run/run_command.test.ts
since this was never a regression.

@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/jsc_hooks.rs:3946 — The widened || specifier == eval_source.path.text match regresses bun run -e <script> -b <file>: boot() at run_command.rs:977-988 unconditionally installs eval_source with path.text = <file's absolute path> and contents = <-e text> whenever -e is set, so the entry file's specifier now matches and the loader serves the -e string instead of reading the file from disk. This is what's breaking test/js/node/process/process.test.js on all 9 CI platforms (including Windows, where the FIFO branch is unreachable) — its fixture at line 1275 runs run -e bruh -b <print-process-execArgv.js> ... and now executes bruh instead of the file. Fix: don't install eval_source in boot() when the entry is a real on-disk file (only for [eval]/[stdin]/FIFO), or gate the new exact-path match on a flag set only by the FIFO branch.

    Extended reasoning...

    What the bug is

    The PR widens the eval-source match in three places — get_loader_and_virtual_source in src/runtime/jsc_hooks.rs:3946 and src/bundler/options.rs, and _resolve in src/jsc/VirtualMachine.rs and src/runtime/jsc_hooks.rs — from only the /[eval] / /[stdin] suffixes to also accept an exact match on eval_source.path.text. This breaks bun run -e <script> -b <file>, which previously ran <file> and only recorded -e <script> in process.execArgv for Node compatibility.

    The code path that triggers it

    1. Argument parsing (src/runtime/cli/Arguments.rs): -e bruh sets ctx.runtime_options.eval.script = "bruh", regardless of whether a file positional is also given.
    2. Command dispatch (src/runtime/cli/mod.rs): the -e → exec_eval diversion only fires for AutoCommand; for bun run -e bruh -b <file> it proceeds to boot the file.
    3. Boot (src/runtime/cli/run_command.rs:972-988): entry is set to the file's absolute path and passed to vm.set_main(entry). Then, because ctx.runtime_options.eval.script is non-empty, it unconditionally installs:
      vm.module_loader.eval_source = Some(Box::new(Source::init_path_string(entry, script)));
      so eval_source.path.text == "/abs/path/to/file.js" and eval_source.contents == "bruh".
    4. Module load (src/runtime/jsc_hooks.rs:3946, and the parallel site in options.rs):
      if is_eval || is_stdin || specifier == eval_source.path.text {
          virtual_source = Some(eval_source);
          loader = Some(Loader::Tsx);
      }
      The entry file's specifier now equals eval_source.path.text, so the loader serves "bruh" as the module source instead of reading file.js from disk.

    Why existing code didn't prevent it

    Before this PR, step 3 already ran and installed the same eval_source, but it was inert: the loader/resolver only matched specifiers ending in /[eval] or /[stdin], so /abs/path/to/file.js fell through to the normal filesystem read. The PR description says "-e and bun run - are unchanged", which is true for those invocations, but it misses that eval.script is also populated (and eval_source installed with the file's path) when -e is combined with a file argument under bun run.

    Impact

    The user's file is silently replaced by the -e argument. This is exactly the CI failure robobun reports: test/js/node/process/process.test.js fails on all 9 platforms — Linux x64/aarch64/ASAN, Alpine x64/aarch64, Ubuntu x64/aarch64, and Windows x64/aarch64. The Windows failures are dispositive: the new ISFIFO branch in maybe_open_with_bun_js is unreachable there, so the regression must be in the widened match, not the FIFO code.

    Step-by-step proof

    Take the failing fixture at test/js/node/process/process.test.js:1275:

    bun run -e bruh -b /path/to/print-process-execArgv.js foo -a -b -c
    

    Expected: the .js file runs and prints {"execArgv":["-e","bruh","-b"],"argv":["foo","-a","-b","-c"]}.

    1. Arguments.rs sets eval.script = "bruh".
    2. RunCommand (not AutoCommand) → does not divert to exec_eval; boots /path/to/print-process-execArgv.js.
    3. run_command.rs:972-975: entry = "/path/to/print-process-execArgv.js", vm.set_main(entry).
    4. run_command.rs:977-988: eval.script is non-empty → eval_source = Source { path.text: "/path/to/print-process-execArgv.js", contents: "bruh" }.
    5. Module loader resolves the entry specifier "/path/to/print-process-execArgv.js". At jsc_hooks.rs:3946, is_eval = false, is_stdin = false, but specifier == eval_source.path.text → true. virtual_source = eval_source, so the module source is "bruh".
    6. Bun evaluates bruh as TSX → ReferenceError: Can't find variable: bruh (or similar), no JSON on stdout, test fails.

    On main (before this PR), step 5's condition was is_eval || is_stdin → false, the loader read the file from disk, and the test passed.

    How to fix

    Two options:

    (a) Don't install eval_source at run_command.rs:977-988 when the entry is a real on-disk file. That block should only run when the entry is one of the synthetic paths ([eval], [stdin]) or the new FIFO path — e.g. gate it on ctx.runtime_options.eval.eval_and_print || <entry ends with [eval]/[stdin]> || <came from the FIFO branch>, or have the FIFO branch set a distinct flag that boot() checks. This is the cleaner fix and also stops leaking the confusing eval_source in this mode.

    (b) Keep run_command.rs as-is but gate the new specifier == eval_source.path.text clause (in all four sites: jsc_hooks.rs ×2, VirtualMachine.rs, options.rs) on a new bool set only by the FIFO branch, so the exact-path match only activates for pipe entry points.

    Either way, re-run bun bd test test/js/node/process/process.test.js to confirm the process.execArgv fixture passes again alongside the new 07102.test.ts.

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review findings in 81d6190 / 6732699:

  • The widened eval_source match that broke bun run -e foo file.js (process.execArgv test) is reverted; the resolver/loader/Bun.main changes are gone.
  • The FIFO branch now routes through the same cwd/[stdin] path bun run - uses, so no new matching rules are needed anywhere.
  • Narrowed from !ISREG to ISFIFO only so bun /dev/zero and other char/block devices keep erroring immediately instead of reading forever.
  • Tests moved out of test/regression/issue/ into test/cli/run/run_command.test.ts.

The net diff is now a single function in run_command.rs plus the three new tests. A pipe entry point behaving exactly like bun run - (Node module globals exposed, process._eval set, tsx loader) is deliberate; see the updated PR body for the rationale.

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

Beyond the inline nits, I checked fd ownership in the new ISFIFO branch — bun_sys::File::from_fd(fd) takes ownership and closes on Drop (file.rs:17-22), so the fd is released on both the read-success and read-error paths even though there's no explicit bun_sys::close like the neighboring ISDIR/get_fd_path exits have. The regular-file path (get_fd_path → close) is unchanged.

Extended reasoning...

The ISFIFO branch is the only place in maybe_open_with_bun_js that doesn't call bun_sys::close(fd) explicitly — it instead constructs a temporary bun_sys::File::from_fd(fd) whose Drop impl closes the handle when the .read_to_end_into(...) expression completes. Verified against src/sys/file.rs:17-22 that Drop for File closes any non-stdio, non-INVALID fd, so no leak on either path. Not approving: this is a user-visible CLI entry-point behavior change that pivoted design mid-PR (81d6190 swapped the resolver-widening approach for [stdin] routing), and the inline nits (stale description, exec_stdin duplication, bun-as-node sibling) are worth a maintainer glance.

Comment thread src/runtime/cli/run_command.rs Outdated
Comment thread src/runtime/cli/run_command.rs
Comment thread src/runtime/cli/run_command.rs Outdated

@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: 4

🤖 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/cli/run_command.rs`:
- Around line 2888-2889: Update the caller of boot_stdin_script to propagate its
Result error instead of converting failures to false with unwrap_or(false).
Preserve false only for a successful non-match, so getcwd() and other
user-reachable syscall failures remain catchable rather than triggering fallback
module resolution.

In `@test/cli/run/run_command.test.ts`:
- Around line 46-49: Replace the expanded regression narrative above the `#7102`
test in run_command.test.ts with only the linked issue URL comment, preserving
the test itself unchanged.
- Around line 61-68: Update the FIFO writer promise in the async feed operation
by removing the catch that converts failures into resolved values, so errors
from open, writeFile, or close reject and propagate through await feed. Keep the
existing Promise.all and feed sequencing unchanged.
- Around line 97-100: Strengthen the stderr assertion in the run-command test by
requiring the expected /dev/fd/N descriptor path produced by the argv1
error-reporting behavior, rather than only excluding pipe:[inode]. Keep the
existing boom and exit-code assertions, and ensure the matcher validates the
intended descriptor-path format.
🪄 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: 8536631b-f20e-4dc4-bc8a-b653e2c0885f

📥 Commits

Reviewing files that changed from the base of the PR and between 028f7a3 and e2443cd.

📒 Files selected for processing (2)
  • src/runtime/cli/run_command.rs
  • test/cli/run/run_command.test.ts

Comment thread src/runtime/cli/run_command.rs Outdated
Comment thread test/cli/run/run_command.test.ts Outdated
Comment thread test/cli/run/run_command.test.ts Outdated
Comment thread test/cli/run/run_command.test.ts

@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/cli/run_command.rs:2959-2962 — The comment above Self::boot(...) was carried over verbatim from exec_stdin and is now stale for the FIFO caller: it says "the stdin path skips configure_allocator(long_running=true)" (but maybe_open_with_bun_js calls it at ~2867 before invoking this helper) and annotates basename(target_name) (= "-") (but the param is now argv1, which is the FIFO / /dev/fd/N path for the new caller, not "-"). Consider generalizing to e.g. "prints basename(argv1) rather than [stdin]" and dropping the stdin-only rationale.

    Extended reasoning...

    Summary

    Commit e2443cd extracted the shared boot_stdin_script helper (per the earlier dedup review comment) and moved the pre-existing comment block from exec_stdin into it verbatim. That comment was written when the block had a single caller (bun run -) and two of its claims no longer hold now that maybe_open_with_bun_js's ISFIFO branch also calls it.

    The comment as it stands (run_command.rs:2955-2959)

    // NOT routed through `_boot_and_handle_error` — the
    // stdin path skips the
    // `configure_allocator(long_running=true)` / `.md` checks and prints
    // `basename(target_name)` (= "-"), not `basename(entry_path)`
    // (= "[stdin]"), in the error message.

    Step-by-step: why it's stale

    1. "the stdin path skips configure_allocator(long_running=true)" — true only for the exec_stdin caller. The FIFO caller enters at maybe_open_with_bun_js, which explicitly runs Global::configure_allocator(GlobalConfigureAllocator { long_running: true, .. }) at lines 2867-2870 before the ISFIFO branch at line 2880 calls Self::boot_stdin_script at line 2888. So on that path the allocator is already configured and the comment's rationale ("skips") does not describe the shared helper's contract.
    2. basename(target_name) (= "-") — the parameter passed to boot_failed_exit is now argv1 (line 2962), not target_name, and its value is only b"-" for the exec_stdin caller. For the FIFO caller it's target — the literal path the user typed, e.g. /dev/fd/63 or /tmp/xxx/script — so the (= "-") annotation is wrong for one of the two callers and target_name no longer names anything in scope.
    3. The core point the comment protects — "NOT routed through _boot_and_handle_error … prints the argv1 basename rather than [stdin] in the error message" — is still correct and still worth keeping; only the caller-specific parentheticals drifted.

    Why the existing code doesn't prevent it

    This is purely a doc-comment; nothing in the build or lint checks that comments match parameter names or caller sets. The helper's own doc comment at lines 2926-2928 is accurate (it correctly says argv1 is "-" for stdin or the path as typed for a FIFO), so the drift is confined to this one inline block.

    Impact

    Zero runtime impact — nit-level. The risk is REVIEW.md's "Comments carry only durable non-obvious content": a future reader debugging _boot_and_handle_error vs boot_failed_exit divergence for a FIFO entry will read "skips configure_allocator" / (= "-") and be misled about what the FIFO path actually does.

    Fix

    Generalize to describe the shared contract, e.g.:

    // NOT routed through `_boot_and_handle_error` — this path skips its
    // `.md`-suffix check and prints `basename(argv1)` ("-" or the FIFO
    // path as typed) rather than `basename(entry_path)` (= "[stdin]")
    // in the error message.

    or simply drop the (= "-") and the configure_allocator clause.

Comment thread test/cli/run/run_command.test.ts Outdated
Comment thread src/runtime/cli/run_command.rs

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

No issues found — the change is small, well-tested, and all prior review threads are resolved. Deferring only because routing FIFO entries through [stdin] semantics (sets process._eval, tsx loader, [stdin] in stack frames rather than /dev/fd/N) is a user-visible API decision worth a maintainer's sign-off.

What was reviewed:

  • fd lifetime in the new ISFIFO branch — File::from_fd owns the fd and closes on drop; no double-close with the regular-file path's explicit close (the branch returns/diverges first).
  • boot_failed_exit returns !, so the Err arm cannot fall through to get_fd_path.
  • exec_stdin refactor is behavior-preserving (argv1 = b"-", same entry-path construction).
  • Tests: writer now races in Promise.all; bash-dependent tests skipIf(!bash); assertions pin argv[1], import.meta.main, and the [stdin]:1 frame.
Extended reasoning...

Overview

Two files touched. In src/runtime/cli/run_command.rs, maybe_open_with_bun_js gains an S::ISFIFO(mode) branch that reads the already-open fd to EOF and boots via a new boot_stdin_script helper. That helper is the body of the pre-existing exec_stdin extracted verbatim with argv1 parameterized (previously hardcoded b"-"), so bun run - and FIFO entry now share one implementation of the cwd/[stdin] synthetic entry point. The regular-file path (get_fd_path → _boot_and_handle_error) is untouched. Three new POSIX-only tests in test/cli/run/run_command.test.ts cover a named FIFO, bash <(), and the error stack frame.

Security risks

None identified. The branch is gated on S::ISFIFO on an fd the process already opened; contents are read into an owned Vec<u8> and handed to the same eval-script slot bun run - uses. No new path resolution, no privilege boundary crossed, no untrusted-length arithmetic.

Level of scrutiny

Medium. This is CLI entry-point dispatch — the code that decides how bun <path> boots — but the change is narrow (~30 net lines of Rust) and additive: it inserts one branch ahead of get_fd_path and refactors exec_stdin into a shared helper without changing its observable behavior. I verified fd ownership (the temporary File::from_fd(fd) closes on drop; the FIFO branch either returns or diverges via boot_failed_exit -> !, so the later explicit close(fd) at :2900/:2906 is never reached), and that the fstat refactor (is_dir → keep full st) preserves the existing ISDIR early-return.

Other factors

All six prior review threads (mine and CodeRabbit's) are resolved: the copy-pasted STDIN_TRIGGER/getcwd block was deduped into boot_stdin_script; the writer's swallowed .catch(e => e) was removed and feed moved into Promise.all; boot_stdin_script's Err now routes through boot_failed_exit; the error test pins [stdin]:1; the bun-as-node sibling is explicitly documented as node-parity in the PR body. The one declined nit (read_to_end_into → return false discards errno) matches the pre-existing pattern at exec_stdin:2922 and the neighboring open/fstat/get_fd_path arms — reasonable to defer.

The reason I'm not approving outright: the PR deliberately gives FIFO entries bun run - semantics (Node module globals exposed, process._eval set, tsx loader, [stdin] as the source name in stacks and import.meta.path) rather than file-entry semantics. The PR body argues this well, and the alternative was tried and reverted for breaking process.execArgv, but it is a product-level choice about what bun <(cmd) means that a maintainer should confirm.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 76b92db is green for this change: test/cli/run/run_command.test.ts, run-eval.test.ts and process.test.js pass on every lane that ran them. The remaining reds are unrelated flakes that passed on retry or are known-flaky (complex-workspace.test.ts install, bun-upgrade ETXTBSY, test-fastutf8stream-reopen, test-repl-close EPIPE, s3.test.ts R2 InternalError). Ready for a maintainer to look at.

This branch has not been deployed

No deployments
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.

Bun can't read file descriptors

1 participant