Skip to content

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

Open
robobun wants to merge 6 commits into
mainfrom
farm/7b31cf56/stdin-single-owner
Open

Bun.stdin: reject concurrent text/bytes/arrayBuffer/json reads instead of racing on fd 0#35833
robobun wants to merge 6 commits into
mainfrom
farm/7b31cf56/stdin-single-owner

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

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:

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

…d of racing on fd 0

Two concurrent Bun.stdin.text() calls each spawned a ReadFile with its
own io::Poll; the second EPOLL_CTL_ADD on fd 0 returned EEXIST after the
loser had already consumed bytes from the pipe, so one promise rejected
with a raw 'EEXIST: file already exists, epoll_ctl' and the other
resolved short. A Bun.stdin blob read alongside process.stdin split the
piped bytes with no error at all.

do_read_file now claims a per-thread in-flight flag for stdin and the
second concurrent caller rejects ERR_INVALID_STATE up front; the claim
is released in NewReadFileHandler::run so sequential reads keep working.
When a ReadableStream has already been cached on the Bun.stdin wrapper
(process.stdin creates one), text/json/arrayBuffer/bytes route through
that stream instead of do_read_file, so a held reader produces the same
ERR_INVALID_STATE as Bun.stdin.stream().getReader().
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5f8a69d5-3617-4a19-a3d8-be844f609a37

📥 Commits

Reviewing files that changed from the base of the PR and between 916492f and 1c7e2b8.

📒 Files selected for processing (4)
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/ReadableStream.rs
  • src/runtime/webcore/blob/read_file.rs
  • test/js/bun/util/bun-stdin-concurrent-read.test.ts

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

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:28 PM PT - Jul 25th, 2026

@robobun, your commit 1c7e2b8 has 2 failures in Build #81986 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35833

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

bun-35833 --bun

Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. webcore(Blob): coalesce concurrent fd-backed blob reads onto a single ReadFile #35832 - Also fixes concurrent fd-backed Blob reads racing on fd 0 (same files, same functions), but coalesces reads onto a single ReadFile instead of rejecting

🤖 Generated with Claude Code

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #35832; they solve the same EEXIST/truncation bug with different designs:

#35832 (coalesce) #35833 (reject)
two concurrent Bun.stdin.text() both resolve with full bytes (second attaches to first ReadFile) second rejects ERR_INVALID_STATE, first resolves full
process.stdin + Bun.stdin.text() still splits (coalesce is ReadFile-to-ReadFile only; process.stdin reads via FileReader) text() rejects, process.stdin receives every byte
platforms POSIX only (#[cfg(not(windows))]) cross-platform
footprint adds a Store field at every construction site (8 files) 2 src files

This PR implements the single-owner reject design (mirroring .stream().getReader() on a locked stream). Happy to close in favour of #35832 if coalescing is preferred and the process.stdin split is handled there separately.

@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/webcore/Blob.rs:484-491 — The reverse ordering is still open: const p = Bun.stdin.text(); process.stdin.on("data", …) — the first call falls through to do_read_file (no cached stream yet) and claims the flag, but the second reaches get_stream_with_cache, which never consults STDIN_BLOB_READ_IN_FLIGHT, so it creates a FileReader on fd 0 that races p's ReadFile and bytes split silently. Not a regression (pre-existing behaviour, and the less-common ordering), but per REVIEW.md "fix the whole class" it's worth either having get_stream_with_cache reject stdin when the flag is set, or noting this as a known residual gap in the PR description.

    Extended reasoning...

    What the bug is

    This PR guards two of the three orderings in which multiple consumers can race on fd 0:

    1. Two concurrent do_read_file calls — guarded by STDIN_BLOB_READ_IN_FLIGHT at Blob.rs:484.
    2. Blob helper called after a stream is cached (process.stdin first, then Bun.stdin.text()) — guarded by fd_cached_stream, which routes through readableStreamTo* so the WHATWG reader lock rejects.

    But the reverse of (2) is unguarded: blob helper first, then stream creation. Nothing in get_stream_with_cache (Blob.rs:1230-1268) checks STDIN_BLOB_READ_IN_FLIGHT before calling ReadableStream::from_blob_copy_ref, so a FileReader is created on fd 0 while a ReadFile task is already mid-flight on the same fd.

    The code path

    const p = Bun.stdin.text();          // (A)
    process.stdin.on("data", c => ...);  // (B)
    • (A) enters get_textfd_cached_stream(self, this) returns None (no stream cached yet — js::stream_get_cached is empty) → falls through to get_text_clonedo_read_fileSTDIN_BLOB_READ_IN_FLIGHT.replace(true) returns false, so the claim succeeds and a ReadFile task is scheduled on fd 0 via the IoRequestLoop.
    • (B) process.stdin is built on Bun.stdin.stream() (src/js/builtins/ProcessObjectInternals.ts), which reaches get_stream_with_cache. get_cached(this_value) returns None (still no cached stream). The function proceeds directly to ReadableStream::from_blob_copy_ref at line 1255 without ever reading STDIN_BLOB_READ_IN_FLIGHT, creates a FileReader-backed stream on fd 0, and caches it.

    Grep confirms STDIN_BLOB_READ_IN_FLIGHT is read in exactly one place (Blob.rs:484, inside do_read_file); get_stream_with_cache never touches it.

    Why the existing guards don't catch it

    • fd_cached_stream only fires when js::stream_get_cached(this_value) is Some — i.e. when a stream has already been materialised. In this ordering the blob helper runs first, so there is nothing cached and get_text takes the do_read_file path.
    • STDIN_BLOB_READ_IN_FLIGHT is checked only by do_read_file. get_stream_with_cache is a different entry point and does not consult it.
    • The two readers use different poll loops: ReadFile registers on the IoRequestLoop's epoll fd, while the stream's FileReader uses the VM's uSockets loop. They are separate epoll instances, so the second registration does not EEXIST — the kernel just delivers readability to whichever wakes first, and bytes split silently between p and process.stdin.

    Step-by-step proof

    1. Bun.stdin.text()get_text (Blob.rs:1270).
    2. fd_cached_streamjs::stream_get_cached(this)None (never called .stream()). Returns None.
    3. Falls through to get_text_clonedo_read_file.
    4. is_stdin_fd_store(self) is true; STDIN_BLOB_READ_IN_FLIGHT.replace(true) returns false (was unset). Guard passes; flag now true. ReadFile scheduled on IoRequestLoop.
    5. process.stdin.on("data", …) → lazily initialises process.stdin, which calls Bun.stdin.stream()get_stream_with_cache (Blob.rs:1230).
    6. get_cached(this_value)None. No check of STDIN_BLOB_READ_IN_FLIGHT. Line 1255 creates a FileReader on fd 0; line 1263 caches it.
    7. Both the ReadFile (step 4) and the FileReader (step 6) now issue read(2) on fd 0 from independent poll loops. Bytes interleave; neither side errors.

    This is exactly the failure mode the PR description names ("bytes split silently"), just for the ordering the tests don't exercise.

    Impact and severity

    • Not a regression: this ordering behaved identically before the PR; the PR is a strict improvement.
    • Less common ordering: users typically wire process.stdin listeners before awaiting Bun.stdin.text(), so the guarded direction is the one most people hit.
    • Node parity: the PR description already notes Node has no single-owner stdin lock either; the locked-stream rejection is framed as a quality improvement, not a compat requirement.
    • Design cost of closing it: making get_stream_with_cache throw synchronously when the flag is set would cause process.stdin initialisation to throw, which may be worse UX than splitting bytes. A softer option is to have the freshly-created stream error asynchronously, or just document the gap.

    Given all of the above this is nit, not blocking — but per REVIEW.md ("Fix the whole class in the same PR… If a site is intentionally excluded, say so in the PR"), it's worth either closing the gap or explicitly acknowledging it as a known residual in the PR description.

    How to fix

    At the top of get_stream_with_cache, mirror the do_read_file guard for the stdin case:

    if is_stdin_fd_store(self) && STDIN_BLOB_READ_IN_FLIGHT.get() {
        return Err(global_this.throw(
            jsc::ErrorCode::INVALID_STATE,
            format_args!("stdin is already being read by another Bun.stdin consumer"),
        ));
    }

    (or reject the returned stream asynchronously if a synchronous throw from process.stdin init is undesirable). Alternatively, add a sentence to the PR description naming this ordering as an intentional exclusion.

  • 🟡 src/runtime/webcore/Blob.rs:1324-1329get_form_data (line 1333, right after get_bytes) still goes straight to to_form_datado_read_file without checking fd_cached_stream, so process.stdin.on('data') + Bun.stdin.formData() still spawns a fresh ReadFile on fd 0 and steals bytes from the stream reader. readable_stream_to_form_data already exists on JSGlobalObject (used in Body.rs:401) — the same routing applies, just needs the content-type passed as the second arg. Nit since Bun.stdin.formData() is exotic and the PR title scoped to text/bytes/arrayBuffer/json, but per REVIEW.md "fix the whole class" it's the missed fifth sibling.

    Extended reasoning...

    What's missed

    The PR updates four Blob body accessors — get_text, get_json, get_array_buffer, get_bytes — to check fd_cached_stream(self, callframe.this()) first and route through the cached ReadableStream when one exists. get_form_data sits immediately after get_bytes in the same impl block (Blob.rs:1333-1339) and follows the identical pattern (to_form_datado_read_file::<ToFormDataWithBytesFn> at Blob.rs:3268-3269), but it is not updated. Its CallFrame parameter is still the discarded _.

    Why the flag guard doesn't cover it

    The PR adds two guards:

    1. STDIN_BLOB_READ_IN_FLIGHT in do_read_file — this DOES protect formData(), so two concurrent Bun.stdin.formData() calls (or formData() + text()) now correctly reject the second with ERR_INVALID_STATE.
    2. fd_cached_stream routing in each accessor — this is what handles the process.stdin case, and formData() doesn't get it.

    process.stdin reads via the cached stream's reader (per the PR description). It never calls do_read_file, so it never sets STDIN_BLOB_READ_IN_FLIGHT. When Bun.stdin.formData() is then called, do_read_file finds the flag false, claims it, and schedules a fresh ReadFile on fd 0 — exactly the second failure mode from the PR description ("ignores the reader, reads fd 0 anyway; bytes split silently"), unfixed for this one method.

    Step-by-step trace

    1. User pipes multipart data to stdin and runs process.stdin.on('data', c => …). This materialises the cached ReadableStream on the Bun.stdin wrapper and locks it with a reader. STDIN_BLOB_READ_IN_FLIGHT remains false.
    2. User calls await Bun.stdin.formData().
    3. get_form_data (Blob.rs:1333) — no fd_cached_stream check — calls to_form_data.
    4. to_form_data (Blob.rs:3268): needs_to_read_file() is true → do_read_file::<ToFormDataWithBytesFn>(global).
    5. do_read_file (Blob.rs:484): is_stdin_fd_store(self) is true, STDIN_BLOB_READ_IN_FLIGHT.replace(true) returns false → guard passes, a ReadFile task is scheduled on fd 0.
    6. Two independent readers now compete for fd 0: the cached stream (feeding process.stdin) and the new ReadFile. Bytes split silently between them; whichever registers its poll second may still surface the raw EEXIST: epoll_ctl.
    7. Even in the common case where Bun.stdin has no content-type set, the "Invalid encoding" rejection happens in to_form_data_with_bytes (Blob.rs:3000) after do_read_file has already consumed bytes from fd 0 — so process.stdin still sees a truncated stream before formData() rejects.

    Fix

    Mirror the four siblings, passing the content type:

    fn get_form_data(&self, global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult<JSValue> {
        if let Some(stream) = fd_cached_stream(self, callframe.this()) {
            let content_type = /* self.get_content_type() as JSValue, or js_undefined() */;
            return bun_jsc::from_js_host_call(global_this, || {
                global_this.readable_stream_to_form_data(stream, content_type)
            });
        }
        ...
    }

    readable_stream_to_form_data already exists (JSGlobalObject.rs:1188) and is used the same way in Body.rs:401, so this is not novel plumbing.

    Severity

    Nit. Piping multipart/form-data into stdin while also reading process.stdin is an exotic combination nobody has filed an issue about, and the PR title explicitly scoped to "text/bytes/arrayBuffer/json". This is a residual gap in pre-existing behaviour, not a regression the PR introduces. Flagging it because REVIEW.md's "Fix the whole class in the same PR — grep for every sibling site sharing the pattern" applies directly: get_form_data is the fifth sibling in the same impl block sharing the identical do_read_file entry, and the fix is a mechanical copy of the four that were done.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Both findings are accurate; added an Intentionally not covered section to the PR body:

  • Reverse ordering (text() before process.stdin is first touched) is left as pre-existing behaviour because checking the flag in get_stream_with_cache would make lazy process.stdin initialisation throw synchronously, which is worse than the split. The two readers sit on different epoll instances so there is no EEXIST in that direction.
  • get_form_data is still guarded by the do_read_file claim against a second concurrent blob read; only the process.stdin + formData() combination falls through, and that path rejects on the missing content-type anyway. Routing it needs the multipart boundary plumbed into readable_stream_to_form_data; left out to keep the diff scoped to the four helpers the fix targets.

Comment thread src/runtime/webcore/Blob.rs
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Referencing process.stdin (e.g. process.stdin.isTTY) caches Bun.stdin's
ReadableStream without locking it; routing text() through that stream
marked it disturbed so a second sequential text() rejected instead of
resolving at EOF. Route only when the cached stream is actually locked,
and narrow the check to stdin so JSBuildArtifact receivers never reach
JSBlob::stream_get_cached. Also make the in-flight flag a process-wide
AtomicBool since the IoRequestLoop epoll set is shared across Workers.
Comment thread src/runtime/webcore/Blob.rs
Comment thread test/js/bun/util/bun-stdin-concurrent-read.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.

All four prior inline findings are addressed and I found nothing new this pass, but this needs a maintainer's call on reject-vs-coalesce (open sibling #35832) and the documented not-covered gaps before landing.

What was reviewed:

  • Claim/release pairing: swap(true, SeqCst) in do_read_file is released in NewReadFileHandler::run on both the Result and Err arms (release precedes the match), and the Windows ReadFileUV path reaches the same run via the ReadFileUvHandler blanket impl.
  • locked_stdin_stream now gates on is_stdin_fd_store + is_locked_value, so an unlocked cached stream (bare process.stdin.isTTY) falls through to do_read_file; Bun.file(0).text() on a fresh wrapper has no cached stream and also falls through.
  • self.dupe() into handler.context shares the same StoreRef, so is_stdin_fd_store(&blob) in the completion handler still matches and the claim is released.
Extended reasoning...

Overview

The PR touches src/runtime/webcore/Blob.rs (+76), ReadableStream.rs (+4), blob/read_file.rs (+3), and adds a 99-line test file. It introduces a process-wide AtomicBool guarding concurrent ReadFile tasks on fd 0, and routes Bun.stdin.{text,json,arrayBuffer,bytes}() through the cached ReadableStream's locked-error path when process.stdin (or a manual reader) already holds it. All four of my earlier inline findings — sequential-after-isTTY regression, over-broad fd match / JSBlob cast, per-thread vs process-wide flag scope, and the missing json test-matrix entry — were addressed in ac43612 and 1c7e2b8, and the worker-termination stuck-claim case was documented as a lateral change.

Security risks

None identified. The change is confined to stdin blob consumption; no auth, crypto, path handling, or untrusted-input parsing is touched. The new state is a single AtomicBool with SeqCst swap/store; no lock-free tricks or shared mutable data beyond that.

Level of scrutiny

Moderate-to-high. This is native concurrency code that adds process-global mutable state and changes user-visible behaviour of a Bun-native API (Bun.stdin.*() now rejects ERR_INVALID_STATE where it previously raced or split bytes). The PR body explicitly leaves the reject-vs-coalesce design choice open against sibling #35832, and the Intentionally not covered section documents three gaps (reverse ordering, formData(), worker-termination) that a maintainer should sign off on rather than a bot.

Other factors

Test coverage is solid for the stated scope: 7 subprocess tests (one per consume method under a locked reader, plus concurrent, sequential-after-isTTY, and the untouched fast path), all test.concurrent, all pipe 1 MiB and assert exact byte counts and the specific error code. The evidence block shows the suite fails on main and passes on the PR under both ASAN-debug and release. I did not approve because (a) the reject-vs-coalesce API decision is explicitly left to a maintainer with a competing open PR, and (b) process-global state guarding a shared kernel resource with documented lifecycle gaps is the kind of change REVIEW.md flags for maintainer agreement.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff compiles clean on every lane that ran (bun_runtime built in 2-4m on all platforms). Builds #81824 and #81986 both failed because their build-cpp sibling jobs expired in the queue before any agent picked them up (state: expired, exit: null), so build-bun had nothing to link. The only test failures on lanes that did run are [flaky]-tagged (20144.test.ts SIGINT/SIGKILL on darwin, no-orphans.test.ts timeout on darwin, tls-client-renegotiation-limit.js).

The new test (test/js/bun/util/bun-stdin-concurrent-read.test.ts) passed on every lane that ran it and passes locally against both debug and release builds. Ready for review once the build-cpp queue clears.

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.

2 participants