Skip to content

process.stdin: end instead of throwing EISDIR when fd 0 is a directory - #41484

Open
robobun wants to merge 5 commits into
mainfrom
robobun/1d10bcbb/stdin-directory-fd
Open

robobun wants to merge 5 commits into
mainfrom
robobun/1d10bcbb/stdin-directory-fd

Conversation

@robobun

@robobun robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • When fd 0 is a directory (bun app.js < somedir), the first process.stdin.resume(), .on("readable"), .read() or .ref() throws synchronously: EISDIR: illegal operation on a directory, fstat from own in getStdinStream. An error listener cannot catch it. The process exits with code 1. Node prints end, close and exits 0.
  • own() (src/js/builtins/ProcessObjectInternals.ts) calls Bun.stdin.stream().getReader(). The native file reader rejects a directory at start (src/runtime/webcore/FileReader.rs:212) and getReader() rethrows that start error synchronously. That is the tested contract for Bun.file(x).stream(), so the fix belongs in process.stdin.

Fix

  • Bun__Process__getStdinFdType (src/jsc/rare_data.rs) gains a fourth value, Unknown. It covers what libuv's uv_guess_handle reports as UV_UNKNOWN_HANDLE: every fd type other than a regular file, a character device, a pipe or a socket (a directory, a block device, or an fd that fstat rejects).
  • getStdinStream returns the existing makeEndedReadable() for a non-TTY unknown fd, with fd = 0 set. This is Node's getStdin() default branch. The native stdin stream is never created for such an fd.
  • getStdioWriteStream also receives the fd type for fd 1 and 2. It only branches on pipe and socket, so an unknown stdout or stderr behaves as before.
  • Verified: test/js/node/process/process-stdin.test.ts (new test, fails on the released build with the EISDIR above). Also process-stdio.test.ts, test/js/node/tty, worker_threads.test.ts.
  • Self-reviewed: 4 concerns raised, 3 addressed (the variant is the libuv class instead of directory, the helper is reused, the mode-0 case ends instead of crashing). Not addressed: a no-op Writable for an unknown stdout, which is a separate change.

Background

  • process.stdin is built lazily by getStdinStream. It wraps Bun.stdin.stream(), a native ReadableStream over fd 0, in a fs.ReadStream or tty.ReadStream. own() acquires the native reader the first time the stream flows.
  • The C++ side passes an fd type (file, pipe, socket) from a cached fstat on fd 0. Node uses guessHandleType() (libuv uv_guess_handle) for the same decision.
  • makeEndedReadable() (src/js/internal/worker/stdio.ts) is the already-ended Readable that a worker's process.stdin uses when the worker has no stdin. It pushes null on the first read.
Notes

Repro on the released build:

mkdir -p /tmp/d
bun -e 'process.stdin.on("error",e=>console.log("error",e.code)).on("end",()=>console.log("end")).resume(); setTimeout(()=>console.log("alive"),200)' < /tmp/d
# EISDIR: illegal operation on a directory, fstat  (rc 1, "alive" never prints)
node -e '...same...' < /tmp/d
# end, alive, rc 0

process.stdin.isTTY alone does not throw on the current canary. The throw happens on the first acquisition of the native reader.

Unchanged: Bun.stdin.text() still rejects with EISDIR, and for await (const c of process.stdin) with a directory now ends at once instead of throwing.

Classification after this change: File | CharacterDevice stay file (regular files, /dev/null, TTYs), NamedPipe is pipe, UnixDomainSocket is socket, everything else is unknown. On Windows fd 0 goes through uv_fs_fstat, which reports consoles as character devices, pipes and sockets as FIFOs and disk files as regular files. A missing stdin handle fails fstat, so its mode is 0 and it is unknown, which matches Node's "dummy contentless input for non-console Windows applications".

The test is skipped on Windows because the test cannot hand a directory handle to a child as stdin there.


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/process/process-stdin.test.ts

@coderabbitai

coderabbitai Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 14 days. After that, they cost $0.25 per reviewed file.

Or wait 2 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 1e924575-ca01-40ca-b742-8e4c3c302a7d

📥 Commits

Reviewing files that changed from the base of the PR and between 30cda57 and 78d145b.

📒 Files selected for processing (4)
  • src/js/builtins/ProcessObjectInternals.ts
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/rare_data.rs
  • test/js/node/process/process-stdin.test.ts

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

@github-actions github-actions Bot added the claude label Sep 6, 2026
@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:41 AM PT - Sep 6th, 2026

✅ @robobun, your commit 78d145b3df20c1686cdecfad32c933212125e35f passed in Build #110758! 🎉


🧪   To try this PR locally:

bunx bun-pr 41484

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

bun-41484 --bun

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/jsc/bindings/BunProcess.cpp — The narrowed _ => Unknown classification has an unaccounted-for consumer: on Windows, forceSync = fdType == file || fdType == pipe now evaluates false for stdout/stderr whose fstat fails (mode 0 — a missing/invalid handle) or returns a directory/block-device mode, where before it was file and true; Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio is no longer called for those handles. Fix: include unknown in the Windows forceSync predicate (or keep it as the else-default), so the write-stream sync behavior for unclassified fd 1/2 is unchanged. The PR description audited only the JS-side getStdioWriteStream branch on fdType.

    Extended reasoning...

    Bun__Process__getStdinFdType (rare_data.rs:1083-1088) previously mapped every FileKind other than NamedPipe/UnixDomainSocket to File; now BlockDevice, Directory, SymLink and Unknown (mode 0 when fstat fails, rare_data.rs:1031-1033) return StdinFdType::Unknown. constructStdioWriteStream (BunProcess.cpp:2988) reads this for fd 1/2 and, inside #if OS(WINDOWS) at line 3012, sets forceSync = fdType == file || fdType == pipe. On the base branch a Windows process with no stdout handle (fstat fails → mode 0 → catch-all File) got forceSync = true; after this change it gets Unknown → forceSync = false, so Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio at line 3024 is skipped. The PR body claims stdout/stderr behave as before because the JS getStdioWriteStream only tests pipe || socket, but it missed this C++ switch on the same discriminant — REVIEW.md's "new enum variant → audit every switch on the discriminant" applies. Practical impact is limited (writes to an invalid handle fail either way), but the predicate should still cover unknown to keep…

    Verification: nit — The mechanism is exactly as described, but the observable impact is negligible. Base branch (src/jsc/rare_data.rs, per diff): rust match bun_sys::kind_from_mode(mode) { bun_sys::FileKind::NamedPipe => StdinFdType::Pipe, bun_sys::FileKind::UnixDomainSocket => StdinFdType::Socket, _ => StdinFdType::File, } kind_from_mode(0) returns FileKind::Unknown… | nit — the…

Comment thread src/js/builtins/ProcessObjectInternals.ts Outdated
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/jsc/rare_data.rs Outdated

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/node/process/process-stdin.test.ts Outdated

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

LGTM — thanks for switching to test.concurrent.skipIf.

What was reviewed: the three BunProcessStdinFdType enums stay in lockstep (TS/C++/Rust all gain = 3); the Windows forceSync flip from file || pipe to != socket is behavior-identical for the three pre-existing variants and only sweeps the new unknown in. Checked kind_from_mode — the wildcard now catches Directory/BlockDevice/Unknown (mode 0), matching libuv's UV_UNKNOWN_HANDLE set. makeEndedReadable() is the existing worker-stdin helper, so no new stream machinery.

Extended reasoning...

Overview

The PR fixes process.stdin to end cleanly (matching Node) instead of throwing EISDIR synchronously when fd 0 is a directory or other non-readable handle type. It adds an Unknown = 3 variant to the mirrored fd-type enum in three places (ProcessObjectInternals.ts, BunProcess.cpp, rare_data.rs), narrows the Rust classifier so only regular files and character devices map to File, short-circuits getStdinStream to reuse the existing makeEndedReadable() helper for the unknown case, and adjusts the Windows forceSync predicate to a denylist so unknown also forces sync writes. A concurrent, Windows-skipped test spawns bun with a directory fd as stdin and asserts end,close with exit 0.

Security risks

None. This is a Node-compat behavior fix on the stdin classification path — no auth, crypto, network, or untrusted-input parsing is involved. The fd type comes from a cached fstat on the process's own fd 0; the change only widens which mode bits fall into a "return an already-ended Readable" branch instead of attempting a native read.

Level of scrutiny

Low-to-moderate. The diff is ~40 lines across four files, follows an existing pattern (the makeEndedReadable helper was already used for worker stdin), and the enum is updated in lockstep everywhere it lives. I verified against bun_core::FileKind that the new wildcard arm catches exactly Directory, BlockDevice, SymLink, Whiteout/Door/EventPort, and Unknown (mode 0) — the set libuv reports as UV_UNKNOWN_HANDLE. The Windows forceSync rewrite is provably identical for the three pre-existing enum values (0/1 → true, 2 → false in both spellings).

Other factors

Earlier feedback on this PR (use test.concurrent.skipIf) was addressed in commit 3f1904d; the only commit since is an empty CI retrigger. The test follows harness conventions (tempDir, bunEnv, Promise.all pipe drain, stderr/stdout asserted before exitCode, closeSync in finally). The bug hunt exited on dry_streak with no findings.

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.

1 participant