Skip to content

Bun.serve: decide the 206 of a Bun.file() slice by the status, not by the header list - #46059

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/a413f81b/serve-slice-206-by-status
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/a413f81b/serve-slice-206-by-status

Conversation

@robobun

@robobun robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Draft until the two questions in Downsides have an answer.

Problem

  • A fetch handler returns new Response(Bun.file(path).slice(0, 100)). Bun.serve answers 206 with Content-Range: bytes 0-99/*. After res.headers or console.log(res), it answers 200 with no Content-Range.
  • render_metadata (src/runtime/server/RequestContext.rs:3882) chooses by whether the Response has a header list. The first read of headers allocates one.

Fix

  • One rule: a slice becomes 206 when the status is 200 or 206, or when the handler set Content-Range. Any other status stays.
  • Correct because the rule reads only what the handler set, as file routes do (FileRoute.rs:386).
  • Verified: 4 new tests in test/js/bun/http/bun-serve-file.test.ts (all fail on 1.4.3). Also serve.test.ts and 5 more suites.
  • Self-reviewed: 8 concerns raised on the first design (a mark on the header list), 8 addressed: this rule replaces it.

Background

  • Bun allocates the header list of a Response on first use: a read, a print, or middleware.
  • Considered a mark on the list that a read makes (Notes). It keeps "any header turns the 206 off", which no doc or test states.

Downsides

Notes

Answers before and after. slice(0, 100) of a 1000-byte file, GET, release builds of main 91e7e3b and of this branch. The released 1.4.3 answers as main does. "206" means with Content-Range: bytes 0-99/*.

Handler returns main this PR
new Response(slice) 206 206
the same, after res.headers, res.headers.get(), [...res.headers] or Bun.inspect(res) 200 206
res.clone() after a read, new Response(slice, response) after a read of response.headers 200 206
{ status: 206 }, after a read 206, no Content-Range 206
{ headers: { "x-a": "1" } }, res.headers.set(), append(), delete() 200 206 (question 1)
{ status: 206, headers: { "x-a": "1" } } 206, no Content-Range 206
{ status: 404 } (also 201, 500), no headers, no read 206 404 (question 2)
{ status: 404 }, after a read or with a header 404 404
{ headers: { "Content-Range": "bytes 0-99/1000" } }, status 200 or 404 206 with that header 206 with that header
whole file, with or without headers, with or without Range unchanged unchanged
the slice as a static route, and HEAD 200 200

Middleware. The docs example (new Response(Bun.file(p).slice(0, 100))) behind Hono 4.7.2:

App main this PR
no middleware 206 206
compress() (adds no header to an .mp4, it only reads them) 200 206
cors(), etag(), secureHeaders() 200 206
no middleware, { status: 404 } 206 404
compress() or cors(), { status: 404 } 404 404

Wire diff. A raw-socket script ran 477 rows on both release builds: 36 response shapes, 15 looks and writes, GET, HEAD and Range requests, 9 copies, 6 entry points.

  • 201 rows changed, all of them path slices from a handler.
  • 66 rows: a read or a print with status 200 or 206 (58 from 200 to 206, 8 gained Content-Range).
  • 9 rows: a copy or another entry point after a read (async fetch, a route function, error()), from 200 to 206.
  • 115 rows: a handler header and no Content-Range (96 from 200 to 206, 19 gained Content-Range). Question 1.
  • 11 rows: a status of 201, 404 or 500 with no header list, from 206 to that status. Question 2.
  • 0 rows of the other bodies changed (string, Blob, bytes, stream, null, Response.json, Response.redirect, whole file, static routes). No row differs in anything but the status line and Content-Range.

Cost. The status test is two integer compares inside needs_content_range && (...). do_sendfile sets that flag only for a path slice or an incoming Range (RequestContext.rs:1968, :2019). The control flow of every other response is the same as on main. 0 rows of the wire diff that are not a slice changed. Release builds of main 91e7e3b and of this branch, linux-x64, one build directory: .text 81,665,765 -> 81,666,021 bytes (llvm-size -A), and the 8 bodies of render_metadata 2342 and 2347 -> 2390 and 2432 bytes (nm -S).

What does not change.

  • A static route answers 200 for a slice. HEAD answers the status of the handler (Bun.serve: resolve a file body for HEAD the same way as for GET #41585). Bun.file(fd) slices keep the status of the handler.
  • A status with an own Content-Range still becomes 206 ({ status: 404, headers: { "Content-Range": ... } }).
  • An empty window answers 206 with Content-Range: bytes 5-5/* and length 0, as a bare slice(5, 5) does on main. With this PR a header or a read no longer turns that into 200.

If the answer to question 1 is no. Then the header list has to tell a read from a write. A variant of this PR did that: a mark on WebCore::FetchHeaders for the list that a read of headers makes, and the update counter of the list to tell that nothing wrote to it. It changed only rows with a read. It was +166/-63 in 8 source files, sizeof(FetchHeaders) stayed 96 bytes, and Response.json() cost 2845.7 -> 2852.2 instructions per call (noise 5 to 12). It needs a status gate so that { status: 404 } plus a read stays 404. It is not in this branch.

Related. #44826 and #44827 change the lines above this hunk (the Content-Length and where the 206 bit is computed). #45601 changes how the header list is stored and leaves this rule as it is.

Suites run on the debug build. bun-serve-file (176 pass), the Content-Range block of serve.test.ts (39 pass), bun-serve-headers, bun-serve-static, bun-server, serve-reused-response, serve-directory-routes. serve.test.ts has the same 2 failures with and without the change in the build container (a privileged port test that runs as root, and a sendfile abort test that times out). serve-file-slice-read-error.test.ts needs more than the 5 s default there and passes with a longer timeout.


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

… the header list

A fetch handler that returns `new Response(Bun.file(path).slice(a, b))`
gets 206 and a Content-Range. `render_metadata` chose between that and the
status of the Response by whether the Response had a header list. The
first read of `headers` allocates the list, and so does printing the
Response. So the same Response went out as 200 with no Content-Range after
a read, and with any header that the handler set. With no list, a status
of the handler such as 404 went out as 206.

A slice is now promoted to 206 when the status is 200 or 206, or when the
handler set its own Content-Range. Any other status stays as the handler
gave it. The header list no longer counts.
@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:30 AM PT - Oct 19th, 2026

✅ @robobun, your commit b649d8c53c36b0de05dc7baaf2353a3ffa2d32d0 passed in Build #128887! 🎉


🧪   To try this PR locally:

bunx bun-pr 46059

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

bun-46059 --bun

@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on 1.4.3 (367d939). A fetch handler that returns new Response(Bun.file(path).slice(0, 100)) answers 206 with Content-Range: bytes 0-99/*. The same handler answers 200 with no Content-Range after res.headers or Bun.inspect(res), and behind Hono 4.7.2 compress() or cors(). The 4 new tests in test/js/bun/http/bun-serve-file.test.ts fail on 1.4.3 and pass on a debug build of this branch.

CI: Buildkite build #128887 passed on all platforms at b649d8c.

This PR (#46059) stays a draft until a maintainer answers the two questions in its description.

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.

1 participant