Skip to content

Bun.write: deliver the full payload to a FIFO named by path - #36028

Closed
robobun wants to merge 3 commits into
mainfrom
farm/c3bec125/bun-write-fifo
Closed

Bun.write: deliver the full payload to a FIFO named by path#36028
robobun wants to merge 3 commits into
mainfrom
farm/c3bec125/bun-write-fifo

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Repro

mkfifo /tmp/f
(cat /tmp/f > /tmp/o &)
bun -e 'await Bun.write("/tmp/f", "x".repeat(1<<20))'  # never settles
# or with 200 KiB: rejects ENXIO, /tmp/o has only the pipe-buffer prefix

Cause

Two independent faults in the path-destination write paths:

  1. Torn write then ENXIO (string/TypedArray < 256 KiB). write_{string,bytes}_to_file_fast::<true> opens the FIFO O_WRONLY|O_NONBLOCK, loops write(), and on EAGAIN sets needs_async and returns. Its CloseOnDrop guard closes the fd, which removes the FIFO's only writer, so the reader's blocked read() returns 0 and exits with only the pipe-buffer-sized prefix. The async fallback reopens O_WRONLY|O_CREAT|O_TRUNC|O_NONBLOCK with no reader present and the promise rejects ENXIO.

  2. Never settles (Blob/Response or any source >= 256 KiB). WriteFile::run_with_fd left could_block=false for every path-opened destination, and do_write computed sys::write(...) once outside a loop { match &result { ... } }; the EAGAIN && !could_block => continue arm re-matched the same stale error forever without re-issuing the syscall or registering for POLLOUT, so a pool worker spins at 100% with zero syscalls while the reader sits in pipe_r.

Bun.file(fifo).writer() already works: its open path fstats and marks pipes pollable, and its streaming writer resumes at the saved offset on POLLOUT on the same fd.

Fix

src/runtime/webcore/Blob.rs, write_{string,bytes}_to_file_fast: after opening a path, fstat the fd; when not a regular file, hand the already-open fd to the async WriteFile via a new opened_fd parameter instead of writing here, so the FIFO is never closed and reopened mid-payload. Regular-file writes keep the existing synchronous loop.

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

  • do_write: drop the stale-result loop. write(2) on a regular file never returns EAGAIN, so reaching that arm means the fd is pollable; set could_block = true and wait_for_writable().
  • run_with_fd: fstat a path-opened fd to derive could_block so pipes use the POLLOUT wait from the start and skip preallocate_file.
  • create/create_with_ctx: accept a pre-opened fd. FileOpener::get_fd already short-circuits when opened_fd is set, and is_allowed_to_close() (path-backed) closes it on finish.

write_file_with_source_destination threads the fd through; a scopeguard closes it if construction of the source/destination blobs throws before WriteFile adopts it.

Verification

New describe.skipIf(isWindows) block in test/js/bun/io/bun-write.test.js with four cases (200 KiB string via the fast path; 1 MiB string/Uint8Array/Blob via the thread-pool path). Each spawns a child that mkfifos, opens the read end synchronously (O_RDONLY|O_NONBLOCK) so the write-side open has a reader, hands that fd to a draining grandchild, and asserts Bun.write resolves with the full byte count and the reader receives every byte. Without the fix the 200 KiB case rejects ENXIO with a short delivery and the 1 MiB cases are killed by the 20 s spawn timeout with a pool worker spinning.

Related: #35953 fixes the same do_write spin for Bun.stdout pipes and dups caller-supplied fds for epoll; this PR covers the FIFO-by-path entry and the fast-path fd handoff. The do_write hunk is the same shape and will merge cleanly with whichever lands second.


no test proof · iteration 0 · 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

Writing to a FIFO via Bun.write(path, data) had two failure modes:

(1) For string/TypedArray < 256 KiB the synchronous fast path opened
    the FIFO O_WRONLY|O_NONBLOCK, looped write(), and on EAGAIN set a
    needs_async flag and returned. The close-on-drop guard closed the
    fd, which removed the FIFO's only writer, so the reader's blocked
    read() saw EOF and exited with only the pipe-buffer-sized prefix.
    The async fallback then reopened O_WRONLY|O_NONBLOCK with no
    reader present and rejected ENXIO.

(2) For Blob/Response or any source >= 256 KiB the thread-pool
    WriteFile path treated every path-opened destination as
    could_block=false and its do_write() matched a write() result
    hoisted outside the loop, so on EAGAIN the !could_block arm
    'continue'd against the same stale error forever: 100% CPU on a
    pool worker, zero syscalls, promise never settles.

Fix:

- write_{string,bytes}_to_file_fast: after opening a path, fstat the
  fd; when not a regular file hand the already-open fd to the async
  WriteFile via a new opened_fd parameter so the FIFO is never closed
  and reopened mid-payload. Regular-file writes keep the existing
  synchronous loop.
- WriteFile::do_write: drop the stale-result loop. EAGAIN on a regular
  file is impossible, so set could_block=true and wait_for_writable().
- WriteFile::run_with_fd: fstat a path-opened fd to derive could_block
  so FIFOs use the POLLOUT wait from the start and skip
  preallocate_file.
- write_file_with_source_destination / WriteFile::create: thread the
  pre-opened fd through; FileOpener::get_fd already short-circuits
  when opened_fd is set and is_allowed_to_close() (path-backed) closes
  it on finish.

Test: four spawn cases (200 KiB string via the fast path; 1 MiB
string/Uint8Array/Blob via the thread-pool path) mkfifo, pre-open the
read end synchronously so the write-side O_NONBLOCK open has a
reader, drain through a child, and assert Bun.write resolves with the
full byte count and the reader receives every byte.
@coderabbitai

coderabbitai Bot commented Jul 26, 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: 10 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: e0a44e1c-3e07-4fc3-beca-d8bf3bcac1a6

📥 Commits

Reviewing files that changed from the base of the PR and between 43d60f6 and cb2957f.

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

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

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:03 PM PT - Jul 26th, 2026

@robobun, your commit cb2957f is building: #82969

Comment thread src/runtime/webcore/Blob.rs Outdated
Comment on lines +4697 to +4699
// Only the fast path in `write_file_internal` supplies a pre-opened fd, and
// it only runs for string/ArrayBuffer (→ Bytes) sources into a path-backed
// File destination, so the fd is consumed on the File+Bytes arm below.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/webcore/Blob.rs Outdated
Comment on lines +5145 to +5146
// If the fast path handed us an open fd and anything below throws before
// `WriteFile` adopts it, close it instead of leaking.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/webcore/Blob.rs Outdated
Comment on lines +5445 to +5448
// A FIFO/socket/chardev opened O_NONBLOCK will EAGAIN once the pipe buffer
// fills. Closing after a partial write delivers EOF to the reader and the
// async fallback's reopen then fails ENXIO, so hand the open fd to the
// async `WriteFile` (which owns the POLLOUT drain) instead of writing here.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/webcore/Blob.rs Outdated
Comment on lines +5548 to +5549
// See `write_string_to_file_fast`: hand a non-regular destination's open
// fd to the async `WriteFile` instead of risking a torn partial write.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/webcore/blob/write_file.rs Outdated
Comment on lines +352 to +354
// write(2) on a regular file never returns EAGAIN, so reaching
// this arm means the fd is pollable regardless of what the
// open-time classification guessed.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +454 to +455
// This is mostly to handle pipes which were passsed to the process somehow
// such as stderr, stdout. Bun.stdin and Bun.stderr will automatically set `mode` for us.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/webcore/blob/write_file.rs Outdated
Comment on lines +460 to +462
// We just opened the path with O_NONBLOCK. For a FIFO/socket/
// character device that open succeeds and every write can
// EAGAIN, so classify from fstat instead of assuming regular.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Writing Response to disk hangs indefinitely (1.4 regression) #35854 - The do_write restructuring (moving write() inside the loop, POLLOUT-based EAGAIN handling) directly addresses the hang when writing a streaming Response body to a file path
  2. Bun.write with new Response(req.body) hangs #13237 - Same class of bug: Bun.write with a Response wrapping a streaming body hangs indefinitely due to the infinite-loop in the old do_write path

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #35854
Fixes #13237

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Bun.write: deliver the whole payload to a FIFO instead of a torn prefix #36025 - Fixes the same two FIFO bugs (torn write + ENXIO in the sync fast path, and infinite EAGAIN spin in do_write) with the same approach (fstat to detect non-regular files, fd handoff to async WriteFile, POLLOUT-based wait instead of spinning)

🤖 Generated with Claude Code

Comment on lines +5442 to +5443
// Hand a FIFO/socket/chardev fd to the async WriteFile: closing here after a
// partial write would EOF the reader and make the async reopen fail ENXIO.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #36025 (same fd-handoff + fstat + do_write EAGAIN fix, opened ~40 min earlier). Left a note there on the one do_write difference; branch kept for reference.

@robobun robobun closed this Jul 27, 2026
Comment on lines +738 to +743
describe.skipIf(isWindows)("Bun.write to a FIFO by path", () => {
// 200 KiB exercises the <256 KiB sync fast path; 1 MiB exercises the
// thread-pool WriteFile path (string/ArrayBuffer) and the Blob path.
it.each([
["200 KiB string", "string", 200 * 1024],
["1 MiB string", "string", 1 << 20],

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.

🟡 The test matrix has no <256 KiB Uint8Array case, so the new fd-handoff block in write_bytes_to_file_fast (Blob.rs:5548-5558) is never executed — the 1 MiB Uint8Array exceeds the 256 KiB threshold at Blob.rs:5109 and skips the fast path entirely. Add ["200 KiB Uint8Array", "u8", 200 * 1024] to the it.each matrix so both sibling entry points receiving the same fix are covered; without it, deleting that handoff block would not break any test.

Extended reasoning...

What the gap is

This PR adds identical fd-handoff logic to two sibling fast-path functions:

  • write_string_to_file_fast::<true> — the new block at Blob.rs:5445-5457
  • write_bytes_to_file_fast::<true> — the new block at Blob.rs:5548-5558

Both blocks do the same thing: after opening a path with O_WRONLY|O_NONBLOCK, fstat the fd, and if it's not a regular file, hand the open fd to the async WriteFile via *handoff_fd instead of writing synchronously and risking a torn partial write on EAGAIN.

The new test's variant matrix at bun-write.test.js:738-743 is:

it.each([
  ["200 KiB string", "string", 200 * 1024],
  ["1 MiB string", "string", 1 << 20],
  ["1 MiB Uint8Array", "u8", 1 << 20],
  ["1 MiB Blob", "blob", 1 << 20],
])

Why the second handoff block is never reached

The ArrayBuffer fast path is gated at Blob.rs:5109:

} else if let Some(buffer_view) = data.as_array_buffer(global_this) {
    if buffer_view.byte_len < 256 * 1024 {
        ...
        write_bytes_to_file_fast::<true>(...)

The only Uint8Array case in the matrix is 1 MiB (1 << 20 = 1,048,576 bytes), which is ≥ 256 KiB, so write_bytes_to_file_fast is never called. That case falls straight through to the thread-pool WriteFile path — which exercises the do_write / run_with_fd fixes in write_file.rs, but not the fast-path handoff. The 200 KiB string case exercises write_string_to_file_fast's handoff; the 1 MiB string and Blob cases both exercise the thread-pool path. Nothing exercises write_bytes_to_file_fast's handoff.

Step-by-step: what happens if the handoff block at 5548-5558 is deleted

Take a hypothetical 200 KiB Uint8Array to a FIFO with a slow reader:

  1. buffer_view.byte_len = 204,800 < 262,144 → enters write_bytes_to_file_fast::<true>.
  2. Opens the FIFO O_WRONLY|O_CREAT|O_TRUNC|O_NONBLOCK → fd.
  3. Without the handoff block, execution falls through to line 5564: let _close = NEEDS_OPEN.then(|| bun_sys::CloseOnDrop::new(fd)); — the close-on-drop guard is armed.
  4. The sync write() loop writes ~64 KiB (Linux default pipe buffer) then gets EAGAIN.
  5. The EAGAIN arm at lines ~5578-5580 sets *_needs_async = true and returns.
  6. _close drops, closing the fd. The FIFO's only writer is gone → the reader's blocked read() returns 0 (EOF) and it exits with only the pipe-buffer-sized prefix.
  7. Back in write_file_internal, dest_opened_fd is still Fd::INVALID (the handoff never set it), so the async fallback reopens O_WRONLY|O_CREAT|O_TRUNC|O_NONBLOCK with no reader present → ENXIO, and the promise rejects.

This is exactly the torn-write bug the PR fixes for strings — silently unfixed for the ArrayBuffer entry point if that block were removed. But no test in this PR would catch it: the only <256 KiB case is a string.

Why this matters per REVIEW.md

REVIEW.md's test rules (which per the doc's own header "have blocked merges"):

Cover the variant matrix, not just the repro. Every sibling entry point receiving the same fix

Confirm deleting each load-bearing clause of your fix breaks at least one test — a test that passes both ways is worse than no test.

Deleting the load-bearing handoff block in write_bytes_to_file_fast breaks zero tests. Both sibling entry points received the same fix; only one is tested.

Fix

One line in the it.each matrix:

it.each([
  ["200 KiB string", "string", 200 * 1024],
  ["200 KiB Uint8Array", "u8", 200 * 1024],   // ← add: exercises write_bytes_to_file_fast handoff
  ["1 MiB string", "string", 1 << 20],
  ["1 MiB Uint8Array", "u8", 1 << 20],
  ["1 MiB Blob", "blob", 1 << 20],
])

The comment above the matrix ("200 KiB exercises the <256 KiB sync fast path") already states the intent — it just wasn't applied to the u8 kind.

Severity

Marking this nit: the production fix is present and correct on both code paths, so merging as-is causes no user-facing failure. The gap is purely test-coverage — a one-line matrix addition — but it does leave one of the two load-bearing hunks in this PR untested, which REVIEW.md explicitly calls out.

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