Skip to content

Bun.write: poll instead of spin when a nonblocking stdout pipe returns EAGAIN - #35953

Open
robobun wants to merge 6 commits into
mainfrom
farm/4588fdfb/bun-write-stdout-eagain-spin
Open

Bun.write: poll instead of spin when a nonblocking stdout pipe returns EAGAIN#35953
robobun wants to merge 6 commits into
mainfrom
farm/4588fdfb/bun-write-stdout-eagain-spin

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

What's wrong

Once process.stdout.write has run (which dups fd 1 and sets O_NONBLOCK on the dup; O_NONBLOCK lives on the shared open file description, so fd 1 itself becomes nonblocking), any Bun.write(Bun.stdout, ...) that overflows the pipe buffer wedges the process:

// bun repro.mjs | (sleep 2; cat >/dev/null)   -> never exits, every core at 100%
process.stdout.write("x");
const big = Buffer.alloc(1024 * 1024, 65).toString();
await Promise.all(Array.from({ length: 20 }, () => Bun.write(Bun.stdout, big)));

strace shows all pool threads running with zero syscalls, main parked in epoll_wait:

TID   %CPU STAT WCHAN   COMMAND
 main   0.8 Sl   ep_poll bun
 pool0 96.8 Rl   -       Bun Pool 0
 pool1 99.4 Rl   -       Bun Pool 1
 ... (every pool worker at ~100%)

Cause

Three bugs chained:

  1. WriteFile::run_with_fd derived could_block from the file store's seekable, but the stdio stores (Bun.stdout/Bun.stderr) only set mode, so could_block stayed false for a pipe.
  2. WriteFile::do_write issued sys::write() once before its loop { match result { ... } }, and the EAGAIN && !could_block => continue arm re-matched that same cached result forever without re-issuing the syscall: a busy spin with zero forward progress. ReadFile::do_read had the identical stale-result loop.
  3. With (1)/(2) addressed, each concurrent WriteFile tried to register fd 1 with the shared IO-thread epoll; the second EPOLL_CTL_ADD returned EEXIST.

Separately, the synchronous fast path's needs_async fallback re-sent the entire payload after a partial write, duplicating whatever had already been committed. This was masked by (2) because the async path never made progress.

Fix

src/runtime/webcore/blob/write_file.rs:

  • do_write: write(2) on a regular file never returns EAGAIN, so reaching the retry arm means the fd is pollable regardless of the initial guess. Set could_block = true and wait_for_writable(); drop the stale-result loop.
  • run_with_fd: derive could_block from mode != 0 && !is_regular_file(mode). When the destination is a caller-supplied fd, dup it so this WriteFile owns a private fd number for the same open file description (covers Bun.file(rawFd) whose store has mode == 0 as well as stdio); the dup is closed via is_allowed_to_close on finish.

src/runtime/webcore/blob/read_file.rs: apply the same drop-the-loop reshaping to do_read.

src/runtime/webcore/Blob.rs, write_{string,bytes}_to_file_fast: only set needs_async when nothing has been written yet. After a partial write, poll(POLLOUT) and continue the write loop so bytes already committed are not re-sent.

Verification

New test in test/js/bun/io/bun-write.test.js spawns a child that touches process.stdout.write and then fires two rounds of 16 × 64 KiB (< 256 KiB, exercises the fast-path EAGAIN fallback) plus 4 × 256 KiB (>= 256 KiB, goes straight to the thread-pool path) Bun.write(Bun.stdout, ...) calls into a pipe. Without this change the child is killed by the spawn timeout with every pool worker spinning (exitCode: 137, signalCode: "SIGKILL"); with it the child exits 0, all promises resolve with their full byte counts, the parent receives exactly 1 + 2 × (16·64 KiB + 4·256 KiB) bytes, and the fd count sampled around the second round is unchanged (guards the dup-close pairing).

Windows is unaffected (the async write path there is libuv's uv_fs_write).

Related: #35949 keeps stdio writes on the synchronous fast path when the destination is a regular file (ordering), and #32890 stops Bun.write from ftruncateing an fd destination for an empty source; this change covers the pipe-destination wedge. The FileSink's O_NONBLOCK on an inherited-stdio dup, and a single sequential owner for fd 1/2 across Bun.write/console.log/process.stdout, remain as separate follow-ups.


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/bun/io/bun-write.test.js

When fd 1 is a pipe that has been flipped to O_NONBLOCK (process.stdout.write
does this on the shared open file description via a dup), a Bun.write that
overflows the pipe buffer reaches the async WriteFile path with
could_block=false, and the EAGAIN handler there spun on a cached result
without re-issuing write() or polling: every pool worker pegged a core with
the returned promise never settling.

- do_write: EAGAIN implies the fd is pollable; set could_block and
  wait_for_writable instead of looping.
- run_with_fd: derive could_block from the store's mode (stdio stores set
  mode but not seekable), and dup a caller-supplied pollable fd so
  concurrent WriteFile instances each own a distinct fd number for the
  shared IO-thread epoll/kqueue.
- write_{string,bytes}_to_file_fast: only hand off to the async path when
  nothing has been written yet; after a partial write, poll(POLLOUT) and
  continue so bytes already committed are not re-sent.
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:24 PM PT - Jul 26th, 2026

@autofix-ci[bot], your commit 82c7c82 has 1 failures in Build #82571 (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    bun-linux-x6472.47 MB71.95 MB+528.0 KB
    bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    bun-windows-x6480.26 MB79.70 MB+570.5 KB
    bun-windows-aarch6470.86 MB70.34 MB+533.0 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 35953

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

bun-35953 --bun

Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/blob/write_file.rs Outdated
Comment thread src/runtime/webcore/blob/write_file.rs Outdated
Comment thread src/runtime/webcore/blob/write_file.rs Outdated
Comment thread src/runtime/webcore/blob/write_file.rs Outdated
Comment thread src/runtime/webcore/blob/write_file.rs Outdated
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 18 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: dde08bdb-5341-4bdb-a663-14aaf46b8de8

📥 Commits

Reviewing files that changed from the base of the PR and between 0f4ca9d and 82c7c82.

📒 Files selected for processing (1)
  • test/js/bun/io/bun-write.test.js

Walkthrough

Changes

POSIX I/O retry handling

Layer / File(s) Summary
Descriptor setup and ownership
src/runtime/webcore/blob/write_file.rs
WriteFile detects caller-supplied descriptors, duplicates them for private polling, updates close eligibility, and handles duplication failures.
Read and write retry handling
src/runtime/webcore/blob/read_file.rs, src/runtime/webcore/blob/write_file.rs, src/runtime/webcore/Blob.rs, test/js/bun/io/bun-write.test.js
Retryable reads and writes update blocking state; fast writes poll after partial EAGAIN, and a nonblocking-pipe test validates repeated output.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main fix to nonblocking stdout write handling.
Description check ✅ Passed It includes the PR purpose and detailed verification, though it uses different headings than the template.

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

Comment thread src/runtime/webcore/Blob.rs
Comment thread src/runtime/webcore/blob/write_file.rs

@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):

  • 🟡 test/js/bun/io/bun-write.test.js:762-769 — The PR description states "fd count before/after 160 such writes is unchanged", but this test only asserts {length, stderr, exitCode, signalCode} — there is no fd-count check. Since run_with_fd now dups caller-supplied pollable fds and relies on is_allowed_to_close's new opened_fd != fd branch to release them, consider having the child compare readdirSync(process.platform === 'darwin' ? '/dev/fd' : '/proc/self/fd').length before and after the Promise.all and emit the delta on stderr so a regression in that predicate would fail CI.

    Extended reasoning...

    What the gap is

    This PR introduces a new resource-acquisition path: when the destination is a caller-supplied pollable fd (e.g. Bun.stdout backed by a pipe), run_with_fd now calls bun_sys::dup(fd) so each concurrent WriteFile owns a private fd number for the epoll/kqueue interest set. The paired release lives in the newly-extended is_allowed_to_close:

    PathOrFileDescriptor::Fd(fd) => {
        self.opened_fd != Fd::INVALID && self.opened_fd != fd
    }

    The PR description's Verification section states "fd count before/after 160 such writes is unchanged", but the added test at test/js/bun/io/bun-write.test.js:762-769 asserts only {length, stderr, exitCode, signalCode}. There is no fd-count assertion anywhere in the child script or the parent — the claim is a manual verification.

    Why it matters

    REVIEW.md is explicit on both points:

    "Verified manually", unnamed "existing tests", and benchmarks don't count, even for one-liners.

    Pair every acquisition with its release at the acquisition site … a struct gaining an owning field wires its release into … ALL lifecycle exits … in the same commit.

    If the opened_fd != fd comparison ever regresses (e.g. someone refactors is_allowed_to_close back to pathlike.is_path(), or opened_fd gets reset to the store fd before on_finish), every large Bun.write(Bun.stdout, ...) to a nonblocking pipe leaks one fd and no test catches it.

    Step-by-step: what a regression would look like

    1. Child runs process.stdout.write("x") → fd 1's open file description is now O_NONBLOCK.
    2. Child fires 8 × Bun.write(Bun.stdout, large) (≥ 256 KiB each → thread-pool WriteFile path).
    3. For each, run_with_fd sees caller_supplied_fd && could_blockbun_sys::dup(1)opened_fd = 4, 5, 6, ….
    4. Each write completes; on_finish calls do_close(is_allowed_to_close()).
    5. Today: opened_fd (4) != store fd (1)true → dup is closed. ✅
    6. Regressed: predicate returns false → dup is never closed → fd 4, 5, 6, … leak.
    7. Test still passes: stdout.length, stderr, exitCode, signalCode are all unchanged by an fd leak.

    Suggested fix

    Have the child sample the fd table around the Promise.all and emit it on stderr (harness already has getFDCount(), but the child is a -e script so inline it):

    const fdDir = process.platform === 'darwin' ? '/dev/fd' : '/proc/self/fd';
    const before = require('fs').readdirSync(fdDir).length;
    const wrote = await Promise.all(ps);
    const after = require('fs').readdirSync(fdDir).length;
    process.stderr.write("wrote=" + wrote.reduce((a, b) => a + b, 0) + " fdDelta=" + (after - before));

    Then assert stderr: "wrote=" + (expected - 1) + " fdDelta=0" in the parent. This backs the PR description's claim with an automated assertion and guards the new acquisition/release pair going forward.

    Severity

    nit — the primary behavioral change (spin-wedge → poll → completes) is covered by an automated test that fails on the unfixed build with SIGKILL. This is a coverage-hardening ask for the secondary dup lifecycle; the release logic is straightforward and correct as written.

  • 🟡 src/runtime/webcore/blob/write_file.rs:480-491 — The dup-for-polling guard if self.could_block && caller_supplied_fd is evaluated using could_block = file.mode != 0 && ..., but Bun.file(rawFd) for a non-stdio fd never populates mode (it stays 0 via File::init's ..Default::default()), so the dup is skipped. Then do_write flips could_block = true on the first EAGAIN and calls wait_for_writable() on the original caller-supplied fd — two concurrent Bun.write(Bun.file(pipeFd), ...) calls hit the exact EEXIST/udata-clobber collision the dup was added to prevent. Consider dupping unconditionally when caller_supplied_fd, or dupping lazily in do_write before the first wait_for_writable() when opened_fd still equals the store fd.

    Extended reasoning...

    What the bug is

    The PR adds a dup() in run_with_fd so that each WriteFile targeting a caller-supplied pollable fd owns a private fd number for epoll/kqueue registration. The guard is:

    if self.could_block && caller_supplied_fd {
        match bun_sys::dup(fd) { ... self.opened_fd = duped ... }
    }

    where could_block was just computed as file.mode != 0 && !bun_sys::is_regular_file(file.mode). This works for Bun.stdout/Bun.stderr because their stores are constructed via the stdio ctor which fstat()s up front and populates mode. But for Bun.file(rawFd) where rawFd > 2, mode is never populated.

    The code path

    Bun.file(fd) for fd > 2find_or_create_file_from_path (Blob.rs:3754-3776) — fd.stdio_tag() is None, so it falls through to Store::init_file(PathOrFileDescriptor::Fd(fd), None)File::init (webcore_types.rs:788):

    pub fn init(pathlike: PathOrFileDescriptor, mime_type: Option<MimeType>) -> File {
        File { pathlike, mime_type: ..., ..Default::default() }  // mode: 0
    }

    Nothing along the write_file_internalWriteFile::run_with_fd path stats the destination, so mode stays 0 when the guard executes.

    Step-by-step

    Take two concurrent Bun.write(Bun.file(pipeFd), Buffer.alloc(256*1024)) calls where pipeFd is the write end of an O_NONBLOCK pipe (fd 7, say):

    1. Both reach run_with_fd. caller_supplied_fd = true, file.mode == 0could_block = (0 != 0 && ...) = false.
    2. The dup guard if false && true fails; opened_fd stays 7 for both.
    3. Both call do_write_loopdo_writesys::write(7, ...). The first write partially succeeds; a subsequent one returns EAGAIN.
    4. do_write's EAGAIN arm now sets self.could_block = true and calls self.wait_for_writable(), which schedules on_request_writableio::Action::Writable { fd: 7, poll: &mut self.io_poll, ... }.
    5. Both WriteFile instances register fd 7 with the IO thread's epoll set. On Linux the second EPOLL_CTL_ADD returns EEXIST (surfaces as a rejected promise); on kqueue the second registration overwrites the first's udata, so the losing instance's poll never fires and its promise never settles.

    This is exactly bug (3) from the PR description, just for a store whose mode was never populated.

    Why existing code doesn't prevent it

    The dup is gated on the initial value of could_block, but do_write can flip could_block to true after run_with_fd has already decided not to dup. is_allowed_to_close is safe here (it returns false since opened_fd == store fd, so the caller's fd is not accidentally closed), but that just means the fd isn't leaked — it doesn't help with the epoll collision.

    Impact and severity

    This is not a regression: before this PR, the same scenario hit the infinite-spin bug (the old EAGAIN && !could_block => continue re-matched a cached result forever), which is strictly worse. After this PR, single writes to Bun.file(nonStdioPipeFd) work where they didn't before, and only the concurrent case can still fail (with a clean error on Linux rather than a wedge). The trigger — a user-owned nonblocking pipe/socket fd > 2 with concurrent ≥256 KiB Bun.write calls — is narrow. Marking as a nit, but per REVIEW.md's "fix the whole class in the same PR" rule the sibling case is worth closing here since the fix is trivial.

    Fix

    Either:

    • dup unconditionally when caller_supplied_fd (an extra dup on a regular-file fd is cheap and never registered anyway), or
    • dup lazily in do_write right before the first wait_for_writable() when self.opened_fd still equals the store's fd.

Comment thread src/runtime/webcore/blob/write_file.rs
Comment thread test/js/bun/io/bun-write.test.js Outdated
Comment thread src/runtime/webcore/blob/write_file.rs

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webcore/Blob.rs`:
- Around line 5445-5461: The EAGAIN handling in write_string_to_file_fast at
src/runtime/webcore/Blob.rs#L5445-L5461 and write_bytes_to_file_fast at
src/runtime/webcore/Blob.rs#L5531-L5545 must not block with poll(..., -1) after
partial writes. Remove the synchronous POLLOUT polling and route any EAGAIN,
including after partial progress, through the existing async path so the
main-thread Bun.write() fast path yields instead of blocking.

In `@src/runtime/webcore/blob/write_file.rs`:
- Around line 447-477: Update the caller_supplied_fd handling in run_with_fd so
the private dup occurs only when the fd-backed blob has could_block true;
regular-file descriptors must retain the existing non-polling path without
dup/close overhead. Verify that file.mode is populated from the earlier fstat
for fd-backed blobs before this logic runs, and preserve do_write’s RETRY
behavior when mode remains unknown.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 924d5b67-6b70-4328-92f5-16e910d967b3

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and 0f4ca9d.

📒 Files selected for processing (4)
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/blob/read_file.rs
  • src/runtime/webcore/blob/write_file.rs
  • test/js/bun/io/bun-write.test.js

Comment thread src/runtime/webcore/Blob.rs
Comment thread src/runtime/webcore/blob/write_file.rs

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

Beyond the inline timeout nit: the fast-path poll(POLLOUT, -1) after a partial write (coderabbitai's unresolved comment on Blob.rs:5461) was examined — falling through to needs_async there would re-send bytes already committed, and the payload is <256 KiB by construction, so the bounded block is the correct tradeoff here. The unconditional dup on caller-supplied fds (coderabbitai's other comment) is deliberate per the earlier thread — Bun.file(rawFd) stores have mode == 0, so gating on could_block would leave those un-duped and reintroduce the epoll EEXIST collision.

Extended reasoning...

The two unresolved coderabbitai comments overlap with concerns already worked through on this PR. The poll(-1)-blocks-main-thread finding was raised by a finder and refuted by verifiers: the fast path only reaches that branch after a partial write on a <256 KiB payload, and the alternative (setting needs_async and returning) hands the full original buffer to WriteFile, duplicating whatever was already written — that's the bug the branch exists to avoid. The unconditional-dup-for-regular-file-fds observation is the intentional flip side of the fix I asked for earlier (dup regardless of the upfront could_block guess so mode==0 stores are covered); a dup+close per Bun.write to a raw fd is cheap relative to the write itself. Leaving approval to a human given this reshapes fd ownership and is_allowed_to_close semantics on a hot I/O path.

Comment thread test/js/bun/io/bun-write.test.js Outdated
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: build #82571 finished with 195/196 lanes passing. The only failure is the binary-size check, which is comparing against a stale main baseline (build #79916; current main is #81770) and fails on unrelated PRs the same way (e.g. #82551, #82554). This diff is net -6 lines across four files and cannot account for 500+ KB on every target.

The flaky-retry annotation lists in-process-cron.test.ts, require-cache.test.ts, multi-run.test.ts and 20144.test.ts, all of which passed on retry and none of which touch blob I/O. The new test in bun-write.test.js passed on every lane.

Ready for review; the binary-size baseline needs a refresh independent of this change.

Comment thread src/runtime/webcore/blob/write_file.rs
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up for the rebase: #37128 routes Bun.write(Bun.stdout, string | buffer) through the shared stdio sink, so once it lands the test here (which triggers the spin through process.stdout + Bun.write(Bun.stdout, ...)) will pass without the write_file.rs change. The spin itself is unaffected by #37128 (it does not touch blob/write_file.rs) and still reproduces on current main without going through stdio, which would make a more durable test:

import { openSync, readSync, constants } from "node:fs";
// fifo created with mkfifo beforehand
const rfd = openSync(fifo, constants.O_RDONLY | constants.O_NONBLOCK);
const wfd = openSync(fifo, constants.O_WRONLY | constants.O_NONBLOCK);
const done = Bun.write(Bun.file(wfd), Buffer.alloc(1024 * 1024, "A"));
// drain rfd from a setInterval; `done` never resolves, one pool thread sits at 100% CPU,
// and the reader only ever receives the first partial write (8192 bytes here).

Closed #35956 today as superseded by #37128; its write_file.rs hunk was the same fix as this PR's, so this PR and #36025 are where that fix lives now.

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