Bun.stdin: reject concurrent text/bytes/arrayBuffer/json reads instead of racing on fd 0

Closed
RO
2mo ago
+178-4

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_file claims a process-wide STDIN_BLOB_READ_IN_FLIGHT atomic before scheduling a stdin ReadFile; a second concurrent caller rejects ERR_INVALID_STATE immediately. NewReadFileHandler::run releases the claim on completion so sequential reads keep working.
  • When the ReadableStream cached on the Bun.stdin wrapper is locked (held by process.stdin or a manual .getReader()), get_text/get_json/get_array_buffer/get_bytes route through readableStreamTo{Text,JSON,ArrayBuffer,Bytes} so the held reader produces the same ERR_INVALID_STATE: ReadableStream is locked as a second getReader() would. A cached-but-unlocked stream (e.g. after a bare process.stdin.isTTY) falls through to do_read_file so 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:

scenariobeforeafter
two concurrent Bun.stdin.text()one rejects EEXIST, other truncatedfirst resolves full 1 MiB, second rejects ERR_INVALID_STATE
process.stdin.on('data') + Bun.stdin.{text,bytes,arrayBuffer}()bytes split, no errorprocess.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()passpass (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 before process.stdin is first touched: get_stream_with_cache does not consult the in-flight flag, so a FileReader created after the ReadFile is already running still reads fd 0 independently (silent split, no EEXIST; the two readers sit on different epoll instances). Throwing from .stream() here would make process.stdin initialisation 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() while process.stdin holds the reader: get_form_data is not routed through the cached stream (it would need the boundary plumbed into readable_stream_to_form_data). Two concurrent formData()/text() calls are still caught by the do_read_file claim; only the process.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 in NewReadFileHandler::run, which runs on the claiming thread's event loop. A Worker terminated while its ReadFile is parked in the io loop never reaches that callback, so the claim stays set and later Bun.stdin blob reads reject ERR_INVALID_STATE. On main the same scenario already rejects EEXIST (the dead worker's io::Poll on fd 0 stays registered), so this is a lateral change in error text, not a regression; clearing the claim in Drop would not help because the handler box is leaked on that teardown path.