Skip to content

Bun.write: reject with EPIPE when a FIFO's reader goes away mid-write (Linux) - #37852

Open
robobun wants to merge 2 commits into
mainfrom
farm/347e37a2/bun-write-fifo-reader-gone
Open

robobun wants to merge 2 commits into
mainfrom
farm/347e37a2/bun-write-fifo-reader-gone

Conversation

@robobun

@robobun robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A Bun.write() into a full FIFO waits for the reader. If the last reader closes, the write must reject with EPIPE. On Linux it rejects with Unknown Error, epoll_ctl (code undefined, errno 0).
  • Cause: Poll::on_update_epoll (src/io/lib.rs:1665) turns the EPOLLERR events bitmask into an errno. The bitmask is never -1, so the errno is always 0. EPOLLERR carries no errno. A waiting Bun.stdin.text() on a socket that gets reset takes the same path.

Fix

  • on_update_epoll delivers every event, EPOLLERR included, as "ready". The owner retries its syscall and gets the real errno: EPIPE for the write, ECONNRESET for the stdin read.
  • No error path is lost. A failing epoll_ctl is reported synchronously at registration in tick_epoll.
  • Verified: test/js/bun/io/bun-write.test.js ("Bun.write() into a FIFO that is full") and test/js/bun/util/bun-file-read.test.ts (stdin ECONNRESET). Both new cases fail on the released binary. Also the rest of bun-write.test.js, bun-file-fd-read.test.ts and bun-stdin-slice.test.ts.

Background

  • A FIFO (named pipe, mkfifo) is a pipe with a filesystem path. A write to a pipe with no reader fails with EPIPE.
  • Bun.write() to anything that is not a regular file runs its write loop on a pool thread. On EAGAIN it hands the fd to the io thread (epoll on Linux) and resumes when told the fd is writable.
  • EPOLLERR and EPOLLHUP are readiness flags, not error codes. The errno for whatever happened comes from the next syscall on the fd.
Notes

This revision is the rebase onto main that was requested. It drops the macOS half of the earlier revision. On macOS the kqueue write filter for a FIFO never fires when the reader closes, so the write never settles there. The earlier revision waited in select(2) on the pool thread instead, and ran that loop outside the job's VM borrow so a worker's teardown did not wait for the reader.

That shape cannot work on main any more. Since #38299 a Job holds a Ticket for its whole trip, and VmHandle::close_and_wait waits, without a bound, for every ticket. The only way to end a parked Bun.write() is JobContext::cancel, which unparks a wait on the io thread through IoParking. A select(2) on a pool thread cannot be cancelled that way, so the earlier revision's own worker-teardown test would hang on the darwin lanes.

The right place for the macOS write side is the io thread. #40099 adds a select(2) watcher thread (src/io/fifo_select.rs) for the read side of named pipes and delivers readiness back into the owner's kqueue, so IoParking cancel keeps working. The write side is a write set on that same thread. I can do that on top of #40099 once it lands.

The EPIPE test is skipIf(isMacOS) for that reason. The drain test runs everywhere. The st_dev and worker-teardown tests from the earlier revision are gone with the macOS code they covered.

Another scenario the same hunk fixes: a pty slave parked in Bun.file(slave).text() whose master closes. The kernel reports EPOLLIN|EPOLLHUP|EPOLLERR at once and read() then returns 0 with the buffered bytes. On 1.4.1 the call rejects with Unknown Error, epoll_ctl and drops the bytes.

Fail-before: both new cases fail on the released binary here. With the debug build, the FIFO block passes 10 of 10 reruns. In the full bun-write.test.js run, "copyFileRange is not available > on large files" exceeded its 5 s budget once under concurrent load (6.3 s) and passed alone at 4.7 s. It is a copy path this change does not touch.

Original description of the earlier revision, for the macOS analysis and XNU references: see the PR history before the rebase.


no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/util/bun-file-read.test.ts, test/js/bun/io/bun-write.test.js

@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: ae90aade-f5e6-4b9a-9df9-11d558cb96da

📥 Commits

Reviewing files that changed from the base of the PR and between f42e980 and 50ca71e.

📒 Files selected for processing (3)
  • src/io/lib.rs
  • test/js/bun/io/bun-write.test.js
  • test/js/bun/util/bun-file-read.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.


Walkthrough

The PR routes all epoll notifications through the readiness callback and adds platform-specific regression tests for FIFO writes and reset errors from piped stdin.

Changes

Pipe I/O behavior

Layer / File(s) Summary
Epoll readiness dispatch
src/io/lib.rs
Poll::on_update_epoll logs raw event flags and dispatches EPOLLERR and other notifications through __bun_io_pollable_on_ready.
FIFO write regression coverage
test/js/bun/io/bun-write.test.js
Non-Windows tests validate large FIFO writes, payload integrity, complete reader draining, and EPIPE when the final reader closes.
Piped stdin reset coverage
test/js/bun/util/bun-file-read.test.ts
Linux-specific subprocess coverage verifies the ECONNRESET result from Bun.stdin.text() after the child’s piped stdin resets.

Suggested reviewers: jarred-sumner

Merge Risk: ⚪ Minimal · up to 9a9af

The change corrects Linux FIFO and socket error reporting with targeted regression coverage. No merge-blocking risk is currently identified.

🚥 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 clearly and concisely identifies the main change: rejecting Bun.write with EPIPE when a FIFO reader closes on Linux.
Description check ✅ Passed The description explains the problem, cause, fix, scope, platform limitations, and verification results. It does not use the exact template headings, but it provides the required information, includin…

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

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:38 PM PT - Sep 5th, 2026

❌ @robobun, your commit 9a9afac has 2 failures in Build #110537 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37852

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

bun-37852 --bun

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed (latest 5dec5cc), waiting on CI. Linux lanes exercise the epoll half; the darwin test lanes are the ones that exercise the select(2) wait.

Reproduced how: test/js/bun/io/bun-write.test.js, "rejects with EPIPE when the last reader closes while it is waiting": against the released binary and against bun bd with src/ stashed it rejects with { code: undefined, syscall: "epoll_ctl" } on Linux (40 of 40 runs); with this change it rejects with { code: "EPIPE", syscall: "write" } (50 of 50). test/js/bun/util/bun-file-read.test.ts, "Bun.stdin.text() reports the socket's error ..." does the same for a waiting ReadFile (40 of 40 fail before, 10 of 10 pass after). The macOS hang is established from the XNU sources in the description (no macOS machine here); the FIFO tests cannot settle there without the new wait, so the darwin lanes are the macOS evidence.

@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 - Carries the same do_write hunk (moving sys::write inside the retry loop so continue re-issues the syscall) plus the neighbouring could_block/fstat change in run_with_fd, and adds FIFO Bun.write coverage to the same test/js/bun/io/bun-write.test.js.
  2. Bun.file(fifo).bytes(): wait for a named pipe's EOF with select(2) on macOS #37823 - Adds the same src/sys/lib.rs plumbing (Tag::select, the select$DARWIN_EXTSN$NOCANCEL import) and the same S_IFIFO && st_dev != 0 inline-select wait for the same macOS root cause, on the read side instead of the write side.
  3. Bun.write: poll instead of spin when a nonblocking stdout pipe returns EAGAIN #35953 - Rewrites the same WriteFile::do_write EAGAIN retry loop to fix the same stale-result spin (by deleting the loop rather than re-issuing the write inside it) and also re-derives could_block in run_with_fd.
  4. io(kqueue): unregister before handing the owner off on close, and bit-test EV_ERROR #37791 - Fixes the sibling event-flags-misread bug in the same impl Poll block of src/io/lib.rs (on_update_kqueue bit-testing EV_ERROR, alongside the on_update_epoll error branch this PR deletes) and adds a FIFO test to the same file.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of any of the four; they are the neighbours the description already accounts for:

The fixed behaviour here, EPIPE instead of an errno-0 epoll_ctl error on Linux and instead of a hang on macOS, is not produced by any of them.

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

I reviewed this PR and didn't find any bugs. Because it changes epoll dispatch semantics for every ReadFile/WriteFile on Linux, introduces a new blocking-select(2)-on-the-pool-thread pattern for macOS FIFOs (with the acknowledged pool-thread-pinning trade-off), and the macOS half couldn't be exercised locally, a human look is warranted before merge.

Checked: on_update_epoll now treating EPOLLERR as readiness — traced that ReadFile::on_ready/WriteFile::on_ready both re-issue the syscall and record the real errno, and that __bun_io_pollable_on_io_error remains live via the kqueue EV_ERROR path. The do_write loop restructure correctly re-issues write() on continue (the old code re-matched a stale result). block_until_writable's bitmap sizing (fd/32 + 1 words for nfds = fd+1) and EINTR retry look right; Tag::select slots into NAMES[108] correctly. The st_dev != 0 FIFO-vs-pipe(2) discriminator is gated behind could_block, so regular files skip the extra fstat.

Extended reasoning...

Overview

Four files: src/io/lib.rs (Linux epoll dispatch), src/runtime/webcore/blob/write_file.rs (macOS named-pipe wait path, do_write loop restructure, new is_named_pipe/polls_before_writing), src/sys/lib.rs (new select$DARWIN_EXTSN$NOCANCEL import, block_until_writable, Tag::select), and two new tests in bun-write.test.js.

Security risks

None identified. No user-controlled input reaches new parsing or allocation paths; the select fd-set is sized from an already-open fd, not external data.

Level of scrutiny

High. The Linux hunk is small but changes how every EPOLLERR from the IoRequestLoop is dispatched — it now treats it as readiness so the owner's next read()/write() surfaces the real errno instead of a fabricated errno-0 epoll_ctl error. That is correct reasoning (the old get_errno(event.events) on a bitmask was always wrong), but it affects ReadFile too, not just the WriteFile case under test. The macOS half introduces a new pattern: instead of parking on the IO thread's kqueue, a FIFO write blocks a work-pool thread in select(2) for as long as the reader is slow. The PR description states the trade-off explicitly and justifies it well (kqueue provably cannot deliver this event for FIFO vnodes), but pool-thread occupancy vs. correctness is the kind of architectural call a maintainer should sign off on.

Other factors

  • The author had no macOS machine; darwin correctness (including the select$DARWIN_EXTSN$NOCANCEL link name and the st_dev != 0 XNU heuristic for distinguishing mkfifo FIFOs from pipe(2) pipes) rests on the CI lanes.
  • The change coordinates with three or four other open PRs (#37823, #36025, #35953, #37787); the description calls out which hunks are shared and how they merge, and notes a follow-up needed for #37787's trace assertions on macOS.
  • Tests: the EPIPE test polls for the observable condition (write refused with EAGAIN) before closing the reader rather than sleeping, and asserts { code, syscall } exactly. The drain test is new coverage for a path (Bun.write waiting on a FIFO) that previously had none, and on macOS now goes through select instead of kqueue. Both look sound; the drain test's Bun.sleep(1) is inside a bounded poll loop, not a fixed wait.
  • The do_write restructure independently fixes a latent spin bug in the non-pollable EAGAIN branch (the old continue re-matched the same result without re-issuing write()).

Given the cross-platform I/O surface, the untested-locally macOS half, and the pool-thread-blocking design decision, this should not be auto-approved.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

On the two points the review says rest on CI: both darwin build lanes of this PR's build (93282) have passed, so the select$DARWIN_EXTSN$NOCANCEL import links on x64 and aarch64; every Linux lane including ASAN has passed as well. The darwin test lanes, which are the ones that can tell the select wait apart from the hang, are still queued on the bare-metal fleet. I will put their result in the status comment above when they report, since that is the evidence for the macOS half; the st_dev discriminator is exercised by the same two tests (the FIFO has to be classified as a named pipe for the EPIPE case to settle at all, and the drain case then runs the whole payload through that path).

Comment thread src/io/lib.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/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/io/lib.rs
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/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Two pushes since the last round, both from going over the diff again:

  • 46791eb: the S_IFIFO && st_dev != 0 test moved into bun_sys::is_named_pipe (with the note on which way it is allowed to be wrong), plus two more tests. bun-write.test.js pins the fstat facts the classification relies on (FIFO has a device number, a pipe(2) pipe has none on macOS; the pipe comes from a shell pipeline because Bun.spawn's stdio pipes are socket pairs). bun-file-read.test.ts covers the ReadFile side of the on_update_epoll change: a child's Bun.stdin.text() waiting on a socket that the parent then resets now rejects with ECONNRESET from recv() instead of the errno-0 epoll_ctl error (fails 40/40 before, passes 10/10 after, Linux only since both the dispatch and the reset-on-close are Linux behaviour).
  • d40f654: comments on the new code cut down; the remaining ones are two to six lines each and each states something the code cannot (why EPOLLERR is readiness, why a named pipe must not be polled first, why the wait is select). The flagged pwrite note is pre-existing text that moved with the write() call. Threads replied to and resolved individually.

Description updated to match.

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

🤖 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/write_file.rs`:
- Around line 473-477: Update the macOS descriptor classification in the write
operation around `sys::fstat` so its error is not swallowed by the `matches!`
expression. Propagate or record the `fstat` failure and terminate the operation,
or otherwise bias the fallback to the safe named-pipe classification instead of
routing failures through the kqueue path; preserve normal `Ok(stat)`
classification via `bun_sys::is_named_pipe`.

In `@src/sys/lib.rs`:
- Around line 7642-7668: Update block_until_writable so it does not wait
indefinitely on a WorkPool thread: use a bounded select timeout and periodically
re-check or return an appropriate timeout/error outcome, while preserving EINTR
handling and writability detection. If the API cannot represent bounded waiting,
document the justification for retaining the unbounded wait and its safety
relative to the prior io-thread registration.

In `@test/js/bun/util/bun-file-read.test.ts`:
- Around line 97-104: Update the child-process assertion around the stdout split
so it validates or compares the raw output before parsing JSON, preserving the
captured stderr and exitCode diagnostics when the child aborts without printing
“reading\n”. Keep the expected structured payload assertion, but avoid calling
JSON.parse on an undefined outcome; anchor the change to the outcome extraction
and combined expect block.
🪄 Autofix

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: b6936302-87a2-4331-873d-98dce088def7

📥 Commits

Reviewing files that changed from the base of the PR and between 9a543cc and d40f654.

📒 Files selected for processing (5)
  • src/io/lib.rs
  • src/runtime/webcore/blob/write_file.rs
  • src/sys/lib.rs
  • test/js/bun/io/bun-write.test.js
  • test/js/bun/util/bun-file-read.test.ts

Comment thread src/runtime/webcore/blob/write_file.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread test/js/bun/util/bun-file-read.test.ts Outdated
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

5dec5cc takes two of the three review points: a failing classification fstat now rejects the write with that error instead of silently choosing the io-thread wait (no test for it: making fstat fail means closing the fd first, and in this concurrent file the number could be reused by a neighbour's open() before the write runs, which is worse than leaving a four-line error branch uncovered), and both child-output tests compare the raw text so a crashed child shows its output and stderr instead of a JSON.parse error. The third, the unbounded select, is intentional and is answered on the thread: the write was equally unbounded in its kqueue registration before, only the thread holding the wait changed. All threads are resolved; description updated.

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

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

The VM-borrow issue I flagged earlier is addressed: run_with_fd now hops to do_write_loop_task before any named-pipe write, so the blocking select() runs outside Job::run_on_pool's borrow, and the new worker-termination test pins that. This pass found no further bugs. A human look is still worthwhile given the macOS half rests on XNU source reading and the darwin test lanes (no local macOS run), the pool-thread-pinning tradeoff is a design call, and the change coordinates with four sibling PRs (#36025, #37823, #35953, #37791).

Also checked this pass and ruled out:

  • __bun_io_pollable_on_io_error is not dead after the epoll caller is removed — on_update_kqueue still uses it for EV_ERROR (which does carry an errno).
  • The select fd_set arithmetic (word + 1 words for nfds = fd + 1) and per-iteration re-init are correct for the $DARWIN_EXTSN variant.
  • Moving sys::write inside the retry loop fixes the pre-existing !could_block EAGAIN spin as a side effect; no other caller of do_write exists.
Extended reasoning...

Overview

Five files: on_update_epoll in src/io/lib.rs now dispatches every event (including EPOLLERR) as readiness so the owner's retried read()/write() reports the real errno; src/sys/lib.rs adds macOS-only Tag::select, the select$DARWIN_EXTSN$NOCANCEL import, is_named_pipe(&stat) and block_until_writable(fd); write_file.rs adds an is_named_pipe field, classifies via fstat when could_block, skips poll(2) before writing for named pipes, waits in select(2) on EAGAIN, and schedules the write loop as its own pool task so the wait runs outside the VM borrow. The sys::write call moved inside do_write's retry loop so continue re-issues the syscall. Four new FIFO tests in bun-write.test.js and one Linux-only ReadFile EPOLLERR test in bun-file-read.test.ts.

Security risks

None identified. No user-controlled parsing, no auth/crypto/permissions. The new select call uses the unlimited-fd Darwin variant with a correctly-sized bitmap; the fstat failure path now rejects the write instead of silently misclassifying.

Level of scrutiny

High. This changes the epoll error-dispatch semantics for both ReadFile and WriteFile on Linux, and introduces a new blocking wait strategy on macOS with a stated tradeoff (a slow FIFO reader now pins a pool thread where it previously parked on the io thread for free). The macOS half is derived from XNU source and exercised only by CI darwin lanes — the author has no local macOS. The description is thorough and cites kernel sources, and the earlier VM-borrow deadlock I raised was fixed with a test, but the design decision (select on the pool vs. accepting the kqueue blind spot) and the four-way PR coordination are things a maintainer should sign off on.

Other factors

My earlier inline finding (the inline select running under run_on_pool's VM borrow, blocking VmHandle::close) was addressed in 2152a5f: the loop is now scheduled as a plain pool task before the first write, and a new test terminates a worker mid-wait and requires its exit event. CodeRabbit's two actionable points (propagate fstat failure; compare child output as text) were taken in 5dec5cc; its unbounded-wait note was correctly declined. The comment-cop bot's remaining two threads on write_file.rs were answered (three-line comments retained with justification). I verified __bun_io_pollable_on_io_error remains live via the kqueue EV_ERROR path, so removing the epoll caller does not leave dead code. The do_write loop restructure also fixes a pre-existing spin in the !could_block EAGAIN branch. Given the platform-specific reasoning, the design tradeoff, and the interaction with #36025/#37823/#35953/#37791, this should have a human reviewer.

@robobun

robobun commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up on an overlap: #40099 adds the same Tag::select / select$DARWIN_EXTSN$NOCANCEL binding in src/sys/lib.rs and a bun_sys::is_named_pipe(&Stat) helper with the same name as the one here. Its src/io/fifo_select.rs is a select(2) watcher thread that delivers a named pipe's readiness into the owning kqueue as an EVFILT_USER event with a NOTE_FIFO_READABLE bit in fflags; a writable wait would be a second bit plus a write set in the same thread, which would cover this PR's macOS half without holding a pool thread per write. The Linux EPOLLERR half here is independent. Whichever lands second rebases.

@robobun

robobun commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

A second scenario for the Linux on_update_epoll hunk, in case it helps when this rebases: a pty slave parked in Bun.file(slave).text() whose master closes. The kernel reports EPOLLIN|EPOLLHUP|EPOLLERR at once, the bytes written before the hangup are still in the tty, and read() then returns 0. On 1.4.1 the call rejects with Unknown Error, epoll_ctl (errno 0) and drops the bytes. With this hunk it resolves with the bytes.

The test is in test/js/bun/util/bun-file-fd-read.test.ts on branch robobun/dc49ebae/read-path-errors (commit 0e84686). It waits for the slave fd to show up armed in /proc/self/fdinfo/<epoll> before it closes the master, so the park is deterministic. Linux only, openpty through bun:ffi. Fails on 1.4.1 and passes with the same src/io/lib.rs change as here. Not opening a separate PR for it since the fix is the one in this PR.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun rebase

@robobun

robobun commented Sep 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (50ca71e). The PR is mergeable and ready for review.

The Linux fix (on_update_epoll delivers EPOLLERR as readiness) and its two tests carried over as they were. Both fail on the released binary and pass with the debug build, and they pass on every CI lane. The two builds since the rebase (110528, 110537) are red only on tests outside this diff: spawn.test.ts stdin EPIPE, test-net-socket-destroy-twice.js on darwin, a Verdaccio startup failure in bun-update-transitive.test.ts, and a napi test_object stall that is pre-existing on main. I reported them for main triage.

I dropped the macOS half. It waited in select(2) on the pool thread, and it depended on the write loop running outside the job's VM borrow so that a worker's teardown did not wait for the reader. Since #38299 a Job holds a Ticket for its whole trip and VmHandle::close_and_wait waits for every ticket without a bound. The only way to end a parked Bun.write() is JobContext::cancel, which unparks a wait on the io thread through IoParking. A select(2) on a pool thread cannot be cancelled that way, so the old revision's own worker-teardown test would have hung on the darwin lanes.

The macOS write side belongs on the io thread, as a write set on the select(2) watcher thread that #40099 adds for the read side (src/io/fifo_select.rs). I can do that on top of #40099 once it lands. The EPIPE test is skipIf(isMacOS) until then.

@robobun
robobun force-pushed the farm/347e37a2/bun-write-fifo-reader-gone branch from 59a2b20 to 50ca71e Compare September 5, 2026 23:51
@robobun robobun changed the title Bun.write: reject with EPIPE when a FIFO's reader goes away mid-write Bun.write: reject with EPIPE when a FIFO's reader goes away mid-write (Linux) Sep 5, 2026
@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

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

LGTM. The scope reduction to Linux-only drops the write_file.rs select() loop my earlier comment flagged, and what remains — treating EPOLLERR as readiness so the owner's retried syscall reports the real errno — is the correct epoll semantics.

Checked that __bun_io_pollable_on_io_error is still live (the kqueue EV_ERROR path at lib.rs:1637 uses it with a real errno from event.data, which is a genuine changelist failure, not a readiness flag), and that epoll_ctl registration failures still surface synchronously via FileAction::on_error in tick_epoll, so no error path is lost. The new tests use bounded poll loops (Bun.sleep(1) only inside EAGAIN retry), using for FIFO/tempDir cleanup, and comment why each platform skip exists; the ECONNRESET test drains stdout by iteration and stderr afterward, which is fine since the child writes nothing to stderr.

Extended reasoning...

Overview

Since the previous review, the PR was squashed down to three files: src/io/lib.rs (the epoll EPOLLERR dispatch fix), test/js/bun/io/bun-write.test.js (FIFO drain + EPIPE tests), and test/js/bun/util/bun-file-read.test.ts (stdin ECONNRESET test). All the macOS work described in the PR body — block_until_writable, is_named_pipe, the select$DARWIN_EXTSN import, and the write_file.rs pool-thread wait loop — is gone from the diff, and the EPIPE test is now skipIf(isMacOS) with a comment explaining the kqueue write-filter limitation. The PR description is stale, but the code that merges is what matters.

The native change is a net −9 lines: the EPOLLERR branch that called sys::get_errno(event.events as isize) (always 0, since an events bitmask is never −1) and dispatched a fabricated epoll_ctl error is deleted, and every event now goes through __bun_io_pollable_on_ready. This matches epoll(7): EPOLLERR and EPOLLHUP are readiness signals with no errno attached; the fd's actual condition comes out of the next read()/write(). My earlier inline concern (the macOS select() running under a VM Borrow, blocking worker.terminate()) was on write_file.rs, which is no longer touched — removing that code resolves it.

Security risks

None. This is purely internal event-dispatch plumbing on an already-registered fd; no user input parsing, no auth/crypto, no new attack surface. The change removes a fabricated errno rather than adding any trust in external data.

Level of scrutiny

Low-to-moderate. The remaining native change is tiny and mechanically verifiable against epoll semantics. I confirmed the two things the deletion could have broken: (1) __bun_io_pollable_on_io_error still has a caller — the kqueue path at lib.rs:1632–1646, where EV_ERROR genuinely carries an errno in event.data from a rejected changelist entry, so that branch is correct to keep; (2) epoll_ctl failures themselves are still reported — they go through FileAction::on_error synchronously in tick_epoll, as the new comment states. REVIEW.md's "fix at the layer that owns the invariant" is exactly what this does: the errno belongs to the owner's syscall, not the dispatch layer.

Other factors

The tests follow the repo's conventions closely: tempDir + using for cleanup, bunExe/bunEnv for the spawned child, Bun.sleep(1) only inside bounded EAGAIN-retry loops (never as a bare wait), platform gating via describe.skipIf/it.skipIf with a comment naming the reason for each exclusion, and stdout/stderr asserted before exit code. The unresolved github-actions inline threads at write_file.rs:530–531 from the earlier iteration are on files no longer in the diff, so they're moot. A maintainer commented just before the scope-reduction commit landed, so the Linux-only shape appears intentional. Exit reason was dry_streak.

This branch has not been deployed

No deployments
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