Skip to content

Bun.serve: honor If-Range on file, directory and handler file responses - #43571

Merged
Jarred-Sumner merged 5 commits into
mainfrom
robobun/9dea4d5e/serve-if-range
Sep 30, 2026
Merged

Jarred-Sumner merged 5 commits into
mainfrom
robobun/9dea4d5e/serve-if-range

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.serve ignores the If-Range request header. A Range request whose validator does not match the response still gets 206 Partial Content. A client that resumes a download after the file changed joins bytes of two versions.
  • All three Range paths are affected: FileRoute::serve (FileRoute.rs:371), DirectoryRoute::on (DirectoryRoute.rs:161) and RequestContext::do_sendfile (RequestContext.rs:1909).

Fix

  • RangeRequest::from_request takes the ETag and Last-Modified of the response. If If-Range does not match them, it returns no range, and the route sends the full 200 body. File routes and { dir } routes use it.
  • The handler path keeps a copy of If-Range. do_sendfile compares it with the ETag and Last-Modified the handler set on its Response. Without them, the response is the full body.
  • Correct per RFC 9110 §13.1.5: an entity-tag uses the strong comparison, and an HTTP-date must name GMT and equal Last-Modified.
  • Verified: test/js/bun/http/bun-serve-file.test.ts (45 new cases, 33 fail on 1.4.3) and test/js/bun/http/serve-directory-routes.test.ts. Also bun-serve-static, serve-if-none-match, serve-http2, serve-http3. Self-reviewed: 2 concerns raised, 2 addressed.

Background

  • If-Range carries the validator (an ETag or a Last-Modified date) of the partial copy that the client has. It means: send the range only if the file is still that version, otherwise send all of it.
  • The strong comparison needs two byte-equal tags that are not weak (no W/ prefix). The ETag of a { dir } route is weak, so only the date form can match there.
  • do_sendfile serves a handler's new Response(Bun.file(p)). It can run after the uWS request object is gone, so the context copies the header.
Notes

Repro on 1.4.3. /dir/* is a { dir } route, /file is a file route, and the fetch handler returns new Response(Bun.file(p)). Each request sends Range: bytes=0-3 and If-Range: "does-not-match":

/dir/big.bin   206 bytes 0-3/100000   (with this PR: 200, full body)
/file          206 bytes 0-3/100000   (with this PR: 200, full body)
/fetch-handler 206 bytes 0-3/100000   (with this PR: 200, full body)
  • Bun.serve: honor If-Range on Bun.file Range requests #33548 made the same fix for the file route and the handler path. It was closed as stale because it conflicted with main, not because of the fix. This PR also covers { dir } routes.
  • Parse If-Modified-Since and If-Unmodified-Since as RFC 9110 HTTP-date #41502 (open) replaces jsc_hooks::parse_http_date with a strict HTTP-date parser for If-Modified-Since and If-Unmodified-Since. The If-Range date uses parse_http_date, like the other date preconditions on main. The PR that lands second should switch it.
  • A failed If-Range also turns off 416, because the server ignores the Range header as a whole.
  • If-Range without a usable Range (absent, multi-range, malformed) has no effect, and the request context does not copy it.
  • A weak If-Range tag never matches, as in Go net/http. A client that sends the weak ETag of a { dir } route gets the full file. That is correct, but it is not a resume. nginx compares the bytes of the two tags, and npm send does a substring match. The docs now say that the date is the validator to resume with on a { dir } route.
  • The date must end in GMT. parse_http_date is Date.parse, which reads a date with no zone (the obsolete asctime form) in the server's local time. Such a date never matches, so the result does not depend on the server's time zone. The IMF-fixdate and rfc850 forms match. The strict parser of Parse If-Modified-Since and If-Unmodified-Since as RFC 9110 HTTP-date #41502 can accept asctime as GMT.
  • The date comparison is exact to the second, as in nginx and Go net/http. A file that is rewritten twice in one second keeps the same Last-Modified, and the server cannot see that. RFC 9110 §8.8.2.2 gives that check to the client: it may use a Last-Modified as a strong validator only when the response's Date is later than it.
  • HEAD on the handler path does not evaluate Range on main (it does not reach do_sendfile). The HEAD cases cover the file route and the { dir } route.
  • Cost for a request without Range: one branch in RequestContext::create, and one more header lookup in FileRoute::serve. The copy happens only when Range parses and If-Range is present.
  • The self-review raised two points, and the second commit addresses both. No test responded after an async hop, which is the case the header copy exists for. The it.each names did not say what the If-Range value was.
  • A manual check over HTTP/2 (node:http2 client, http2: true server) gives the same results as HTTP/1.1 on all three paths.
  • Suites run with the debug build: bun-serve-file, serve-directory-routes, bun-serve-static, serve-if-none-match, serve-http2, serve-http3, regression/issue/29181, the Content-Range block of serve.test.ts, and test/internal/source-lints/.
  • The three pollable Bun.file(...) tests in bun-serve-file.test.ts take 2.6 to 6.5 s under the debug ASAN build in my container, so they can exceed the default 5 s timeout when the host is busy. Five interleaved runs of the base binary and the fixed binary show the same times for both.

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

A Range request with an If-Range validator that does not match the
response got a 206 anyway. A client that resumes a download after the
file changed on disk then joins bytes of two versions.

RangeRequest::from_request now takes the validators of the
representation and ignores Range when If-Range does not match them
(RFC 9110 section 13.1.5). An entity-tag uses the strong comparison.
An HTTP-date must equal Last-Modified. FileRoute and DirectoryRoute
pass their ETag and Last-Modified. RequestContext keeps the If-Range
header and compares it in do_sendfile with the ETag and Last-Modified
that the handler set on its Response.
@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for one more day.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for one more day. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 7 minutes for your next included review.

Check out review usage here.

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 6693ac8e-7e45-4c3a-ba00-dd8960c0fee6

📥 Commits

Reviewing files that changed from the base of the PR and between 9b7c982 and e980c42.

📒 Files selected for processing (8)
  • docs/runtime/http/routing.mdx
  • src/http_types/ETag.rs
  • src/runtime/server/DirectoryRoute.rs
  • src/runtime/server/FileRoute.rs
  • src/runtime/server/RangeRequest.rs
  • src/runtime/server/RequestContext.rs
  • test/js/bun/http/bun-serve-file.test.ts
  • test/js/bun/http/serve-directory-routes.test.ts

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

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:12 PM PT - Sep 19th, 2026

✅ @robobun, your commit e980c42ad7cdf4cf334757bd854eb787cbb43195 passed in Build #118784! 🎉


🧪   To try this PR locally:

bunx bun-pr 43571

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

bun-43571 --bun

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on 1.4.3 (canary 367d939d9, linux x64). A { dir } route, a file route and a fetch handler that returns new Response(Bun.file(p)) all answer 206 bytes 0-3/100000 to this request:

Range: bytes=0-3
If-Range: "does-not-match"

With this PR all three answer 200 with the full body. The same happens for a weak tag and for a date that is not the Last-Modified of the response.

Fail-before: USE_SYSTEM_BUN=1 bun test test/js/bun/http/bun-serve-file.test.ts -t "If-Range" gives 33 fail of 45, and serve-directory-routes.test.ts -t "If-Range" fails. Both pass with bun bd test.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked whether the new if_range: Option<Box<[u8]>> on RequestContext is released on every lifecycle exit — deinit never touches it, but release_request_context hands the slot to HiveArray::put, which runs drop_in_place on the whole struct (and the heap-fallback path goes through heap::destroy), so the Box is freed on both pool paths. The weak-tag-never-matches behavior on { dir } routes and the DQUOTE-in-first-3-bytes classification were also looked at and are consistent with RFC 9110 §8.8.3.2 given uWS delivers header values already trimmed.

Extended reasoning...

Three confirmed findings are already attached inline (asctime/TZ-dependent parse_http_date on client bytes, the new Serve::Done hang trigger via if-range in FileRoute::serve, and second-granularity Last-Modified comparison on { dir } routes), so a human look is already signalled. This note only records the additional concern I ruled out from reading the code: RequestContext::deinit takes &self and explicitly clears other owned buffers but not if_range, which would look like a leak; however the context is returned via server.release_request_context -> HiveArray::put, which calls core::ptr::drop_in_place on the slot (and bun_core::heap::destroy for heap-fallback contexts), so the Box<[u8]> is dropped exactly once on both paths. memory_cost also counts it. Not approving given the open findings.

Comment thread src/runtime/server/RangeRequest.rs
Comment thread src/runtime/server/FileRoute.rs
Comment thread src/runtime/server/RangeRequest.rs
Date.parse reads a date with no zone, such as the obsolete asctime
form, in the local time of the server. The result of the comparison
with Last-Modified then depends on the time zone of the server. A date
with no zone now never matches, so the response is the full body.
Comment thread src/http_types/ETag.rs Outdated
Comment thread src/runtime/server/FileRoute.rs Outdated
Comment thread src/runtime/server/RangeRequest.rs Outdated
Comment thread src/runtime/server/RangeRequest.rs Outdated
Comment thread src/runtime/server/RangeRequest.rs Outdated
Comment thread src/runtime/server/RangeRequest.rs Outdated
Comment thread src/runtime/server/RangeRequest.rs Outdated
Comment thread src/runtime/server/RequestContext.rs Outdated
Comment thread src/runtime/server/RequestContext.rs Outdated

@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 re-reviewed the latest push and found no new bugs; the GMT-suffix commit addresses the time-zone point from the earlier round. Because this changes response semantics on every Range request across three serving paths and adds a header lookup to RequestContext::create, a human look is still worthwhile.

What was reviewed this round:

  • The new owning if_range: Option<Box<[u8]>> on RequestContext: freed on both release paths (HiveArray::put runs drop_in_place; the heap fallback runs destroy), and only allocated when Range parsed to something usable.
  • if_range_matches on adversarial values: empty / short header is safe (len().min(3) slice), weak tag on either side and absent ETag/Last-Modified all fall to the full-body branch; no unwrap on client bytes.
  • Handler path (if_range_matches_response) reads validators from the handler's own response headers only, so a bare new Response(Bun.file()) never emits a 206 against an If-Range, matching the tests.
Extended reasoning...

Overview

The PR adds RFC 9110 §13.1.5 If-Range evaluation to the three places Bun.serve honors Range: FileRoute::serve, DirectoryRoute::on, and RequestContext::do_sendfile for handler-returned Response(Bun.file(...)). RangeRequest::from_request grows etag/last_modified_ms parameters and returns Result::None (full 200 body, no 416) when the validator fails; ETag::if_range does strong comparison; RequestContext gains an owned copy of the If-Range header for the async handler case. Tests cover the file route, sync and async handlers, directory routes, HEAD, weak tags, the three date forms, 416 suppression, and a resume-after-rewrite scenario. Since the prior review, one commit (883db62) restricts the HTTP-date path to values ending in GMT, which closes the asctime/local-timezone mismatch that was raised inline last round.

Security risks

if_range_matches operates on client-controlled bytes. I checked the slicing (&if_range[..if_range.len().min(3)]) for empty and 1-2 byte inputs, the ends_with(b"GMT") guard, and that parse_http_date returning None falls to "no match" rather than panicking. The entity-tag path reuses the existing ETag::parse. On the handler path, the ETag header is copied via to_utf8().into_owned() before use, so no borrowed JSC string escapes. Nothing here changes authentication, path resolution, or what file is served; the worst outcome of a wrong comparison is a 200 instead of a 206 or vice versa. No security concern beyond ordinary input hygiene, which looks sound.

Level of scrutiny

Moderate. It is a ~100-line native change to the HTTP server, including RequestContext::create, which runs for every request; the added cost for requests without Range is one already-existing raw_from_request call plus a branch. The new owning field is the main memory-safety question: I traced release_request_context to HiveArray::put, which calls drop_in_place, and to the heap fallback destroy, so the Box<[u8]> is freed with the rest of the context and is also counted in memory_cost. The FileRoute change reorders the etag/last-modified computation before range resolution and adds if-range as a trigger for the fallible last_modified_date(); that extends an existing Err → Serve::Done pattern that was already raised inline in the previous round and is not repeated here.

Other factors

The exit reason was dry_streak, so the hunt ran to exhaustion rather than being cut off. The test matrix is reasonably complete for the behavior, including negative cases (weak tag on either side, date before/after, no validators on the response, If-Range without Range). I did not approve because the change alters observable HTTP behavior on a hot path across three code paths, one earlier inline thread of mine has no corresponding code change in this push, and the PR relies on Date.parse semantics via parse_http_date that a concurrently open PR is replacing; those are judgment calls a maintainer should weigh.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

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

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

@Jarred-Sumner
Jarred-Sumner merged commit d115f54 into main Sep 30, 2026
10 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/9dea4d5e/serve-if-range branch September 30, 2026 03:15
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