Skip to content

Bun.serve: honour handler-set Content-Length for ReadableStream bodies - #35682

Closed
robobun wants to merge 5 commits into
mainfrom
farm/ba28ac9d/serve-stream-content-length
Closed

robobun wants to merge 5 commits into
mainfrom
farm/ba28ac9d/serve-stream-content-length

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #10507.

Problem

Bun.serve strips a handler-set Content-Length header when the Response body is a ReadableStream, and frames the body as Transfer-Encoding: chunked instead. A handler that proxies an upstream body, streams a file whose size it already knows, or pipes to an S3 presigned URL cannot tell the client how long the body is (no download progress, no resumable downloads, 411 from S3).

Bun.serve({
  fetch() {
    return new Response(someReadableStream, {
      headers: { "Content-Length": "12345" },
    });
  },
});
// response has no Content-Length, uses Transfer-Encoding: chunked

render_metadata() always calls headers.fast_remove(ContentLength) before writing headers to uWS, and only the sendfile path re-emits it from the stat size. Stream bodies have no known size there, so nothing puts the header back.

Fix

In render_metadata(), when the body is a ReadableStream (sink or byte_stream is set) and the handler did not also set Transfer-Encoding, read the handler's Content-Length before it is stripped and emit it after the status/headers, marking HTTP_WROTE_CONTENT_LENGTH_HEADER on the uWS response. uWS then writes the body raw instead of chunk-framing it.

Follow-on adjustments:

  • HTTPServerWritable::send* routed a single-write stream body through try_end(), which writes its own Content-Length. When the header is already marked, use end() instead so it is not duplicated.
  • do_render_head_response's Locked arm wrote Transfer-Encoding: chunked unconditionally for stream bodies; it now emits the handler's Content-Length when present so HEAD matches GET.
  • handle_reject, handle_reject_stream, render_missing and the ByteStream pipe path (on_pipe, a proxied fetch().body whose upstream errors) force-close the socket when the Content-Length is already on the wire but the stream errors before delivering the promised bytes, so the client sees a truncated message rather than waiting forever over keep-alive.

In-memory and Bun.file bodies are unchanged: Bun keeps framing those from the actual byte length. A handler that sets a Content-Length that does not match the bytes the stream then produces gets exactly what it asked for, same as node:http; no byte-count enforcement is added here.

Why this is correct

Node's http module honours a user-set Content-Length verbatim. The server does not know a stream's length, so the handler is the only source of that information, and RFC 9112 permits either framing for a known-length body. Honouring the header for stream bodies is strictly more capable than forcing chunked; handlers that do not set it keep chunked.

Testing

New tests in test/js/bun/http/bun-serve-headers.test.ts cover an async pull() stream, a sub-highWaterMark single-write stream, Readable.toWeb, proxied fetch().body, HEAD, keep-alive framing, a stream that errors after the header is committed, the no-CL chunked fallback, and the in-memory-body invariant. The behaviour-change tests fail before the fix (stripped CL / chunked) and pass after; the invariant tests pass on both.

test/js/web/fetch/fetch-gzip.test.ts set Content-Length: 1 on a larger gzip stream and relied on the header being discarded; that header is dropped so the test keeps its actual subject (gzip decoding over a streamed body).

Supersedes #27262 and #28673, which targeted the pre-rewrite Zig tree.

render_metadata()/do_write_headers() stripped Content-Length on every
response, then only re-emitted it for the sendfile path. A Response with a
ReadableStream body and an explicit Content-Length therefore went out as
Transfer-Encoding: chunked, so proxies and download endpoints that already
knew the size could never tell the client (no progress bar, no resumable
downloads, 411 from S3 presigned PUT targets).

Capture the handler's Content-Length in render_metadata() before it is
stripped, only when the body is a ReadableStream (sink/byte_stream set) and
the handler did not also set Transfer-Encoding. Emit it and mark the uWS
response so write()/end() use content-length framing instead of chunked. The
sink's single-write try_end() fast path now routes to end() when the header
is already marked, avoiding a duplicate Content-Length. HEAD over a stream
body frames the same way. In-memory and file bodies are unchanged: their
size is known and Bun keeps framing from it.

The fetch-gzip stream test set Content-Length: 1 on a larger gzip stream to
rely on the header being discarded; drop that header so the test keeps its
actual subject (gzip decoding over a streamed body).

Fixes #10507
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 22 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: 39964ba3-28fd-4670-aca2-86dfd9264c9b

📥 Commits

Reviewing files that changed from the base of the PR and between 60772af and eeba92a.

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

Walkthrough

Changes

Updated HTTP streaming to preserve valid handler-provided Content-Length, complete framed streams directly, and force-close responses when errors occur after framing is committed. Added socket-level coverage for streams, proxies, HEAD responses, keep-alive behavior, and error closure.

Possibly related PRs

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement #10507 by honoring handler-set Content-Length for ReadableStream bodies and matching HEAD/error behavior.
Out of Scope Changes check ✅ Passed The only extra change is a test adjustment for streamed gzip behavior, which supports the PR's framing changes and stays in scope.
Title check ✅ Passed The title clearly summarizes the main change: honoring handler-set Content-Length for ReadableStream responses.
Description check ✅ Passed It covers the problem, fix, rationale, and testing, though it uses different headings than the repository template.

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

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:27 AM PT - Jul 25th, 2026

@autofix-ci[bot], your commit eeba92a is building: #81210

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on main: Bun.serve drops a handler-set Content-Length for ReadableStream bodies and falls back to chunked. Fixed by capturing the header in render_metadata() for stream bodies and marking it on the uWS response so the body is framed raw. Stream-error and render-missing paths force-close when the header is already committed but no bytes were written. Tests in test/js/bun/http/bun-serve-headers.test.ts fail before and pass after.

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Respect User-Defined Headers in HTTP Responses #19760 - Requests Bun.serve to respect user-defined Content-Length headers instead of overwriting them, which is exactly what this PR implements for ReadableStream bodies
  2. Sveltekit (using vite) returns cloudflare 520 status #6687 - Cloudflare 520 errors caused by Bun setting incorrect Content-Length on stream responses; nginx logs show "upstream sent more data than specified in Content-Length header"
  3. ERR_INCOMPLETE_CHUNKED_ENCODING with Next.js app using Bun in Docker behind NGINX proxy #19789 - Chunked encoding errors behind nginx proxy ("too many transfer encodings: chunked chunked"), caused by the Content-Length vs Transfer-Encoding conflict on stream bodies

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

Fixes #19760
Fixes #6687
Fixes #19789

🤖 Generated with Claude Code

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

Beyond the inline nit, I checked whether the HEAD Locked arm could double-emit Content-Length via render_metadata()'s new capture — it can't, because sink/byte_stream are not set on the HEAD path, so the capture guard is false. Also confirmed the new res.end() branch in send_without_auto_flusher only fires under requested_end && !is_http_write_called(), so end_from_js's post-return ended_response = true still applies. This changes HTTP/1.1 wire framing in Bun.serve's hot path, so a maintainer familiar with the uWS write()/end() state machine should confirm the mark_wrote_content_length_header interaction with multi-chunk streams and keep-alive.

Extended reasoning...

The PR captures a handler-set Content-Length for ReadableStream response bodies in render_metadata() (gated on sink || byte_stream, no needs_content_length, and no user Transfer-Encoding), emits it and marks HTTP_WROTE_CONTENT_LENGTH_HEADER on the uWS response, and adjusts HTTPServerWritable::send* to use end() instead of try_end() when that flag is set so uWS doesn't write a second CL. The HEAD Locked arm mirrors the capture. I verified the HEAD path doesn't double-emit (sink/byte_stream are only assigned in the GET stream-setup path at RequestContext.rs:2040/3240, after the HEAD branch), and that the ended_response bookkeeping still holds for the new end() branch. The fetch-gzip.test.ts edit removes a bogus Content-Length: 1 that only passed because the header was previously discarded — reasonable. Deferring because this is a wire-protocol behavior change in production-critical framing code with subtle backpressure/keep-alive interactions; the test coverage is good but a maintainer sign-off on the uWS state-flag semantics is warranted.

Addresses review: render_metadata() now commits the handler's
Content-Length before the stream pump starts, so a stream that errors
before writing any body bytes would leave the client waiting on the
promised bytes over keep-alive. handle_reject, handle_reject_stream and
render_missing now force-close in that state (same treatment as the
existing mid-body error case).

Also adds a single-write sub-highWaterMark test that exercises the
end() branch in HTTPServerWritable, and makes the proxy test stream its
upstream body so the byte_stream path is taken deterministically.
Comment thread src/runtime/server/RequestContext.rs Resolved
Comment thread src/runtime/server/RequestContext.rs Resolved
Comment thread src/runtime/server/RequestContext.rs Resolved
Comment thread src/runtime/server/RequestContext.rs Resolved
Comment thread src/runtime/server/RequestContext.rs Resolved
Comment thread src/runtime/server/RequestContext.rs Resolved
The ByteStream error path (a proxied fetch().body whose upstream dies)
routed through on_pipe, which ended the response cleanly even when a
committed Content-Length could no longer be satisfied, leaving keep-alive
clients waiting on the promised bytes. Apply the same close-instead-of-end
rule handle_reject uses.

Also await the socket close event directly in the error-path tests:
once(socket, "close") rejects on the ECONNRESET a forced close surfaces
on Windows. Trim review-flagged comments.

@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/RequestContext.rs`:
- Around line 2608-2620: Extract the repeated Transfer-Encoding check and
Content-Length parsing into a shared helper near the relevant RequestContext
framing logic, then reuse it in both the HEAD locked-body path and
render_metadata() before headers are stripped. Keep the non-body branch on
HTTP::parse_content_length, while making the shared parser choice explicit and
preserving the existing Option behavior.

In `@test/js/bun/http/bun-serve-headers.test.ts`:
- Around line 300-304: Strengthen the assertion in the socket truncation test by
first requiring the response headers to contain the committed Content-Length
value of 100, then explicitly assert that the received body is shorter than 100
bytes. Keep the existing Buffer collection and header/body parsing flow, and
ensure the test fails when the connection closes without sending the status and
Content-Length headers.
🪄 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: 979d617b-cd6b-4308-b652-310deac11a04

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and 60772af.

📒 Files selected for processing (4)
  • src/runtime/server/RequestContext.rs
  • src/runtime/webcore/streams.rs
  • test/js/bun/http/bun-serve-headers.test.ts
  • test/js/web/fetch/fetch-gzip.test.ts
💤 Files with no reviewable changes (1)
  • test/js/web/fetch/fetch-gzip.test.ts

Comment thread src/runtime/server/RequestContext.rs Resolved
Comment thread src/runtime/server/RequestContext.rs Resolved
Comment thread src/runtime/server/RequestContext.rs Resolved
Extract handler_content_length (Transfer-Encoding skip + Content-Length
parse) shared by the HEAD locked-body path and render_metadata, and
force_close_if_framing_committed shared by handle_reject,
handle_reject_stream and the on_pipe error arm.

Check the committed head strictly in the stream-error test when it was
received; a reset can beat the header flush, so the close event itself
stays the unconditional assertion.
Comment on lines +1232 to +1234
/// Framing (a chunked write or a Content-Length) is already on the wire but
/// the body cannot complete; close so the client sees a truncated message
/// instead of waiting on the promised bytes over keep-alive.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +4596 to +4597
/// Handler-set Content-Length for framing a stream body (#10507); None when the
/// handler also set Transfer-Encoding or the value does not parse.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

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

No further issues from me — the prior three rounds of feedback (socket 'error' handlers in the raw-socket tests, the missing on_pipe byte_stream force-close, and the dedup of force_close_if_framing_committed / handler_content_length) are all addressed in 60772af and c90b0f3. Deferring to a human because this changes HTTP/1.1 response framing on the Bun.serve hot path: honouring a handler-set Content-Length for stream bodies is a user-visible behaviour change (a wrong CL now mis-frames instead of being discarded), and the four new force-close sites on error paths interact with keep-alive reuse.

What was reviewed:

  • render_metadata / do_render_head_response capture the handler CL only for sink/byte_stream bodies, skip when Transfer-Encoding is set, and mark HTTP_WROTE_CONTENT_LENGTH_HEADER so uWS writes raw — no path double-emits the header.
  • HTTPServerWritable::send* route to res.end() instead of try_end() when the CL header is already marked; ended_response is still set by the callers on the same paths as before.
  • All four stream-error exits (handle_reject, handle_reject_stream, on_pipe Err, render_missing_corked) now force-close when framing is committed; the on_pipe non-CL fallthrough still terminates chunked correctly.
  • The fetch-gzip.test.ts edit drops a Content-Length: 1 that only worked because it was previously ignored — the test's subject (gzip decode) is unchanged.
Extended reasoning...

Overview

The PR makes Bun.serve honour a handler-set Content-Length header when the response body is a ReadableStream, instead of unconditionally stripping it and falling back to Transfer-Encoding: chunked. Touches src/runtime/server/RequestContext.rs (~70 lines: render_metadata, do_render_head_response, handle_reject, handle_reject_stream, on_pipe, render_missing_corked, end_stream, plus two extracted helpers), src/runtime/webcore/streams.rs (~13 lines in HTTPServerWritable::send*), ~380 lines of new tests in bun-serve-headers.test.ts, and a one-line test fixture correction in fetch-gzip.test.ts.

Security risks

HTTP/1.1 framing changes carry request-smuggling / response-desync risk. The change only affects the server's outgoing framing and only when the handler explicitly sets Content-Length on a stream body; the handler already controls both the header and the byte count, so this doesn't introduce a new attacker-controlled desync vector. The keep-alive test verifies the next request on the same socket parses correctly after a CL-framed stream body. The force-close-on-error additions are the correct RFC 9112 behaviour (truncate rather than under-deliver over keep-alive). The Transfer-Encoding guard prevents emitting both TE and CL. I did not find a smuggling exposure, but framing changes on the core server warrant a maintainer's eyes.

Level of scrutiny

High. RequestContext.rs is the per-request state machine for Bun.serve — the hottest and most correctness-sensitive path in the runtime. This is a deliberate behaviour change (previously a wrong handler CL was silently discarded; now it's honoured verbatim, matching node:http), and it threads through four error-handling exits plus the uWS try_end/end/markDone state machine. The test matrix is thorough (async pull, single-write fast path, Readable.toWeb, proxied fetch().body, HEAD, keep-alive framing, both sink- and byte_stream-path error truncation, and the negative/no-CL invariants), and all prior review feedback has been incorporated. But the interaction with uWS response state (HTTP_WROTE_CONTENT_LENGTH_HEADER, markDone, sendTerminatingChunk) is subtle enough that a maintainer familiar with that layer should sign off.

Other factors

Two comment-cop bot flags remain unresolved on the doc comments for force_close_if_framing_committed (line 1232) and handler_content_length (line 4597); both are 2–3 line function doc comments, not workaround justifications, so they read as false positives. This PR supersedes two prior attempts (#27262, #28673) against the pre-Rust tree.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-25, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. The linked issue (#10507) stays open. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

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.

Content-Length Header gets overwritten

2 participants