Skip to content

Bun.serve: serve full content for Bun.file() bodies whose stat size is 0 (procfs) - #34242

Open
robobun wants to merge 5 commits into
mainfrom
farm/86fe9226/serve-procfs-empty-body
Open

Bun.serve: serve full content for Bun.file() bodies whose stat size is 0 (procfs)#34242
robobun wants to merge 5 commits into
mainfrom
farm/86fe9226/serve-procfs-empty-body

Conversation

@robobun

@robobun robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Repro

const P = "/proc/self/status";
using srv = Bun.serve({ port: 0, fetch: () => new Response(Bun.file(P)) });
const r = await fetch(srv.url);
console.log(r.status, r.headers.get("content-length"), (await r.text()).length);
// before: 200 "0" 0
// after:  200 "1534" 1534  (or chunked when the file spans multiple reads)

Bun.file(P).text() and new Response(Bun.file(P).stream()) both return the full ~1.5 KB; only the Response(Bun.file) path served empty. procfs, sysfs and cgroupfs regular files report st_size == 0 by design but yield content on read(). Node's http + createReadStream serves the content.

Cause

RequestContext::do_sendfile stats the fd, sets the blob size and sendfile.remain to stat_size, writes Content-Length: <stat_size>, and for is_regular && remain == 0 ends immediately with an empty body. For a procfs file that is Content-Length: 0 and zero reads. The blob is fresh (.size never touched); this is the serve path doing its own stat, not the known cached-size issue.

Fix

When a regular file stats as 0 bytes and the blob is the unsliced whole file (offset == 0 && size == MAX_SIZE), skip the stat-based framing: no upfront Content-Length, no remain clamp, no Range resolution, and hand length: None to FileResponseStream so it reads to EOF via the BufferedReader path. The same condition was added to FileRoute (static routes: with a Bun.file body) which had the identical clamp.

FileResponseStream::on_reader_done now terminates the response with resp.end(b"", ...) when BufferedReader signals done without ever having delivered a chunk. Previously that fell through to end_without_body(), which with headers already written and no Content-Length leaves the client hanging. This keeps a genuinely empty 0-byte file on a real filesystem serving correctly (one read() hits EOF, uWS writes Content-Length: 0).

Verification

$ USE_SYSTEM_BUN=1 bun test test/js/bun/http/serve.test.ts -t "stat size is 0"
(fail) ... { hasName: false, nonEmpty: false }

$ bun bd test test/js/bun/http/serve.test.ts -t "stat size is 0"
(pass) serves the full content of a Bun.file() whose stat size is 0 (procfs)

The test is Linux-only (procfs). It covers both the fetch-handler path and the static-route path, and asserts a real 0-byte file still serves as empty.


no test proof · iteration 3 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/http/serve.test.ts

new Response(Bun.file("/proc/self/status")) served 200 Content-Length: 0
with an empty body, while Bun.file(p).text() and the .stream() route both
returned the ~1.5 KB content. procfs/sysfs regular files report st_size == 0
by design but are readable; do_sendfile trusted the stat, clamped remain to
0, wrote Content-Length: 0, and ended with no body.

For an unsliced Bun.file() body whose stat size is 0, fall back to a
read-to-EOF BufferedReader path (no upfront Content-Length, length: None)
so the body is actually read. FileRoute (static routes) had the same
clamping and gets the same fallback. FileResponseStream.on_reader_done now
terminates the response with resp.end() when BufferedReader reports EOF
without ever delivering a chunk, so a genuinely empty file still serves
correctly.
@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:31 PM PT - Jul 15th, 2026

@robobun, your commit 91e1e08 has 1 failures in Build #73314 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34242

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

bun-34242 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Bun serving Content-length: 0 with nginx and hono #23975 - Reports Content-Length: 0 empty responses when serving static files via Bun.serve on Linux, matching the stat-size-0 root cause this PR fixes
  2. Bun.file as response behaving weirdly #6961 - new Response(Bun.file(...)) causes the server to hang at "pending" for certain files; consistent with the FileResponseStream::on_reader_done fix for improper response termination

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

Fixes #23975
Fixes #6961

🤖 Generated with Claude Code

@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Neither suggested issue is a match:

  • Bun serving Content-length: 0 with nginx and hono #23975 serves real on-disk files built by vite; those stat with their actual size, so the stat_size == 0 fallback here never fires. That regression between 1.2.23 and 1.3 is a different path.
  • Bun.file as response behaving weirdly #6961 is macOS / Bun 1.0.9 and the file content does reach the browser; the hang is on the next request over keep-alive. The on_reader_done change in this PR only affects the new read-to-EOF path it introduces (previously a 0-byte regular file ended before FileResponseStream started).

Leaving the PR without Fixes links.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Bun.file: stop a file's stat size from capping reads of the whole file #33360 - Both fix the same root cause: procfs/sysfs/cgroupfs files reporting st_size == 0 causing empty content. Bun.file: stop a file's stat size from capping reads of the whole file #33360 fixes the Bun.file() read paths while this PR fixes the Bun.serve() serving paths.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Zero-stat-size regular files such as procfs files now stream to EOF without inferred range or content-length framing. Empty-chunk EOF responses complete directly, and Linux regression coverage verifies procfs and genuine zero-byte file behavior.

Changes

Zero-size file streaming

Layer / File(s) Summary
Detect stream-to-EOF file routes
src/runtime/server/FileRoute.rs
Routes identify zero-stat-size regular files that must stream to EOF and omit fixed range and content-length handling.
Propagate stream-to-EOF sendfile state
src/runtime/server/RequestContext.rs
Sendfile setup avoids fixed length, content ranges, range resolution, and premature termination for streaming-to-EOF files.
Complete empty streams and validate Linux behavior
src/runtime/server/FileResponseStream.rs, test/js/bun/http/serve.test.ts
Empty-chunk EOF completes responses directly, with Linux tests covering procfs files and genuine empty files.

Suggested reviewers: jarred-sumner, alii

🚥 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 is concise, specific, and accurately summarizes the main change to Bun.serve for 0-sized procfs Bun.file bodies.
Description check ✅ Passed The description covers the problem, root cause, fix, and verification, which satisfies the repository template overall.

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

@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #33360. Same filesystem quirk, disjoint code paths:

The repro here uses a fresh Bun.file() whose size is never resolved in JS; do_sendfile does its own fstat and trusts it. #33360 does not touch that path, and this PR does not touch #33360's files.

@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/server/FileRoute.rs`:
- Around line 438-444: Update the stream_to_eof condition in FileRoute’s
file-serving logic to require S::ISREG(mode) in addition to the existing
FileType::File, zero-size, unsliced checks. Preserve the current behavior for
actual regular files while preventing zero-size block devices and other
non-regular files from being read unboundedly.

In `@src/runtime/server/RequestContext.rs`:
- Around line 1825-1836: The zero-stat regular-file classification currently
only affects GET/sendfile handling, while HEAD still derives and advertises a
zero content length. Reuse the stream_to_eof classification in
do_render_head_response when resolving the Blob size so unsliced procfs/sysfs
files omit the derived Content-Length consistently, and add a HEAD regression
assertion for this case.

In `@test/js/bun/http/serve.test.ts`:
- Around line 2028-2049: Update the response-header assertions in both the
fetch-handler path and static-route path to require that
res.headers.get("content-length") is absent rather than merely nonzero. Keep the
existing status and body-content assertions unchanged.
🪄 Autofix (Beta)

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: 5a6ec4e2-371e-45e1-9b4e-86591467cd96

📥 Commits

Reviewing files that changed from the base of the PR and between be77b65 and 8795f04.

📒 Files selected for processing (4)
  • src/runtime/server/FileResponseStream.rs
  • src/runtime/server/FileRoute.rs
  • src/runtime/server/RequestContext.rs
  • test/js/bun/http/serve.test.ts

Comment thread src/runtime/server/FileRoute.rs
Comment thread src/runtime/server/RequestContext.rs
Comment thread test/js/bun/http/serve.test.ts Outdated
robobun added 2 commits July 15, 2026 17:24
…tat files

FileRoute's stream_to_eof now requires S::ISREG(mode) rather than the
FileType::File fallback bucket, so a zero-size block device is not read
unboundedly.

do_render_head_response omits Content-Length for an unsliced file blob that
resolves to size 0, matching the GET path which now streams to EOF without a
stat-derived length.

Tightened the test's Content-Length assertion to null-or-exact-body-length,
and added a HEAD assertion.
HEAD cannot distinguish a genuinely empty regular file from a procfs file
without reading, and bun-serve-file.test.ts locks down Content-Length: 0 for
HEAD on empty files. Revert the do_render_head_response change and exclude
HEAD from FileRoute's stream_to_eof so HEAD keeps its existing framing; only
GET takes the read-to-EOF fallback.

@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/serve.test.ts`:
- Around line 2003-2063: Add a HEAD request case to the existing procfs serving
test, preferably for the static `/route` path, and assert it returns status 200,
an empty response body, and `Content-Length: 0`. Keep the existing GET and real
empty-file assertions unchanged.
🪄 Autofix (Beta)

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: c8ef054f-962c-4de3-a7b1-beb696623d4e

📥 Commits

Reviewing files that changed from the base of the PR and between 8795f04 and c192eee.

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

Comment thread test/js/bun/http/serve.test.ts

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/runtime/server/RequestContext.rs:2646-2652 — Minor: this guard can't distinguish a procfs file (stat=0, has content) from a genuinely empty regular file (stat=0, no content), so HEAD on a real empty file now omits Content-Length entirely, whereas GET on the same file returns Content-Length: 0 (via resp.end(b"") in on_reader_done). The same divergence exists in FileRoute via the !stream_to_eof gate at FileRoute.rs:563. This is RFC 9110 §9.3.2-compliant and unavoidable without reading on HEAD, so it's a reasonable tradeoff — but the test's /empty case only exercises GET; consider adding a HEAD /empty assertion to lock down the intended behavior.

    Extended reasoning...

    What changed and how it manifests

    Before this PR, do_render_head_response on a genuinely empty regular file called blob.resolve_size()blob_size == 0 → fell into the else-branch and wrote content-length: 0. After this PR, was_unsliced_file is true for a fresh Bun.file(emptyPath) (needs_to_read_file && offset == 0 && size == MAX_SIZE), resolve_size() sets blob_size = 0, and the new guard !(was_unsliced_file && blob_size == 0) skips the header write entirely. end_without_body() then writes only the terminating \r\n, so the HEAD response has no Content-Length at all.

    The same holds in FileRoute::on: for an unsliced empty regular file, stream_to_eof is true, so the !stream_to_eof gate at line 563 skips the content-length write, and the method == HEAD branch calls end_without_body with no CL header.

    GET vs HEAD divergence

    GET on the same empty file takes the stream-to-EOF path: length: None is passed to FileResponseStream, the first read() returns 0 bytes, BufferedReader skips on_read_chunk for empty chunks and calls on_reader_done directly, which (per this PR's other change at FileResponseStream.rs:310-317) now calls resp.end(b"", ...). uWS's end() writes Content-Length: 0 for an empty body. So GET returns Content-Length: 0 but HEAD returns no header — a divergence that didn't exist before this PR (both used to return Content-Length: 0).

    Step-by-step proof

    1. User serves new Response(Bun.file("/tmp/empty")) where /tmp/empty is a 0-byte regular file.
    2. HEAD requestdo_render_head_response:
      • was_unsliced_file = true (it's a file blob, offset=0, size=MAX_SIZE)
      • resolve_size() stats the file → blob_size = 0
      • !(true && 0 == 0) = false → skip write_header_int
      • end_without_body → response has no Content-Length
    3. GET requestdo_sendfile:
      • stream_to_eof = true (is_regular, stat_size=0, offset=0, original_size=MAX_SIZE)
      • length: None passed to FileResponseStream::start
      • First read() hits EOF with 0 bytes → on_reader_done fires without RESPONSE_DONE set
      • resp.end(b"", ...) → uWS writes Content-Length: 0
    4. Result: GET has Content-Length: 0, HEAD has none.

    Why this isn't a merge blocker

    The refutation is correct that this is intentional and documented — the code comment at line 2643-2645 cites RFC 9110 §9.3.2, which explicitly permits a server to omit on HEAD any header field "for which a value is determined only while generating the content." At HEAD time the server genuinely cannot know whether a 0-stat file is empty or procfs without reading it, and reading defeats HEAD's purpose. The alternatives are (a) write CL: 0, which lies for procfs — the very bug this PR fixes; or (b) do a filesystem-type check (statfs, path prefix), which is fragile. Omitting is the only choice that's correct for both cases, and it's spec-sanctioned. The pre-PR behavior was actively wrong for procfs (advertised CL: 0 for a 1.5KB file); trading a technically-correct-but-uninformative CL: 0 on empty files for correctness on procfs is the right call.

    Practical impact is minimal: genuinely empty files served over HTTP are rare, and clients that use HEAD to probe size must already tolerate absent Content-Length (chunked/dynamic responses are common). RFC 9110 §8.6 says CL is optional on HEAD.

    What's actionable

    The test's /empty case at serve.test.ts:2069-2073 only exercises GET. Since the HEAD behavior on empty files is now different from before (and different from GET), it's worth locking down with an explicit assertion — e.g., expect((await fetch(new URL("/empty", server.url), { method: "HEAD" })).headers.get("content-length")).toBeNull() — so a future change doesn't accidentally reintroduce CL: 0 (which would re-break procfs HEAD) or start reading the file on HEAD. That's a test-coverage nit, not a code fix.

@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

The finding above reviewed 5c9bf80, which was superseded by c192eee before the review posted (the header notes "PR may have been updated during review"):

  • do_render_head_response no longer has the was_unsliced_file guard; it is back to always writing content-length from the resolved blob size. HEAD on a real 0-byte file returns Content-Length: 0 as before.
  • FileRoute's stream_to_eof now includes method != Method::HEAD, so HEAD there also writes Content-Length: 0 as before.

So there is no GET/HEAD divergence for genuinely empty files at c192eee. The HEAD /empty contract is already locked down by bun-serve-file.test.ts lines 629-656, which is what caught the regression in 5c9bf80 in the first place.

@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

The diff is green on the tests it touches (serve.test.ts, bun-serve-file.test.ts, bun-serve-static.test.ts, bun-serve-routes.test.ts, serve-if-none-match.test.ts). Remaining red on build 73314 is unrelated: test-net-connect-memleak.js is pre-existing on main, and the rest (complex-workspace, stdin-fixtures, es-module-lexer, bun-upgrade, grpc-js, napi, bun-install-proxy, test-docker-build-debian) are flaky single-lane failures that passed on retry or are known. Ready for review.

@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 didn't find bugs, but this touches Bun.serve's file-response streaming across three sites, including a change to FileResponseStream::on_reader_done that swaps the fall-through end_without_body() for resp.end(b"") — reachable by more than just the new procfs path — so it warrants a human look.

Checked: stream_to_eof in both do_sendfile and FileRoute::on is gated on S_ISREG && stat_size == 0 && unsliced, with HEAD excluded in FileRoute.
Checked: sendfile.remain is left at MAX_SIZE under stream_to_eof but unused — needs_content_length is cleared and length: None is passed to the stream.
Checked: on_reader_done was already reachable with !RESPONSE_DONE for pipes/sockets that EOF on first read; the end(b"") swap looks correct there too (no Content-Length was written), but worth confirming.
Checked: Range resolution is skipped for zero-stat files; a Range request now gets a 200 full body instead of 206/416, which seems right for unknown-length content.

Extended reasoning...

Overview

The PR fixes Bun.serve returning empty bodies for Bun.file() responses backed by procfs/sysfs/cgroupfs regular files that report st_size == 0 but yield real content on read(). It touches three files in src/runtime/server/:

  • RequestContext.rs (do_sendfile): adds a stream_to_eof classifier and gates Content-Length, Content-Range, sendfile-remain clamping, Range resolution, the empty-body early-exit, and the length passed to FileResponseStream on !stream_to_eof.
  • FileRoute.rs (on): mirrors the same classifier for static routes, additionally excluding method == HEAD; threads an is_regular flag out of the stat block to gate on S_ISREG(mode) rather than the FileType::File fallback.
  • FileResponseStream.rs (on_reader_done): when the reader signals done without RESPONSE_DONE set (i.e. EOF at first read, no chunk ever delivered), terminates via resp.end(b"", ...) instead of falling through to finish()end_without_body().

A Linux-only test in serve.test.ts covers the fetch-handler path, the static-route path, and a genuine 0-byte file.

Security risks

None identified. The change narrows to is_regular && stat_size == 0 && offset == 0 && size == MAX_SIZE, so it cannot be used to unbound-read block devices (CodeRabbit's earlier concern, addressed in 5c9bf80). No user-controlled input reaches new parsing or allocation logic. Range handling is disabled for these files, not extended.

Level of scrutiny

High. This is core Bun.serve file-serving logic — a hot, production-critical path shared by both the fetch-handler and static-route sides. The on_reader_done change alters response-termination semantics for a codepath that predates this PR: pipes and sockets that EOF on first read previously reached finish() with !RESPONSE_DONE and called end_without_body(); they now call resp.end(b""). The PR description argues the old behavior left clients hanging when no Content-Length was written, which is plausible, but the swap affects more than the new procfs case and deserves a maintainer's eye on the uWS framing implications.

The PR also went through a design reversal on HEAD (5c9bf80 omitted Content-Length for zero-stat HEAD, c192eee reverted after bun-serve-file.test.ts caught a regression on real empty files). The final position — HEAD keeps stat-derived Content-Length: 0 because it can't distinguish procfs from a real empty file without reading — is reasonable but is a tradeoff a human should ratify.

Other factors

  • Test is Linux-only (inherent to procfs); no coverage on macOS/Windows, though the stream_to_eof branch is unreachable there absent a zero-stat regular file.
  • All CodeRabbit threads are resolved; no human reviewer has weighed in yet.
  • The bug-hunting system found nothing; I independently traced the stream_to_eof guards through both call sites and the FileResponseStream start options and did not find leaks, double-closes, or unclamped sendfile state escaping into the reader path.

@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

On the on_reader_done reachability for pipes/sockets that EOF on first read:

  • do_sendfile callers (fetch handler): pipes/sockets get Content-Length: 0 written via render_metadata (they are not stream_to_eof, so needs_content_length stays true) and mark_wrote_content_length_header() is called. resp.end(b"") with CL already marked is wire-identical to end_without_body(): uWS skips its own CL write, terminates the headers, writes no body bytes.
  • FileRoute callers (static routes): the CL-write block is gated on file_type == FileType::File, so pipes/sockets get no CL header. The old end_without_body() left the client with neither CL nor a chunked terminator (hang). resp.end(b"") writes Content-Length: 0 and terminates cleanly, so this path is strictly a fix.

Either way the new block only runs when !RESPONSE_DONE, so any pipe that delivered at least one chunk already hit resp.end(chunk) in on_read_chunk and skips it.

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