From 031142ee9ffb5a89f0fc7761e496897c6c259038 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:49:43 +0000 Subject: [PATCH 1/5] process.stdin: end at once when fd 0 is a directory instead of throwing EISDIR --- src/js/builtins/ProcessObjectInternals.ts | 12 ++++++++ src/jsc/bindings/BunProcess.cpp | 1 + src/jsc/rare_data.rs | 6 +++- test/js/node/process/process-stdin.test.ts | 33 ++++++++++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 37501f664d0b..ca5338a5296c 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -28,6 +28,8 @@ const enum BunProcessStdinFdType { file = 0, pipe = 1, socket = 2, + // libuv's UV_UNKNOWN_HANDLE: a directory, a block device, or an fd fstat rejects. + unknown = 3, } export function getStdioWriteStream( @@ -136,6 +138,16 @@ export function getStdinStream( fdType: BunProcessStdinFdType, ) { $assert(fd === 0); + + if (!isTTY && fdType === BunProcessStdinFdType.unknown) { + // Node's getStdin() default branch: an fd libuv cannot classify (a directory, + // for example) becomes an empty Readable that ends at once. The native stdin + // stream would instead throw EISDIR synchronously from the first read. + const stream = require("internal/worker/stdio").makeEndedReadable(); + stream.fd = fd; + return stream; + } + const native = Bun.stdin.stream(); const source = native.$bunNativePtr; diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 57f1b66e0eda..3c8d35c25439 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -2946,6 +2946,7 @@ enum class BunProcessStdinFdType : int32_t { file = 0, pipe = 1, socket = 2, + unknown = 3, }; extern "C" BunProcessStdinFdType Bun__Process__getStdinFdType(void*, int fd); diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index 379823158ef6..36a8610f64fe 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -1052,6 +1052,9 @@ enum StdinFdType { File = 0, Pipe = 1, Socket = 2, + /// Anything libuv's `uv_guess_handle` reports as `UV_UNKNOWN_HANDLE`: a + /// directory, a block device, or an fd that `fstat` rejects. + Unknown = 3, } #[unsafe(no_mangle)] @@ -1080,7 +1083,8 @@ extern "C" fn Bun__Process__getStdinFdType(vm: &VirtualMachine, fd: i32) -> Stdi match bun_sys::kind_from_mode(mode) { bun_sys::FileKind::NamedPipe => StdinFdType::Pipe, bun_sys::FileKind::UnixDomainSocket => StdinFdType::Socket, - _ => StdinFdType::File, + bun_sys::FileKind::File | bun_sys::FileKind::CharacterDevice => StdinFdType::File, + _ => StdinFdType::Unknown, } } diff --git a/test/js/node/process/process-stdin.test.ts b/test/js/node/process/process-stdin.test.ts index 6aa5d6a528ba..949dfd82001c 100644 --- a/test/js/node/process/process-stdin.test.ts +++ b/test/js/node/process/process-stdin.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isASAN, isDebug, isWindows, tempDir } from "harness"; import { exec } from "node:child_process"; +import { closeSync, openSync } from "node:fs"; test.concurrent("pipe does the right thing", async () => { // Note: Bun.spawnSync uses memfd_create on Linux for pipe, which means we see @@ -420,6 +421,38 @@ test.concurrent("pause() and resume() churn while data is in flight never destro expect(exitCode).toBe(0); }); +// Windows cannot hand a directory handle to a child as stdin. +test.skipIf(isWindows)("process.stdin ends when fd 0 is a directory, like node", async () => { + using dir = tempDir("stdin-directory", {}); + const dirFd = openSync(String(dir), "r"); + try { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const events = []; + process.stdin + .on("error", e => events.push("error:" + e.code)) + .on("end", () => events.push("end")) + .on("close", () => events.push("close")) + .resume(); + console.log(process.stdin.isTTY, process.stdin.fd); + process.on("exit", () => console.log(events.join(",")));`, + ], + stdin: dirFd, + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("undefined 0\nend,close\n"); + expect(exitCode).toBe(0); + } finally { + closeSync(dirFd); + } +}); + // The native FileReader source over a pollable pipe used to drain the fd to // EAGAIN regardless of JS demand, so an idle consumer still ingested the whole // pipe into an internal buffer. The kernel pipe buffer filling up is the From 46d0825d80f45225361856e4328f16ec3b34b232 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:59:38 +0000 Subject: [PATCH 2/5] process.stdin: keep Windows stdout/stderr synchronous for an unknown fd type --- src/jsc/bindings/BunProcess.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 3c8d35c25439..9c19f3bd1e4e 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -3009,7 +3009,9 @@ static JSValue constructStdioWriteStream(JSC::JSGlobalObject* globalObject, JSC: // Pipes (and sockets): synchronous on Windows, asynchronous on POSIX bool forceSync = false; #if OS(WINDOWS) - forceSync = fdType == BunProcessStdinFdType::file || fdType == BunProcessStdinFdType::pipe; + // `unknown` (an fstat failure or an unclassified handle) was `file` before the + // variant existed and keeps that synchronous behavior. + forceSync = fdType == BunProcessStdinFdType::file || fdType == BunProcessStdinFdType::pipe || fdType == BunProcessStdinFdType::unknown; #else // Note: files are always sync anyway. // forceSync = fdType == BunProcessStdinFdType::file || bun_stdio_tty[fd]; From 4a59d96a73f5d37f9daf226a00e59de68b7070e1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:02:44 +0000 Subject: [PATCH 3/5] process.stdin: shorten comments on the unknown fd type --- src/js/builtins/ProcessObjectInternals.ts | 4 +--- src/jsc/bindings/BunProcess.cpp | 4 +--- src/jsc/rare_data.rs | 3 +-- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index ca5338a5296c..32e442fbe5aa 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -140,9 +140,7 @@ export function getStdinStream( $assert(fd === 0); if (!isTTY && fdType === BunProcessStdinFdType.unknown) { - // Node's getStdin() default branch: an fd libuv cannot classify (a directory, - // for example) becomes an empty Readable that ends at once. The native stdin - // stream would instead throw EISDIR synchronously from the first read. + // Node's getStdin() default branch. The native stream would throw EISDIR on first read. const stream = require("internal/worker/stdio").makeEndedReadable(); stream.fd = fd; return stream; diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 9c19f3bd1e4e..9222a917b0de 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -3009,9 +3009,7 @@ static JSValue constructStdioWriteStream(JSC::JSGlobalObject* globalObject, JSC: // Pipes (and sockets): synchronous on Windows, asynchronous on POSIX bool forceSync = false; #if OS(WINDOWS) - // `unknown` (an fstat failure or an unclassified handle) was `file` before the - // variant existed and keeps that synchronous behavior. - forceSync = fdType == BunProcessStdinFdType::file || fdType == BunProcessStdinFdType::pipe || fdType == BunProcessStdinFdType::unknown; + forceSync = fdType != BunProcessStdinFdType::socket; #else // Note: files are always sync anyway. // forceSync = fdType == BunProcessStdinFdType::file || bun_stdio_tty[fd]; diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index 36a8610f64fe..f49c483b7ec7 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -1052,8 +1052,7 @@ enum StdinFdType { File = 0, Pipe = 1, Socket = 2, - /// Anything libuv's `uv_guess_handle` reports as `UV_UNKNOWN_HANDLE`: a - /// directory, a block device, or an fd that `fstat` rejects. + /// libuv's `UV_UNKNOWN_HANDLE`: a directory, a block device, or a failed `fstat`. Unknown = 3, } From 3f1904df6e9ae3843e49e255505dc716b50b0b1a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:11:56 +0000 Subject: [PATCH 4/5] test: run the directory stdin test concurrently --- test/js/node/process/process-stdin.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/node/process/process-stdin.test.ts b/test/js/node/process/process-stdin.test.ts index 949dfd82001c..e93e995c9efe 100644 --- a/test/js/node/process/process-stdin.test.ts +++ b/test/js/node/process/process-stdin.test.ts @@ -422,7 +422,7 @@ test.concurrent("pause() and resume() churn while data is in flight never destro }); // Windows cannot hand a directory handle to a child as stdin. -test.skipIf(isWindows)("process.stdin ends when fd 0 is a directory, like node", async () => { +test.concurrent.skipIf(isWindows)("process.stdin ends when fd 0 is a directory, like node", async () => { using dir = tempDir("stdin-directory", {}); const dirFd = openSync(String(dir), "r"); try { From 78d145b3df20c1686cdecfad32c933212125e35f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:25:23 +0000 Subject: [PATCH 5/5] ci: retrigger