Bun.stdin: reject concurrent text/bytes/arrayBuffer/json reads instead of racing on fd 0
Problem
Three stdin consumers share fd 0: Bun.stdin.stream() (cached on the JS wrapper), process.stdin (built on that cached stream's reader), and Bun.stdin.text()/bytes()/arrayBuffer()/json() (each spawns a fresh ReadFile task with its own io::Poll). Only the first two honour the WHATWG reader lock; the blob helpers ignored it and read fd 0 directly.
Two visible failures:
// cat 1MB.bin | bun child.mjs
const [a, b] = await Promise.all([Bun.stdin.text(), Bun.stdin.text()]);
// a rejects EEXIST: file already exists, epoll_ctl
// b resolves with 524288..894848 bytes (truncated; loser's partial read is dropped)
process.stdin.on("data", c => ...); // holds the cached stream's reader
await Bun.stdin.arrayBuffer(); // ignores the reader, reads fd 0 anyway; bytes split silently
The second ReadFile issues EPOLL_CTL_ADD on a fd already in the IoRequestLoop epoll set (each io::Poll tracks WasEverRegistered independently), so the kernel returns EEXIST. The loser has already consumed bytes before it needs to wait; its error path drops that buffer.
Fix
do_read_fileclaims a process-wideSTDIN_BLOB_READ_IN_FLIGHTatomic before scheduling a stdinReadFile; a second concurrent caller rejectsERR_INVALID_STATEimmediately.NewReadFileHandler::runreleases the claim on completion so sequential reads keep working.- When the
ReadableStreamcached on theBun.stdinwrapper is locked (held byprocess.stdinor a manual.getReader()),get_text/get_json/get_array_buffer/get_bytesroute throughreadableStreamTo{Text,JSON,ArrayBuffer,Bytes}so the held reader produces the sameERR_INVALID_STATE: ReadableStream is lockedas a secondgetReader()would. A cached-but-unlocked stream (e.g. after a bareprocess.stdin.isTTY) falls through todo_read_fileso sequential reads keep resolving at EOF.
Node v26 has no single-owner stdin lock either (process.stdin + fs.promises.readFile('/dev/stdin') splits silently), so the locked-stream rejection is a quality improvement rather than a compat requirement; not surfacing a raw epoll_ctl errno is a bug fix.
Supersedes #35831 (that PR routed through the cached stream but left two concurrent blob reads racing).
Verification
test/js/bun/util/bun-stdin-concurrent-read.test.ts pipes 1 MiB into a child and asserts:
| scenario | before | after |
|---|---|---|
two concurrent Bun.stdin.text() | one rejects EEXIST, other truncated | first resolves full 1 MiB, second rejects ERR_INVALID_STATE |
process.stdin.on('data') + Bun.stdin.{text,bytes,arrayBuffer}() | bytes split, no error | process.stdin receives full 1 MiB, blob rejects ERR_INVALID_STATE |
process.stdin.isTTY then sequential text(), text() | second resolves "" | second resolves "" (no regression) |
single text() | pass | pass (fast path unchanged) |
Existing Bun.stdin regressions (07500, 27849, 29787), bun-stdin-slice, process-stdin and blob.test.ts all pass.
Intentionally not covered
Bun.stdin.text()started beforeprocess.stdinis first touched:get_stream_with_cachedoes not consult the in-flight flag, so aFileReadercreated after theReadFileis already running still reads fd 0 independently (silent split, noEEXIST; the two readers sit on different epoll instances). Throwing from.stream()here would makeprocess.stdininitialisation itself throw, which is worse UX than the split. Pre-existing behaviour; the common ordering (listener first) is the one that now rejects.Bun.stdin.formData()whileprocess.stdinholds the reader:get_form_datais not routed through the cached stream (it would need the boundary plumbed intoreadable_stream_to_form_data). Two concurrentformData()/text()calls are still caught by thedo_read_fileclaim; only theprocess.stdin+formData()combination still reads fd 0 before rejecting on the missing content-type.
[review] gate passed · iteration 2 · 4 files touched
fails on main (without fix)
ASAN without fix: 5 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/bun-stdin-concurrent-read.test.ts
bun test v1.4.0 (1c7e2b843)
test/js/bun/util/bun-stdin-concurrent-read.test.ts:
37 | );
38 | const [a, b] = await Promise.all([wrap(Bun.stdin.text()), wrap(Bun.stdin.text())]);
39 | process.stdout.write(JSON.stringify({ a, b }));
40 | `);
41 | expect(stderr).toBe("");
42 | expect(JSON.parse(stdout)).toEqual({
^
error: expect(received).toEqual(expected)
{
"a": {
"len": 1048576,
"state": "resolved",
},
"b": {
- "code": "ERR_INVALID_STATE",
+ "code": "EEXIST",
"name": "Error",
"state": "rejected",
},
}
- Expected - 1
+ Received + 1
at <anonymous> (/workspace/bun/test/js/bun/util/bun-stdin-concurrent-read.test.ts:42:32)
(fail) two concurrent Bun.stdin.text() calls: second rejects ERR_INVALID_STATE, first reads every byte [1124.24ms]
60 | await new Promise(r => process.stdin.once("end", r));
61 | await Promise.resolve();
62 | process.stdout.write(JSON.s
... (truncated)
release without fix: all passed
bun test v1.4.0-canary.1 (1a28f3223)
test/js/bun/util/bun-stdin-concurrent-read.test.ts:
(pass) Bun.stdin.text() with no other consumer reads every byte [23.66ms]
(pass) sequential Bun.stdin.text() still works after process.stdin was referenced without reading [27.48ms]
(pass) Bun.stdin.json() > rejects when process.stdin holds the reader; process.stdin receives every byte [29.53ms]
(pass) Bun.stdin.bytes() > rejects when process.stdin holds the reader; process.stdin receives every byte [30.54ms]
(pass) Bun.stdin.arrayBuffer() > rejects when process.stdin holds the reader; process.stdin receives every byte [31.18ms]
(pass) Bun.stdin.text() > rejects when process.stdin holds the reader; process.stdin receives every byte [32.18ms]
(pass) two concurrent Bun.stdin.text() calls: second rejects ERR_INVALID_STATE, first reads every byte [40.36ms]
7 pass
0 fail
21 expect() calls
Ran 7 tests across 1 file. [392.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/bun-stdin-concurrent-read.test.ts
bun test v1.4.0 (1c7e2b843)
test/js/bun/util/bun-stdin-concurrent-read.test.ts:
(pass) two concurrent Bun.stdin.text() calls: second rejects ERR_INVALID_STATE, first reads every byte [1126.27ms]
(pass) Bun.stdin.text() > rejects when process.stdin holds the reader; process.stdin receives every byte [1319.50ms]
(pass) Bun.stdin.bytes() > rejects when process.stdin holds the reader; process.stdin receives every byte [1318.24ms]
(pass) Bun.stdin.arrayBuffer() > rejects when process.stdin holds the reader; process.stdin receives every byte [1331.07ms]
(pass) Bun.stdin.json() > rejects when process.stdin holds the reader; process.stdin receives every byte [1325.91ms]
(pass) sequential Bun.stdin.text() still works after process.stdin was referenced without reading [1180.62ms]
(pass) Bun.stdin.text() with no other consumer reads every byte [1082.35ms]
7 pass
0 fail
21 expect() calls
Ran 7 tests across 1 file. [4.42s]
__F:0:S:0
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 699ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/32] gen cpp.rs (cppbind)
[2/32] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[3/32] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
- ResolveMessage (13 fields)
- BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
- Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
- ResourceUsage (8 fields)
- Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
- CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
- FileSystemRouter (5 fields)
- FrameworkFileSystemRouter (2 fields)
- MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
- Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2.classes.ts
- H2FrameP
... (truncated)
diff hotspot
src/runtime/webcore/Blob.rs | 76 ++++++++++++++++-
src/runtime/webcore/ReadableStream.rs | 4 +
src/runtime/webcore/blob/read_file.rs | 3 +
test/js/bun/util/bun-stdin-concurrent-read.test.ts | 99 ++++++++++++++++++++++
4 files changed, 178 insertions(+), 4 deletions(-)
gate history · 4 passed · 0 rejected · iteration 2
evidence per changed file
file reads edits tests
src/runtime/webcore/Blob.rs 8 15 0
src/runtime/webcore/ReadableStream.rs 3 1 0
src/runtime/webcore/blob/read_file.rs 2 1 0
test/js/bun/util/bun-stdin-concurrent-read.test.ts 1 3 0
- Worker terminated mid-
Bun.stdin.text(): the in-flight claim is released inNewReadFileHandler::run, which runs on the claiming thread's event loop. A Worker terminated while itsReadFileis parked in the io loop never reaches that callback, so the claim stays set and laterBun.stdinblob reads rejectERR_INVALID_STATE. On main the same scenario already rejectsEEXIST(the dead worker'sio::Pollon fd 0 stays registered), so this is a lateral change in error text, not a regression; clearing the claim inDropwould not help because the handler box is leaked on that teardown path.