Skip to content

Bun.serve: stop FIFO file responses from keeping the process alive - #37083

Open
robobun wants to merge 6 commits into
mainfrom
farm/36917ccf/fifo-response-abort-hang
Open

Bun.serve: stop FIFO file responses from keeping the process alive#37083
robobun wants to merge 6 commits into
mainfrom
farm/36917ccf/fifo-response-abort-hang

Conversation

@robobun

@robobun robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

What

A Response(Bun.file(fifo)) body whose read is parked on a poll that will never fire keeps the bun process alive forever. After the client aborts and server.stop(true) runs, the process never exits:

import fs from "node:fs";
import { execSync } from "node:child_process";
const fifo = `/tmp/f-${process.pid}`;
execSync(`mkfifo ${fifo}`);
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch() { return new Response(Bun.file(fifo)); } });
try { await fetch(`http://127.0.0.1:${server.port}/`, { signal: AbortSignal.timeout(1500) }); } catch (e) { console.log(e.name); }
server.stop(true);
fs.unlinkSync(fifo);
// prints TimeoutError, then hangs forever

While reproducing this I found the success path leaks too: a FIFO response that completes normally at EOF also leaves the process unable to exit (body delivered, server.stop(true) returns, event loop never goes idle).

Cause

Two leaks in FileResponseStream, one per scenario:

  1. start() takes a ref for the in-flight read (hold_read_ref), released only by take_read_ref() in on_reader_done / on_reader_error / the backpressure arm of on_read_chunk. When the client aborts while the read is parked on a poll that will never fire (FIFO with no writer), none of those ever run, so the stream's refcount never reaches zero: Drop never runs, the fd leaks, and the registered FilePoll keeps the event loop referenced for the life of the process.

  2. start() clears the reader's CLOSE_HANDLE flag because auto_close owns the fd, and the posix PosixBufferedReader teardown (close_without_reporting, reached from its Drop) skips the handle entirely in that mode. The armed FilePoll, which holds the event loop's active ref, is never unregistered or freed even when the refcount does reach zero, which is why the clean-EOF case hangs as well.

Fix

  • finish() now releases the in-flight read ref if it is still held: once the stream is finished, no reader callback is coming to adopt it. This is a no-op on the reader-callback paths, which already took the ref, and every entry point into finish() holds its own guard ref, so the free still lands on that guard's drop.
  • Drop now unregisters and frees the FilePoll explicitly without closing the fd (which auto_close still owns). Unix only; on Windows the reader's own Drop already hands its libuv source back to the loop. This unregister-without-closing-the-fd teardown already existed hand-rolled in the shell IOReader/IOWriter (the other CLOSE_HANDLE-cleared owners), so it is now a named PollOrFd::release_poll_keep_fd() in bun_io and all five sites use it.

Windows follow-up

The first CI round (build 89813) segfaulted in serve.test.ts on Windows x64 with heap corruption:

panic(main thread): Segmentation fault at address 0xFFFFFFFFFFFFFFFF
mi_page_malloc_zero <- Vec::reserve <- WindowsBufferedReader::get_read_buffer_with_stable_memory_address <- FileResponseStream::start

Releasing the read ref means an aborted stream can now actually be freed while its uv_fs_read is still running on the libuv threadpool (before this PR it just leaked, so the window was unreachable). uv_cancel cannot stop an op that has already started, and the op's iov points into the reader's _buffer, so the read completed into freed memory and corrupted the allocator freelist; the next same-size allocation (another response's reader buffer) crashed. WindowsBufferedReader::close_impl now moves _buffer into the detached File box when an op is in flight, and the fs callback reclaims both together after the op completes.

Verification

Three new tests in test/js/bun/http/bun-serve-file.test.ts, each spawning a fixture process that must exit on its own:

  • abort via fetch() handler (posix): client reads the first body chunk (proving the server's read is parked on its poll), aborts, server.stop(true), process must exit 0. The fixture deliberately never closes its FIFO writer: an explicit close would deliver an EOF that releases the parked read through the reader-done path and mask a missing abort-time release, so the test isolates both halves of the fix.
  • abort via a static route (posix): same scenario through routes:, the FileRoute entry point, which had the identical hang.
  • EOF (Linux only): writer closes after the first chunk is read, response completes, server.stop(true), process must exit 0. Restricted to Linux because macOS kqueue does not reliably wake an armed FIFO read filter when the last writer closes (the workaround note in io/pipes.rs documents this), so the late EOF there arrives via idleTimeout instead of the poll.

All three hang (test timeout, dangling process killed) on bun 1.4.0 without the fix and pass with it. Full bun-serve-file.test.ts, bun-serve-static.test.ts, serve-file-slice-read-error.test.ts, and tls-bunfile-leak.test.ts suites pass on Linux, and the shell file-io.test.ts / shell-blocking-pipe.test.ts suites pass with the release_poll_keep_fd migration; on Windows, serve.test.ts and bun-serve-file.test.ts pass and a 1000-iteration abort-mid-file-response stress runs clean with the buffer hand-off fix. serve.test.ts on Linux has 4 pre-existing environment failures (IPv6, privileged ports) that fail identically on the released bun.

Related but distinct: #37082 fixes the wire framing of the late-EOF completion in this same function (end_without_body leaving a chunked body unterminated). This PR does not touch framing; the new EOF test tolerates either framing so the two do not depend on each other.


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/http/bun-serve-file.test.ts

FileResponseStream held its in-flight read ref until on_reader_done or
on_reader_error fired, but an abort while the read is parked on a poll
that will never fire (a FIFO with no writer) never reaches those, so the
stream leaked and the armed FilePoll kept the event loop referenced
forever. Release the ref in finish().

The stream also clears the reader's CLOSE_HANDLE flag because auto_close
owns the fd, which makes the posix reader's own teardown skip the
FilePoll; its event-loop active ref then leaked even when the response
completed at EOF. Unregister and free the poll in Drop without closing
the fd, the same way the shell IOReader does.
@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on Linux with the FIFO repro in the description (process hangs after server.stop(true)). Three regression tests (fetch-handler abort, static-route abort, EOF) all fail by timeout on bun 1.4.0 and pass with this diff. Review follow-ups so far: 3e82ce6 keeps the reader buffer alive across a detached in-flight uv_fs_read on Windows (CI-caught heap corruption), d4c7050 isolates the abort test / covers static routes / names the poll-release idiom, 305f243 defers the Windows fd close until a pending threadpool read completes (the fd half of the same race). Verified on Windows with a 1500-iteration abort-mid-read stress plus full serve.test.ts and bun-serve-file.test.ts. Waiting on CI.

@github-actions github-actions Bot added the claude label Aug 7, 2026
Comment thread src/runtime/server/FileResponseStream.rs Outdated
Comment thread src/runtime/server/FileResponseStream.rs Outdated
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 16 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: b9b14c7f-8a59-483a-987a-c8febd395a23

📥 Commits

Reviewing files that changed from the base of the PR and between d4c7050 and 305f243.

📒 Files selected for processing (3)
  • src/io/PipeReader.rs
  • src/io/pipes.rs
  • src/runtime/server/FileResponseStream.rs

Walkthrough

Changes

File reader cleanup now preserves buffers used by pending operations. File response teardown releases pending reader references and Unix poll handles. Shell I/O cleanup uses shared poll-release logic. FIFO subprocess tests cover parked-read abort and EOF cleanup.

FIFO response cleanup

Layer / File(s) Summary
File reader buffer lifetime
src/io/source.rs, src/io/PipeReader.rs
File retains an orphaned buffer for detached readers. Reader cleanup transfers or safely leaks sources before clearing its buffer.
Poll resource release
src/io/pipes.rs, src/runtime/shell/IOReader.rs, src/runtime/shell/IOWriter.rs
release_poll_keep_fd() releases poll resources while preserving the file descriptor. POSIX shell reader and writer cleanup uses this method.
FileResponseStream teardown
src/runtime/server/FileResponseStream.rs
finish() releases a pending reader reference. Drop releases Unix FilePoll handles before separate file-descriptor cleanup.
FIFO abort and EOF regression tests
test/js/bun/http/bun-serve-file.test.ts
Platform-specific subprocess tests verify clean termination after parked-read aborts and after FIFO input reaches EOF.

Possibly related PRs

  • oven-sh/bun#37082: Modifies FileResponseStream::finish() and adds FIFO regression tests for related cleanup paths.
  • oven-sh/bun#36858: Changes FileResponseStream reference and poll lifecycle handling during asynchronous file operations.

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 clearly identifies the primary fix: preventing FIFO file responses from keeping the Bun process alive.
Description check ✅ Passed The description explains the problem, causes, fixes, platform considerations, tests, and related scope with sufficient verification detail.

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

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

🤖 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 `@test/js/bun/http/bun-serve-file.test.ts`:
- Around line 1172-1173: The two independent subprocess tests currently run
sequentially; make both concurrent. In test/js/bun/http/bun-serve-file.test.ts
at lines 1172-1173, update the abort test to use
test.concurrent.skipIf(isWindows), and at line 1231 update the EOF test the same
way.
🪄 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: 753dc422-fd4e-41b9-8e94-e4023c64be4d

📥 Commits

Reviewing files that changed from the base of the PR and between 25d9d4a and 3efe492.

📒 Files selected for processing (2)
  • src/runtime/server/FileResponseStream.rs
  • test/js/bun/http/bun-serve-file.test.ts

Comment thread test/js/bun/http/bun-serve-file.test.ts Outdated

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

🤖 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 `@test/js/bun/http/bun-serve-file.test.ts`:
- Line 1289: Remove the explicit 15_000 per-test timeout from the
hang-regression test, leaving the test to use the existing test runner timeout.
🪄 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: 9333870b-2ae3-4518-87e7-82f6a7379e62

📥 Commits

Reviewing files that changed from the base of the PR and between 3efe492 and 9a7500d.

📒 Files selected for processing (1)
  • test/js/bun/http/bun-serve-file.test.ts

Comment thread test/js/bun/http/bun-serve-file.test.ts Outdated
CI hit a heap corruption segfault on Windows (build 89813, serve.test.ts):
freeing a FileResponseStream while its uv_fs_read was still running on the
threadpool let the op write into the freed reader buffer, since uv_cancel
cannot stop an op that already started. close_impl now moves the buffer
into the detached File box, which the fs callback reclaims after the op.

Also from review: trim two comments, run the new FIFO tests concurrently
without per-test timeouts, and restrict the EOF test to Linux, where
closing the last FIFO writer reliably wakes the armed read poll (macOS
kqueue does not deliver that event; the EOF there arrives via
idleTimeout).
Comment thread src/io/PipeReader.rs
Comment thread src/io/PipeReader.rs
Comment thread src/io/PipeReader.rs
Comment thread src/io/PipeReader.rs
Comment thread src/io/source.rs
Comment thread src/runtime/server/FileResponseStream.rs
Comment thread src/runtime/server/FileResponseStream.rs Outdated
… poll-release idiom

The abort fixture closed its FIFO writer before exiting, which delivered an
EOF that released the parked read through the reader-done path; the test
passed even without the finish() release. Keep the writer open so the test
fails if either half of the teardown is lost.

Add the same abort-while-parked test through a static route: FileRoute is
the other FileResponseStream entry point (and the only on_abort: None
caller), and it had the identical hang.

Replace the five hand-rolled matches!-plus-close_impl(None, None, false)
sites with PollOrFd::release_poll_keep_fd(), so the FilePoll teardown for
CLOSE_HANDLE-cleared owners lives in bun_io next to the invariant it
maintains.
Comment thread src/io/pipes.rs Outdated
Comment thread src/runtime/server/FileResponseStream.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: 1

🤖 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/io/pipes.rs`:
- Around line 131-140: Gate the public method PollOrFd::release_poll_keep_fd
with a non-Windows configuration so it is unavailable on Windows, where
close_impl cannot preserve the file descriptor. Keep its existing Poll-state
check and teardown behavior unchanged on supported targets.
🪄 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: c2c2db33-a33c-45b0-9e94-1d7ea162deaf

📥 Commits

Reviewing files that changed from the base of the PR and between 3e82ce6 and d4c7050.

📒 Files selected for processing (5)
  • src/io/pipes.rs
  • src/runtime/server/FileResponseStream.rs
  • src/runtime/shell/IOReader.rs
  • src/runtime/shell/IOWriter.rs
  • test/js/bun/http/bun-serve-file.test.ts

Comment thread src/io/pipes.rs
Comment thread src/io/pipes.rs
Comment thread src/runtime/server/FileResponseStream.rs
Comment thread test/js/bun/http/bun-serve-file.test.ts
…ight

FileResponseStream::Drop queued Closer::close (an async uv_fs_close on the
libuv threadpool) before the reader detach could even attempt uv_cancel, so
with a read already running on a worker the fd could be closed, and its CRT
slot recycled, out from under the in-flight ReadFile. This is the fd half
of the hazard whose buffer half 3e82ce6 fixed.

When an op is pending, Drop now sets close_after_operation on the File
source instead of closing the fd itself; complete() already honors that
flag after the op's callback, and on_close_complete reclaims the box.
Comment thread src/io/PipeReader.rs
Comment thread src/runtime/server/FileResponseStream.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.

The two issues I raised earlier are addressed — 305f243 defers the Windows fd close until the in-flight uv_fs_read completes (close_fd_after_pending_opcomplete() honors close_after_operationon_close_complete reclaims the box + orphaned_buffer), and the abort fixtures now deliberately never close writerFd so the test pins the finish() clause on its own. I have nothing further; deferring to a human for the refcount / cross-thread fd-lifetime changes.

What was reviewed:

  • Traced the Windows abort Drop ordering: close_fd_after_pending_op runs before the field-drop's detach_borrowed_fd, both on the loop thread, so close_after_operation is set before the box is handed off and on_file_read's null-parent branch takes the state == Closing arm.
  • release_poll_keep_fd at all five call sites is a mechanical extraction of the pre-existing if Poll { close_impl(None, None, false) } idiom; the #[cfg(not(windows))] gate keeps the contract honest.
  • deinit() reordering: _buffer = Vec::new() now runs after close_impl, so the in-flight-op branch can move the live allocation into orphaned_buffer first.
Extended reasoning...

Overview

Fixes two process-keepalive leaks in FileResponseStream when serving a FIFO body: (1) finish() now releases the in-flight read ref so an aborted stream whose read is parked on a poll that will never fire can reach Drop, and (2) Drop explicitly frees the FilePoll on Unix (start() cleared CLOSE_HANDLE, so the reader's own teardown skips it). The pre-existing hand-rolled poll-release in shell IOReader/IOWriter is factored into PollOrFd::release_poll_keep_fd(). On Windows, making Drop reachable mid-uv_fs_read opened a buffer-UAF and an fd-close race; WindowsBufferedReader::close_impl now moves _buffer into the detached File box, and FileResponseStream::Drop defers the fd close to the box via close_fd_after_pending_op() when an op is in flight. Three new subprocess fixture tests cover abort (fetch handler + static route) and clean-EOF exit.

Security risks

None. No parsing of untrusted input, no auth/crypto, no path handling. The changes are lifetime/refcount bookkeeping on already-open server-owned fds.

Level of scrutiny

High — this is squarely in REVIEW.md's "memory safety (the most-blocked category)": intrusive-refcount balance across every terminal path, a new reachable Drop while a threadpool op is running, and fd ownership hand-off between the parent and a detached libuv box. The Windows path in particular is a real cross-thread hazard (uv_cancel cannot stop a running op; Closer::close is an async uv_fs_close on the same pool). A human maintainer should sign off on the ownership story.

Other factors

I raised two findings on an earlier revision (the Windows fd-close race and the abort fixture's closeSync(writerFd) masking the finish() clause); both are resolved in the current diff and the threads are marked resolved. The release_poll_keep_fd extraction in IOReader/IOWriter is byte-for-byte equivalent to what was there. Tests follow harness conventions (tempDir, await using proc, concurrent pipe-drain, test.concurrent, no per-test timeouts, hang-guard asserts exitCode === 0). No outstanding human review comments.

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.

1 participant