Skip to content

Bun.serve: stream the body for a 307/308 Bun.file() route instead of hanging keep-alive - #35024

Open
robobun wants to merge 3 commits into
mainfrom
farm/1da0a9aa/fix-end-without-body-framing
Open

Bun.serve: stream the body for a 307/308 Bun.file() route instead of hanging keep-alive#35024
robobun wants to merge 3 commits into
mainfrom
farm/1da0a9aa/fix-end-without-body-framing

Conversation

@robobun

@robobun robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Repro

Bun.serve({
  port: 0,
  routes: {
    "/r": new Response(Bun.file("./f.txt"), { status: 307, headers: { Location: "/" } }),
  },
});
await fetch("http://localhost:PORT/r", { redirect: "manual" }); // hangs until client timeout

Raw wire before:

HTTP/1.1 307 Temporary Redirect
Content-Type: text/plain;charset=utf-8
Location: /
last-modified: ...

No Content-Length, no Transfer-Encoding, no Connection: close, socket left open. RFC 9112 6.3: a response with none of those is delimited by the server closing the connection, so a keep-alive client blocks. Same for status: 205 and status: 308.

Cause

FileRoute::on ended a null-body or 307/308 status before starting the file stream:

if HTTPStatusText::is_null_body(status_code) || matches!(status_code, 307 | 308) {
    resp.end_without_body(resp.should_close_connection());
    return;
}

uws_res_end_without_body writes only the header terminator and markDone(); it never writes Content-Length. Of the statuses this arm handles, 1xx/204/304 self-terminate under RFC 9112 6.3, but 205/307/308 do not.

The 307/308 body drop is also a FileRoute-only behavior. The identical new Response(body, { status: 307 }) through the other three paths ships the body:

path before after
routes: + Bun.file() hangs CL=N, body sent
routes: + in-memory CL=N, body sent unchanged
fetch: + Bun.file() CL=N, body sent unchanged
fetch: + in-memory CL=N, body sent unchanged

RFC 9110 15.4 permits a body on a redirect response and browsers render it. The 307 | 308 clause arrived with the original FileRoute PR without a stated reason.

Fix

Remove || matches!(status_code, 307 | 308) so 307/308 fall through to the normal sendfile/write() path and get the real Content-Length plus body, same as every other serve path.

205 remains in the bodiless arm (it is a WHATWG null-body status and RFC 9110 15.3.6 forbids generating content on a 205) and now writes Content-Length: 0 since it is the one null-body status RFC 9112 6.3 does not self-terminate. 1xx/204/304 stay header-only.

Verification

New raw-socket test in bun-serve-file.test.ts asserts 307/308 carry the file's Content-Length and body (and that the routes: path matches the fetch: path), 205 carries Content-Length: 0, 204/304 stay header-only, two pipelined 307s both resolve on a keep-alive connection, and fetch(redirect:'manual') completes. All 84 existing tests in the file still pass.

Other end_without_body sites audited

Follow-up to #35008's audit of the same class. Of the remaining sites that PR listed as out of scope:

site finding
FileRoute.rs:510 (205/307/308) fixed here
FileResponseStream.rs:500 owned by #34257 (same end_without_body to end(b"") change, open)
RequestContext.rs:1120, FileRoute.rs:555 HEAD, self-terminating (RFC 9112 6.3): not a bug
RequestContext.rs:3411 (render_production_error 404) unreachable: only caller passes status=500
NodeHTTPResponse.rs:1589 (res.destroy()) verified: socket closes
NodeHTTPResponse.rs:1494 (res.emit('close') without end()) Node also hangs; matches
server_body.rs:420 (respond_stopped_503) guarded by js_value_for_dispatch; callers are inside uWS handlers where post-uncork closes

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

…alive

A Bun.file() route configured with a status the route drops the body for
(205, 307, 308) wrote only status + headers via end_without_body: no
Content-Length, no Connection: close. HTTP/1.1 keep-alive clients block
waiting for body framing (RFC 9112 6.3 only self-terminates 1xx/204/304).

Write Content-Length: 0 for the three non-self-terminating statuses;
1xx/204/304 stay header-only.
@coderabbitai

coderabbitai Bot commented Jul 22, 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: 13 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: 350d0627-973f-4115-b276-266b00a37db3

📥 Commits

Reviewing files that changed from the base of the PR and between 85ddc95 and 54cb9ba.

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

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

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:54 PM PT - Jul 21st, 2026

@robobun, your commit 54cb9ba has 2 failures in Build #77346 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35024

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

bun-35024 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun.file routes 404 #22174 - Bun.file routes become unresponsive under keep-alive stress testing, matching the symptom of missing Content-Length: 0 causing HTTP/1.1 clients to stall on the FileRoute code path

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

Fixes #22174

🤖 Generated with Claude Code

Comment thread test/js/bun/http/bun-serve-file.test.ts
Self-review pointed out that only FileRoute dropped the body for 307/308;
StaticRoute and both fetch-handler paths (file and in-memory) ship it.
RFC 9110 15.4 permits a redirect body, and adding Content-Length: 0 on
top of a FileRoute-only drop would have cemented the divergence.

Instead, remove the 307|308 clause so 307/308 fall through to the
normal sendfile/write() path (real Content-Length, real body), matching
the other three paths. 205 stays bodiless (RFC 9110 15.3.6 forbids
content on a 205) and gets Content-Length: 0.
@robobun robobun changed the title Bun.serve: frame bodiless file-route responses (205/307/308) on HTTP/1.1 keep-alive Bun.serve: stream the body for a 307/308 Bun.file() route instead of hanging keep-alive Jul 22, 2026

@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 earlier inline comment about wiring close/error on the raw-socket helpers is addressed in f3644fb — both now resolve(buf) on close and reject on error. Beyond the description-staleness nit, I also confirmed is_null_body covers 205 (so that branch is reachable), the has_written_content_length_header() guard prevents a duplicate header when the user set one, and the updated fd_guard comment is accurate now that 307/308 reach FileResponseStream::start.

Extended reasoning...

Previous review feedback was addressed; the only new finding is a nit on the stale PR title/description. The runtime change itself (307/308 now stream the file body rather than ending bodiless) is a user-visible behavioral change to Bun.serve — consistent with StaticRoute and the fetch-handler path per the code comment, but worth a maintainer's glance, so not auto-approving.

Comment thread src/runtime/server/FileRoute.rs
Pipelining a Bun.file() route on Windows closes the connection with zero
bytes, independent of status (reproduces on main with status 200). The
framing and fetch assertions already cover the fix on Windows.

@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 — small, well-reasoned framing fix that aligns FileRoute's 307/308 handling with the three sibling paths that already stream the body.

What was reviewed:

  • Traced 307/308 fall-through: status_code != 200 skips Range, hits the existing content-length + FileResponseStream path; fd_guard ownership transfer is unchanged.
  • Checked is_null_body set (101/103/204/205/304) against RFC 9112 §6.3 — 205 is indeed the only one not self-terminating; 204/304 correctly stay header-only.
  • Verified the !has_written_content_length_header() guard prevents a double CL when the user sets their own (via write_headersmark_wrote_content_length_header).
  • Test now wires close→resolve and error→reject per earlier feedback; Windows pipelining skip is documented as a pre-existing bug.
Extended reasoning...

Overview

Two files: src/runtime/server/FileRoute.rs (~10 net lines) removes || matches!(status_code, 307 | 308) from the null-body early-return so 307/308 fall through to the normal FileResponseStream path, and adds a Content-Length: 0 write for status 205 before end_without_body. test/js/bun/http/bun-serve-file.test.ts gains one raw-socket test covering framing for 204/205/304/307/308, a pipelined-307 keep-alive check, and a fetch(redirect:'manual') completion assertion.

Security risks

None identified. The change makes HTTP/1.1 message framing more correct (adds Content-Length where it was missing, causing keep-alive hangs). No auth, crypto, permissions, or untrusted-input parsing is touched. 307/308 now send file bytes on the wire, but that matches what routes: + in-memory body, fetch: + Bun.file(), and fetch: + in-memory already do — so no new exposure surface.

Level of scrutiny

Medium — Bun.serve response framing is production-critical, but the runtime change is tiny and the design decision ("should a 307/308 file route send its body?") is not novel: the PR aligns the one outlier with three existing sibling paths and RFC 9110 §15.4. The 205 Content-Length: 0 is a mechanical RFC-compliance fix guarded against double-write. I traced the fall-through path (Range skipped because status_code != 200, fd_guard cleared before stream ownership transfer) and it reuses the existing 200 path unchanged.

Other factors

Two prior review rounds already landed fixes: raw-socket helpers now wire close/error correctly, and the PR title/description were re-synced after the approach changed mid-review. The PR description includes an audit of every remaining end_without_body call site. The Windows pipelining skip is documented as reproducing on main with status 200, so it's pre-existing and orthogonal. CI build #77346 is running on the head commit.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Build #77346: bun-serve-file.test.ts passes on all lanes. The one red (worker_threads.test.ts SIGABRT on debian x64-asan, JSC ExceptionScope assertion) is unrelated to this diff and reported for main-break triage; the rest are flaky retries. Diff is ready for review.

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