Skip to content

Bun.serve: terminate FIFO/pipe file responses at EOF - #37082

Open
robobun wants to merge 5 commits into
mainfrom
farm/0d5eeba7/fifo-response-eof-framing
Open

Bun.serve: terminate FIFO/pipe file responses at EOF#37082
robobun wants to merge 5 commits into
mainfrom
farm/0d5eeba7/fifo-response-eof-framing

Conversation

@robobun

@robobun robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Repro

// mkfifo /tmp/p
const server = Bun.serve({
  port: 0,
  fetch() {
    setTimeout(() => {
      const w = require("node:fs").createWriteStream("/tmp/p");
      w.end("hello"); // write + close together, the common producer pattern
    }, 20);
    return new Response(Bun.file("/tmp/p"));
  },
});

With the pipe's data and EOF arriving as separate poll events (which the write+close pattern produces), the response head says Transfer-Encoding: chunked but the body is never terminated. The wire ends:

5\r\nhello\r\n\r\n

a bare CRLF where the 0\r\n\r\n last-chunk belongs, and the keep-alive connection stays open. curl fails with (56) chunk hex-length char not a hex digit: 0xd, node http with HPE_INVALID_CHUNK_SIZE, and fetch hangs or errors. A pipe whose writer closes without writing anything is worse: the head carries neither Content-Length nor Transfer-Encoding (nor Date) and the response never completes, so clients hang.

Cause

Since the pipe bodies stopped being mis-framed as Content-Length: 0 (#36243), a FIFO body streams through FileResponseStream with no Content-Length, entering uWS chunked mode on the first body write. When the pipe's EOF arrives as its own poll event with no pending bytes, the reader reports it through on_reader_done rather than an EOF-flagged data chunk, and FileResponseStream::finish() ended the response with end_without_body().

uws_res_end_without_body is a C shim that writes a bare \r\n (the header-terminating blank line) and marks the response done. It has no awareness of chunked mode: in a chunk-framed body that stray CRLF is the garbage curl chokes on and the terminating 0-chunk is never written, and for a body with no writes at all it terminates the headers with no framing declared, leaving the client waiting forever on a connection the server keeps open.

Fix

finish() now ends the response with end() and empty data, which completes whichever framing the response is in: the terminating 0\r\n\r\n chunk when body writes entered chunked mode, and Content-Length: 0 when nothing was written. The data+EOF-coalesced path (on_read_chunk with EOF state) already did this with the final chunk and is unchanged, as are the abort/error paths.

Verification

New tests in test/js/bun/http/bun-serve-file.test.ts drive both shapes over a raw socket, closing the FIFO's write end only after the payload bytes came out the other side so EOF deterministically arrives as its own event, and assert the wire forms a complete HTTP/1.1 message (chunked with terminator, or satisfied Content-Length).

$ USE_SYSTEM_BUN=1 bun test test/js/bun/http/bun-serve-file.test.ts -t "ends the response when the pipe writer closes"
(fail) Response(Bun.file(FIFO)) ends the response when the pipe writer closes > write then close
(fail) Response(Bun.file(FIFO)) ends the response when the pipe writer closes > close with no writes

$ bun bd test test/js/bun/http/bun-serve-file.test.ts -t "ends the response when the pipe writer closes"
(pass) Response(Bun.file(FIFO)) ends the response when the pipe writer closes > write then close
(pass) Response(Bun.file(FIFO)) ends the response when the pipe writer closes > close with no writes

The full bun-serve-file.test.ts suite passes (107 tests), curl now exits 0 against a FIFO response where it previously failed with error 56, and an empty pipe yields HTTP/1.1 200 OK with Content-Length: 0. Regular-file responses, type: "direct" streams, and HEAD requests are unaffected (they never reach this path).


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

When a pipe's writer closes after its last write was already consumed,
EOF reaches FileResponseStream through on_reader_done with no pending
bytes, and finish() ended the response with end_without_body(), which
writes a bare CRLF and marks the response done without completing the
body framing. A chunk-framed body never got its terminating 0-chunk, so
the wire ended "5\r\nhello\r\n\r\n" and clients fail parsing (curl:
"chunk hex-length char not a hex digit: 0xd", node: HPE_INVALID_CHUNK_SIZE),
and a pipe that closed without writing produced a head with neither
Content-Length nor Transfer-Encoding that never completes.

End the response with end() instead: it writes the terminating chunk
when in chunked mode and Content-Length: 0 when nothing was written.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

FileResponseStream::finish() now ends empty streams with explicit empty-body framing. FIFO tests cover writers that close after writing data and writers that close without writing.

Changes

FIFO response completion

Layer / File(s) Summary
Complete empty response streams
src/runtime/server/FileResponseStream.rs
finish() calls resp.end(b"", ...) when no trailing data exists.
Validate FIFO EOF responses
test/js/bun/http/bun-serve-file.test.ts
Adds asynchronous FIFO writer setup and tests for payload-producing and empty writers. Tests decode chunked or content-length responses and verify completion, status, and exact payload delivery.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#36858: Both changes modify FileResponseStream EOF handling and related regression tests.

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 and concisely describes the primary change: terminating FIFO and pipe file responses at EOF.
Description check ✅ Passed The description explains the problem, cause, fix, and verification results, including targeted and full test coverage.

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: 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/server/FileResponseStream.rs`:
- Around line 544-552: Shorten the comment above the EOF handling to state only
the durable invariant: call end with empty data so the response correctly
completes body framing and emits the appropriate zero-length response headers.
Remove the historical explanation of end_without_body and its failure symptoms.

In `@test/js/bun/http/bun-serve-file.test.ts`:
- Around line 1225-1227: Remove the fixed 3000 ms setTimeout deadline around
resolveWire in the wireDone response flow, and await the observable completion
event instead so the test runner supplies the hang timeout. Apply the same
change to the related timer at the additionally referenced lines, preserving the
existing wire collection and assertions.
🪄 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: 4eb00569-54ec-4f6a-9e66-fac8cc47c02a

📥 Commits

Reviewing files that changed from the base of the PR and between 5a5f1b8 and e14bdcc.

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

Comment thread src/runtime/server/FileResponseStream.rs Outdated
Comment thread test/js/bun/http/bun-serve-file.test.ts Outdated
Comment thread src/runtime/server/FileResponseStream.rs Outdated
Comment thread src/runtime/server/FileResponseStream.rs Outdated
Comment thread src/runtime/server/FileResponseStream.rs
Comment thread test/js/bun/http/bun-serve-file.test.ts Outdated
Comment thread test/js/bun/http/bun-serve-file.test.ts Outdated
@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:13 PM PT - Aug 6th, 2026

@robobun, your commit d789bfc has 1 failures in Build #89810 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37082

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

bun-37082 --bun

The tests relied on the write-end open blocking until the server opened
the FIFO's read end. That rendezvous raced: the writer dance could run
before the request was served (EPIPE on write, or a server that waits
forever for a writer that already left), and on macOS a FIFO kevent
registered while no writer exists never fires, so the darwin lanes
never streamed at all.

Hold the FIFO open read+write before the request, hand over to the real
writer only after the response head reaches the client (proving the
read end is open and polled), and resolve the ordering promises on
close/error so a broken build fails with the captured wire instead of
hanging to the runner timeout. idleTimeout bounds that capture.

@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 one-line end_without_bodyend(b"", …) swap is the correct completion for the reader-EOF path, and the earlier test race/cleanup concerns from this review were addressed in d789bfc.

What was reviewed:

  • Traced finish()'s !RESPONSE_DONE branch to its only live entry (on_reader_done with no trailing bytes); the abort/error/sendfile/deferred-EOF paths all set RESPONSE_DONE first, so they are unaffected.
  • Confirmed against uWS internalEnd (HttpResponse.h) that end("") emits the 0-chunk when HTTP_WRITE_CALLED and Content-Length: 0 otherwise, whereas endWithoutBody(nullopt) writes only the bare CRLF — matches the PR's cause analysis.
  • Re-checked the d789bfc test rewrite: keeper r+ fd is held until the real writer is established, headSeen/payloadSeen/wireDone all resolve on close/error, writer and keeper fds are closed in finally, and idleTimeout: 5 bounds a broken build instead of a fixed JS deadline.
Extended reasoning...

Overview

The runtime change is a single line in FileResponseStream::finish(): when the buffered reader reports EOF with no trailing data (the pipe/FIFO case where data and EOF arrive as separate poll events), the response now completes via resp.end(b"", …) instead of resp.end_without_body(…). The test change adds a two-variant describe block to bun-serve-file.test.ts that drives a FIFO body over a raw socket and asserts the wire forms a complete HTTP/1.1 message.

Correctness of the runtime change

I traced every caller of finish(). on_aborted, fail_with, end_sendfile, and the deferred FileResponseStreamEof task (via on_read_chunk's inline EOF branch) all set RESPONSE_DONE before reaching finish(), so the changed branch is unreachable from them. It fires only from on_reader_done() (and the theoretically-reachable on_writablereader.is_done() path) when the reader closed with no bytes left. I then read packages/bun-uws/src/HttpResponse.h::internalEnd: with HTTP_WRITE_CALLED set it emits the terminating 0-chunk; without it and with allowContentLength=true it emits Content-Length: 0. endWithoutBody(nullopt) passes allowContentLength=false and falls through to a bare \r\n at line 224 — exactly the malformed wire the PR reproduces. Regular-file responses reach EOF via on_read_chunk's state == Eof arm (which already calls resp.end(chunk, …)), so they never enter this branch. The swap is strictly more correct with no behavior change on other paths.

Security risks

None. No user input parsing, no allocation, no lifetime changes. The refcount / ScopedRef structure around finish() is untouched. end() and end_without_body() have identical ownership semantics on the uWS side (both mark HTTP_END_CALLED and hand the socket back).

Level of scrutiny

Moderate — this is a hot response-completion path in Bun.serve, but the diff is a one-token semantic swap between two adjacent uWS calls whose difference I verified in the C++ source. The test file additions are larger (~140 lines) but purely additive and confined to a Windows-skipped describe block.

Other factors

This PR has already been through three feedback rounds. CodeRabbit's comment-length and fixed-deadline nits were applied; comment-cop's paragraph-comment flag was resolved down to two lines; and my own two prior inline findings (the keeper-fd race that hung one Ubuntu lane and never streamed on Darwin, and the unwired payloadSeen/writer-fd cleanup) were both fixed in d789bfc — the author confirmed the earlier shape actually failed in CI, which validates the concern was real. The current test uses the same keeper-fd pattern as the neighboring FIFO test in this file, wires every failure event to resolve all three promises, releases both fds in finally, and relies on idleTimeout + runner timeout instead of an in-test deadline. The full bun-serve-file.test.ts suite (107 tests) passes per the PR body. Nothing outstanding.

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