Skip to content

Don't write Connection: close into an in-flight chunked response body - #32036

Closed
robobun wants to merge 4 commits into
mainfrom
farm/92ccee11/fix-destroy-mid-chunked-body
Closed

Don't write Connection: close into an in-flight chunked response body#32036
robobun wants to merge 4 commits into
mainfrom
farm/92ccee11/fix-destroy-mid-chunked-body

Conversation

@robobun

@robobun robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

What

test/js/node/test/parallel/test-http-server-capture-rejections.js flakes in CI with:

Mismatched <anonymous> function calls. Expected exactly 1, actual 2.
    at <anonymous> (.../test-http-server-capture-rejections.js:77:29)

The client's data listener (common.mustCall, expected once for the single-byte body {) fires a second time with "\r\n".

Root cause

Capturing the wire traffic of a failing run shows the server injecting header bytes into the response body:

s>c  HTTP/1.1 200 OK\r\n...Transfer-Encoding: chunked\r\n\r\n1\r\n{\r\n
s>c  Connection: close\r\n\r\n        <-- written after the body started

When the test's async request handler throws after res.write('{'), the server captureRejections handler calls res.destroy(), which reaches uws_res_end_without_body in src/uws_sys/libuwsockets.cpp. That shim wrote Connection: close\r\n plus a bare \r\n gated only on HTTP_CONNECTION_CLOSE / HTTP_END_CALLED, without checking HTTP_WRITE_CALLED, so the bytes landed in the middle of an in-flight chunked body.

The client's chunked decoder then parses C as a chunk size (0xC), onnection: close as a chunk extension, and delivers the trailing \r\n as two bytes of body data: the second data event.

The flakiness is only in delivery: the stray bytes are written on every run, but the socket is destroyed immediately afterwards, so whether they get flushed before the connection dies depends on machine timing.

Second site, same class (#28019)

uWS::HttpResponse::internalEnd's content-length branch has the same missing guard: it checks HTTP_END_CALLED but not HTTP_WRITE_CALLED. An HTTP/1.0 (fromAncientRequest) streaming response skips the chunked branch, so when the response sink ends with leftover buffered data after raw body writes already started, internalEnd injects Content-Length: <n>\r\n\r\n into the body. This is the repro in #28019 (Content-Length: 83 appears near the end of the body).

Fix

Guard both sites with HTTP_WRITE_CALLED, the same guard internalEnd already uses for the Connection: close header and for the bare header-terminating CRLF (packages/bun-uws/src/HttpResponse.h:125,180): once write() has been called, the header section is terminated and body bytes are on the wire, so there is nothing valid left to append. Node sends nothing in either situation (verified on v24.3.0).

Likely also the mechanism behind proxy reports like #19789 (ERR_INCOMPLETE_CHUNKED_ENCODING behind NGINX): injected header bytes corrupt the chunked framing mid-stream. That issue has no minimal repro, so it is not claimed as fixed here.

Verification

Two new tests:

  1. test/js/node/http/node-http-transfer-encoding.test.ts: drives a node:http server with a raw net socket, destroys the response after the first chunk is on the wire, and asserts no further bytes arrive. On the unfixed build it fails with:

    expect(received).toBe: ""
    Received: "Connection: close\r\n\r\n"
    
  2. test/js/bun/http/serve-direct-readable-stream.test.ts: uses a type: "direct" stream to drive the response sink deterministically: one chunk at the highWaterMark (flushed to the socket immediately, so the body is started), then end() with a small chunk still buffered, which is exactly the end-with-leftover path. On the unfixed build the client receives Content-Length: 11\r\n\r\n spliced into the body; this fails on every run under the ASAN debug build, independent of event-loop timing.

Fixes #28019

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 4 minutes and 16 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

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

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

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, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d076bd1d-4568-409a-89a3-123a726bf17b

📥 Commits

Reviewing files that changed from the base of the PR and between 29da399 and 5cf190a.

📒 Files selected for processing (4)
  • packages/bun-uws/src/HttpResponse.h
  • src/uws_sys/libuwsockets.cpp
  • test/js/bun/http/serve-direct-readable-stream.test.ts
  • test/js/node/http/node-http-transfer-encoding.test.ts

Walkthrough

This PR adds safety guards to the HTTP response code path to prevent header injection after the response body has begun writing. Changes protect both the C++ HttpResponse class and the C-ABI wrapper against writing headers once the HTTP_WRITE_CALLED flag is set. Two new regression tests validate that streaming and chunked responses do not receive unwanted Content-Length headers.

Changes

HTTP Response Header Safety

Layer / File(s) Summary
Content-Length header safety in HttpResponse
packages/bun-uws/src/HttpResponse.h
HttpResponse::internalEnd adds HTTP_WRITE_CALLED check to prevent Content-Length header emission after body bytes have been written, with an expanded comment explaining that header writes after write() would corrupt the response.
Connection-close header safety in C-ABI wrapper
src/uws_sys/libuwsockets.cpp
uws_res_end_without_body checks HTTP_WRITE_CALLED in both SSL and non-SSL branches before injecting "Connection: close" header, preventing response corruption when body write has already commenced. An inline comment explains that headers cannot be written once the header section is terminated.
Streaming and chunked response validation tests
test/js/bun/http/serve-direct-readable-stream.test.ts, test/js/node/http/node-http-transfer-encoding.test.ts
New Bun test verifies HTTP/1.0 streaming responses do not inject Content-Length when ending close-delimited streams; new Node.js test confirms response destruction mid-chunked-body produces no trailing header bytes.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: preventing Connection: close headers from being written into in-flight chunked response bodies, which is the primary bug being addressed.
Description check ✅ Passed The description provides comprehensive details about the bug, root cause, fix approach, and verification tests, but lacks explicit 'How did you verify your code works?' section structure from the template.
Linked Issues check ✅ Passed The PR fully addresses #28019's requirement to prevent Content-Length header injection mid-response-body for HTTP/1.0 streaming responses, plus fixes the related Connection: close injection issue with a comprehensive fix to both code sites.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the header-injection bug: two production fixes guard header writes with HTTP_WRITE_CALLED check, and two new regression tests verify the fixes without extraneous modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions github-actions Bot added the claude label Jun 9, 2026
@robobun

robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:32 PM PT - Jun 23rd, 2026

@robobun, your commit 5cf190a has 5 failures in Build #64380 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32036

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

bun-32036 --bun

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Bun.serve will in some cases add "Content-Length: <n>" to response body for large responses over HTTP1.0 #28019 - Header text (Content-Length: <n>) is injected into the response body of streaming HTTP/1.0 responses — same root cause of header bytes written into an in-flight body via uws_res_end_without_body
  2. ERR_INCOMPLETE_CHUNKED_ENCODING with Next.js app using Bun in Docker behind NGINX proxy #19789 - ERR_INCOMPLETE_CHUNKED_ENCODING behind NGINX proxy — injected Connection: close bytes corrupt chunked framing, causing the proxy to see invalid chunk data

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

Fixes #28019
Fixes #19789

🤖 Generated with Claude Code

@robobun

robobun commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main at be6a664 (sha 5cf190a). Conflict was additive in serve-direct-readable-stream.test.ts (#32140 added direct-stream tests at the same spot; kept all of them plus mine). Fix diff unchanged; gate proof re-verified both directions post-rebase.

CI build 64380 finished with 281 green, 5 failed jobs; all in unrelated subsystems:

  • darwin 26 aarch64: Buildkite artifact-download timeout (never ran a test)
  • windows 2019 x64: test/bake/dev-and-prod.test.ts HMR rapid-edit race
  • darwin 14 x64: grpc-js/test-server.test.ts SIGTRAP; terminal.test.ts pty-attach 90s timeout
  • darwin 14 aarch64: test-tls-client-destroy-soon.js byte-count mismatch; autobahn.test.ts docker image platform mismatch (exec format error)

Both regression tests in this PR and the files touched by this diff are absent from every failure annotation (passed on every lane). The most recent main build (64400) also finished in state failed, so these are ambient rather than introduced here. Diff is ready for review.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
test/js/bun/http/serve-direct-readable-stream.test.ts (2)

14-14: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove the arbitrary timing wait.

The sleep(10) here doesn't wait for a condition—it's an arbitrary delay between writes. The test validates that leakedCtrl.write() throws after pull() returns; that assertion doesn't depend on a 10ms delay. Per coding guidelines, "do not use setTimeout in tests; instead, await the condition to be met."

✂️ Remove the sleep
 async pull(ctrl) {
   await ctrl.write("a");
-  await sleep(10);
   await ctrl.write("b");
   ctrl.flush();
🤖 Prompt for 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.

In `@test/js/bun/http/serve-direct-readable-stream.test.ts` at line 14, Remove the
arbitrary await sleep(10) and instead rely on the actual sequence between pull()
and leakedCtrl.write(); delete the call to sleep(10) and make the test assert
immediately after pull() resolves that leakedCtrl.write() throws. Locate the
test lines referencing sleep(10), leakedCtrl.write(), and the pull()
implementation and ensure the test awaits the pull completion (or the promise it
returns) before asserting the thrown error, rather than inserting a timed delay.

Source: Coding guidelines


4-4: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Prefer concurrent tests for independent server-spawn tests.

Both tests spawn independent servers on random ports (port: 0), so they can run concurrently without interference. As per coding guidelines, prefer test.concurrent for tests that spawn processes or servers when they don't share state.

♻️ Mark tests concurrent
-test("HTTPResponseSink displays correct message", async () => {
+test.concurrent("HTTPResponseSink displays correct message", async () => {
-test("ending an HTTP/1.0 streaming response does not inject a Content-Length header", async () => {
+test.concurrent("ending an HTTP/1.0 streaming response does not inject a Content-Length header", async () => {

Also applies to: 36-36

🤖 Prompt for 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.

In `@test/js/bun/http/serve-direct-readable-stream.test.ts` at line 4, Change the
two independent server-spawning tests to run concurrently by replacing the
synchronous Jest/Mocha test declarations with concurrent ones: update the test
call for "HTTPResponseSink displays correct message" and the other test titled
the same at the second occurrence to use test.concurrent (or the framework's
equivalent) instead of test so each server-created test runs in parallel on
random ports; ensure the test names and behavior remain unchanged and only the
test function wrapper is modified.

Source: Coding guidelines

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

Outside diff comments:
In `@test/js/bun/http/serve-direct-readable-stream.test.ts`:
- Line 14: Remove the arbitrary await sleep(10) and instead rely on the actual
sequence between pull() and leakedCtrl.write(); delete the call to sleep(10) and
make the test assert immediately after pull() resolves that leakedCtrl.write()
throws. Locate the test lines referencing sleep(10), leakedCtrl.write(), and the
pull() implementation and ensure the test awaits the pull completion (or the
promise it returns) before asserting the thrown error, rather than inserting a
timed delay.
- Line 4: Change the two independent server-spawning tests to run concurrently
by replacing the synchronous Jest/Mocha test declarations with concurrent ones:
update the test call for "HTTPResponseSink displays correct message" and the
other test titled the same at the second occurrence to use test.concurrent (or
the framework's equivalent) instead of test so each server-created test runs in
parallel on random ports; ensure the test names and behavior remain unchanged
and only the test function wrapper is modified.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 80ee5c29-e3f0-4505-abf2-fc2f79c35e4c

📥 Commits

Reviewing files that changed from the base of the PR and between 92eecd4 and 88334f7.

📒 Files selected for processing (1)
  • test/js/bun/http/serve-direct-readable-stream.test.ts

@robobun

robobun commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator Author

Re the two outside-diff suggestions on serve-direct-readable-stream.test.ts:

  • Keeping the sleep(10) in the pre-existing HTTPResponseSink test: that test's writes land in separate event loop turns on purpose, so "a" is flushed before "b" is written (the response sink auto-flushes per turn). Removing the delay would coalesce both writes into one flush and change what the test covers. It is also not part of this PR's diff.
  • Not converting the pre-existing test to test.concurrent in this PR for the same scope reason. The new regression test added here runs in well under a second and the file has two tests total, so concurrency buys nothing measurable.

@robobun
robobun force-pushed the farm/92ccee11/fix-destroy-mid-chunked-body branch from 88334f7 to 29da399 Compare June 23, 2026 03:17

@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 any issues — the guard mirrors the existing HTTP_WRITE_CALLED check already used for Connection: close in internalEnd — but since this touches wire-level response framing in the core HTTP server path, it's worth a human sign-off.

Extended reasoning...

Overview

This PR adds HTTP_WRITE_CALLED guards at two sites to prevent header bytes from being injected into an in-flight response body:

  • packages/bun-uws/src/HttpResponse.h (internalEnd): skip the Content-Length: <n>\r\n\r\n write once write() has already terminated the header section and put body bytes on the wire (HTTP/1.0 / close-delimited streaming path).
  • src/uws_sys/libuwsockets.cpp (uws_res_end_without_body, SSL + non-SSL): skip the Connection: close header and the bare header-terminating \r\n once the body has started (the res.destroy() path).

Two regression tests cover both sites with raw-socket assertions on the exact bytes received.

Security risks

None identified. The change strictly removes writes in a state where they were corrupting the body; it does not add any new data to responses, change parsing, or touch auth/crypto/permissions.

Level of scrutiny

High. Although the diff is small and mechanically mirrors the identical guard already present a few lines above (HttpResponse.h:125 for Connection: close in internalEnd), this is the core HTTP response framing path that every Bun.serve / node:http response flows through. A subtle regression here — e.g. a caller of uws_res_end_without_body or the non-chunked internalEnd branch that relied on the old behavior in some edge case (sendfile, SSE, WebSocket upgrade, 204/304, content-length-already-set) — would have a wide blast radius. A maintainer familiar with all the call sites should confirm.

Other factors

  • The root-cause analysis in the description is thorough and the fix matches Node's verified behavior (send nothing on destroy mid-body).
  • The author ran the broader HTTP test suite (node-http.test.ts, serve.test.ts, hspec.test.ts, etc.) with no new failures, and the flaky upstream test went 0/130 after.
  • No CODEOWNERS cover these files; no outstanding human review comments.
  • The bug hunting system found no issues.

robobun added 4 commits June 24, 2026 01:06
uws_res_end_without_body wrote a Connection: close header and a bare
CRLF without checking HTTP_WRITE_CALLED. When a response was destroyed
after chunked body data had been written (e.g. res.destroy() from the
server captureRejections handler), those bytes were appended to the
body stream. Clients parsed them as chunk framing: 'C' became a chunk
size of 0xC, 'onnection: close' a chunk extension, and the trailing
CRLF two bytes of body data, producing a spurious 'data' event.

Guard both writes with HTTP_WRITE_CALLED, matching the guard in
uWS::HttpResponse::internalEnd.
Same bug class as the previous commit, different site: internalEnd's
content-length branch checked HTTP_END_CALLED but not HTTP_WRITE_CALLED.
An HTTP/1.0 (fromAncientRequest) streaming response skips the chunked
branch, so when the response sink ended the stream with leftover
buffered data after raw body writes had begun, internalEnd injected
'Content-Length: <n>' plus a blank line into the body.

Guard the content-length write with HTTP_WRITE_CALLED, like the bare
CRLF arm next to it.

Fixes #28019
Move it from http-server-chunking.test.ts to
serve-direct-readable-stream.test.ts and drive the response sink
directly: write one chunk at the highWaterMark so it is flushed to the
socket immediately, then end the sink with a small chunk still
buffered. That hits the end-with-leftover path on every run instead of
depending on event-loop timing, and the previous host file also
carried an unrelated test that binds localhost and cannot pass in
IPv6-first environments.
@robobun
robobun force-pushed the farm/92ccee11/fix-destroy-mid-chunked-body branch from 29da399 to 5cf190a Compare June 24, 2026 01:14

@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 any issues — the guard mirrors the existing HTTP_WRITE_CALLED check at HttpResponse.h:125 and the reasoning is solid — but this sits on the core HTTP response write path where the state-flag interactions are subtle, so worth a human look before merge.

Extended reasoning...

Overview

This PR adds an HTTP_WRITE_CALLED guard at two sites that previously could write header bytes after the response body had already started:

  • packages/bun-uws/src/HttpResponse.hinternalEnd()'s Content-Length branch
  • src/uws_sys/libuwsockets.cppuws_res_end_without_body() (both SSL and non-SSL branches), for the Connection: close header and the header-terminating \r\n

Two regression tests are added that deterministically reproduce both injection sites and assert no stray bytes appear on the wire.

Security risks

None identified. The change is purely subtractive (suppresses header bytes that were being written at the wrong time); it does not introduce new parsing, allocation, or user-controlled input handling. If anything, it removes a wire-corruption vector that could confuse downstream proxies/clients.

Level of scrutiny

High. HttpResponse::internalEnd and uws_res_end_without_body sit on the hot path for every HTTP response Bun serves. The correctness of the change hinges on the interaction between several state flags (HTTP_WRITE_CALLED, HTTP_END_CALLED, HTTP_WROTE_CONTENT_LENGTH_HEADER, fromAncientRequest, closeDelimited, noBodyStatus). I traced the else-branch entry conditions and the new guard looks correct — once write() has terminated the header section, neither a Content-Length header nor a bare \r\n should be emitted, and the existing else if (!HTTP_WRITE_CALLED) already encodes the same invariant. The pattern also exactly matches the pre-existing guard for Connection: close at HttpResponse.h:125.

Other factors

The PR description is unusually thorough (wire-level capture, root-cause analysis, before/after flake counts, cross-reference to #28019), and the author re-verified the gate proof after rebase. The bug-hunting system found nothing. Still, given this is core HTTP server infrastructure, I'd prefer a maintainer sign-off rather than auto-approving.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

The same internalEnd() else-branch also corrupts node:http HTTP/1.0 responses that stream via res.write() before res.end(chunk):

import http from 'node:http';
import net from 'node:net';
const srv = http.createServer((q, r) => { r.writeHead(200); r.write('a'); setImmediate(() => { r.write('b'); r.end('c'); }); });
srv.listen(0, () => {
  const s = net.connect(srv.address().port);
  s.on('connect', () => s.write('GET / HTTP/1.0\r\nHost:a\r\n\r\n'));
  let got = ''; s.on('data', d => got += d); s.on('close', () => console.log(JSON.stringify(got)));
});

body comes back as abContent-Length: 1\r\n\r\nc instead of abc.

I pushed a node:http regression test for this variant (and an equivalent HttpResponse.h fix that gates the whole header-terminator block on !HTTP_WRITE_CALLED) to claude/farm/34c24bb1/fix-http10-body-corruption in case it's useful to fold into this PR. The test fails on main and passes with either this PR's HttpResponse.h change or mine.

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.

Bun.serve will in some cases add "Content-Length: <n>" to response body for large responses over HTTP1.0

2 participants