Skip to content

Bun.serve: leave a Response without a body unused when its request is dropped - #44753

Open
robobun wants to merge 1 commit into
mainfrom
robobun/0bb85702/dropped-request-bodyless-response
Open

robobun wants to merge 1 commit into
mainfrom
robobun/0bb85702/dropped-request-bodyless-response

Conversation

@robobun

@robobun robobun commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A handler keeps one Response without a body, such as new Response(null, { status: 404 }), and returns it for every request. After one request that the server does not answer, each later request gets 500 ERR_BODY_ALREADY_USED: "Response body already used. A Response body can only be sent once; create a new Response for each request."
  • A slow handler under the default idle timer is enough to cause it.
  • cancel_unread_body (src/runtime/server/RequestContext.rs:760) stores Body::Value::Used for every kind of body, since Bun.serve: cancel the body stream of a Response the server will never transmit #41011 (1.4.1 and 1.4.2).

Fix

  • cancel_unread_body leaves a Null or Empty body as it is. It gives up every other body as before.
  • Correct: such a Response owns nothing that the store releases, and Fetch keeps its bodyUsed false.
  • Verified: test/js/bun/http/serve-reused-response.test.ts, 5 new tests, 4 fail on main. The fifth is a control for bodies with content. Also serve-pending-promise-abort-leak.test.ts.

Background

  • The server drops the Response of a request it can no longer answer, and cancels a stream body.
  • The store of Used also releases a string, Blob or errored body (impl Drop for Value, Body.rs:1434). A GET refuses a Used body (Bun.serve: invoke error() when the returned Response body is already used #33118).
  • Considered the mark for stream bodies only. A dropped string or Blob body then keeps its bytes until GC.

Downsides

  • After a dropped request, bodyUsed of a body-less Response is false. Since 1.4.1 it was true.
  • Not changed here: a kept Response still sends its headers in the first answer only (since 2022).
Notes

Where it reproduces. I ran two scripts from the report. In the first, the handler returns the kept Response behind server.upgrade(req). In the second, a client leaves by a close or a reset while the handler awaits. Both are right on 1.3.13. Both fail on 1.4.2 and on a release build of main (bd599f5). git tag --contains 936bf867aa lists bun-v1.4.1 and bun-v1.4.2.

With this change (debug build of this branch): both scripts pass. A script with a slow handler under the default idle timer passes. A fourth script drops a request in four ways over seven kept shapes: server.upgrade(), idleTimeout: 1, server.timeout(req, 1), and server.stop(true) with a new server. It has 20 of 28 broken cells on main and 0 of 28 here. That script compares the status line only.

The test file. With src/ at main (620b50f): 12 pass, 4 fail. The 404 row and the empty string row answer 500 three times through error(). All three kept objects say bodyUsed: true. With this change: 16 pass.

The abort suite. I ran serve-pending-promise-abort-leak.test.ts twice on each of two debug builds: main and this branch. Main: 27 pass and 3 fail, both times. This branch: 27 and 3, then 26 and 4. Every failure is the 5 s default timeout of a test that starts a debug subprocess. The one extra failure passes 3 of 3 alone on both builds.

Not changed here.

  • The header loss of a kept Response: the first answer has the headers, later answers do not. It needs its own change.
  • A kept new Response("") whose .body was read answers 500 from the second request on main. The cause is the Empty arm of Value::to_readable_stream since Keep body stream bookkeeping independent of the body's source #42116. It does not enter cancel_unread_body.
  • A Response with a body. A dropped string, bytes or stream body is marked used as before (tested). A Blob, file or errored body takes the same arm (read from the code, not tested).

Open PRs that rewrite the same statement. Each keeps the store for every body. Each needs the same condition if it lands after this one.


[human-review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/http/serve-reused-response.test.ts
bun test v1.4.3 (367d939d9)

test/js/bun/http/serve-reused-response.test.ts:
(pass) returning a Response with an already-used body > returning the same string-bodied Response twice calls the error handler [117.25ms]
(pass) returning a Response with an already-used body > returning the same Uint8Array-bodied Response twice calls the error handler [58.18ms]
(pass) returning a Response with an already-used body > returning the same stream-bodied Response twice calls the error handler [60.75ms]
(pass) returning a Response with an already-used body > returning another Response around a ReadableStream that was already sent calls the error handler [84.81ms]
(pass) returning a Response with an already-used body > returning another Response around a type: "direct" ReadableStream that was already sent calls the error handler [52.88ms]
(pass) returning a Response with an already-used body > returning a Response whose body was consumed before returning calls the error handler [70.72ms]
(pass) returning
... (truncated)

release without fix: 1 FAILED
bun test v1.4.3-canary.1 (a5b6664b1)

test/js/bun/http/serve-reused-response.test.ts:
(pass) returning a Response with an already-used body > returning the same string-bodied Response twice calls the error handler [9.24ms]
(pass) returning a Response with an already-used body > returning the same Uint8Array-bodied Response twice calls the error handler [3.79ms]
(pass) returning a Response with an already-used body > returning the same stream-bodied Response twice calls the error handler [3.93ms]
(pass) returning a Response with an already-used body > returning another Response around a ReadableStream that was already sent calls the error handler [12.81ms]
(pass) returning a Response with an already-used body > returning another Response around a type: "direct" ReadableStream that was already sent calls the error handler [7.46ms]
(pass) returning a Response with an already-used body > returning a Response whose body was consumed before returning calls the error handler [5.84ms]
(pass) returning a Response with an already-used body > HEAD for a fetch() Response that a pending text() reads leaves the body to text() [102.53ms]
(pass) returning a Response with an already
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/http/serve-reused-response.test.ts
bun test v1.4.3 (367d939d9)

test/js/bun/http/serve-reused-response.test.ts:
(pass) returning a Response with an already-used body > returning the same string-bodied Response twice calls the error handler [202.93ms]
(pass) returning a Response with an already-used body > returning the same Uint8Array-bodied Response twice calls the error handler [58.64ms]
(pass) returning a Response with an already-used body > returning the same stream-bodied Response twice calls the error handler [65.24ms]
(pass) returning a Response with an already-used body > returning another Response around a ReadableStream that was already sent calls the error handler [98.97ms]
(pass) returning a Response with an already-used body > returning another Response around a type: "direct" ReadableStream that was already sent calls the error handler [37.10ms]
(pass) returning a Response with an already-used body > returning a Response whose body was consumed before returning calls the error handler [53.36ms]
(pass) returning
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 2915ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/2] cargo plan → /workspace/bun/build/release/rust-target/plan.json
247 units: 175 lib, 16 proc-macro (host), 19 custom-build (host), 15 run custom-build, 17 lib (host), 4 run custom-build (host), 1 rlib
[1/2] reconfigure
[1/114] gen generated_host_exports.rs
generated_host_exports.rs: 120 exports (host=5, lazy=10, generic=105, rust=0); 248 extern-C blocks audited
[2/113] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-3.cpp.o
[3/113] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[4/113] cxx obj/src/jsc/bindings/napi.cpp.o
[5/113] cxx obj/unified/UnifiedSource-src_jsc_bindings-3.cpp.o
[6/113] cxx obj/src/jsc/bindings/bindings.cpp.o
[7/113] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-2.cpp.o
[8/113] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[9/113] cxx obj/unified/UnifiedSource-src_jsc_bindings-2.cpp.o
[10/113] cxx obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o
[11/113] cxx obj/codegen/ZigGeneratedClasses.cpp.o
[12/113] cxx obj/src/jsc/bindings/BunObject.cpp.
... (truncated)
diff hotspot
src/runtime/server/RequestContext.rs           |   8 +-
 test/js/bun/http/serve-reused-response.test.ts | 174 +++++++++++++++++++++++++
 2 files changed, 181 insertions(+), 1 deletion(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                            reads  edits  tests
src/runtime/server/RequestContext.rs               13      8     33
test/js/bun/http/serve-reused-response.test.ts      0      0     33

… dropped

`cancel_unread_body` runs for the Response of a request that the server
does not answer: the client left, the idle timer closed the connection,
the request became a WebSocket, or the server stopped. It stored
`Body::Value::Used` for every kind of body. A Response without a body
that the handler keeps was then refused with ERR_BODY_ALREADY_USED on
every later request.

A null or empty body has nothing to release, so it now stays as it is.
A body with content and a stream are given up as before.
@robobun

robobun commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Status

How I reproduced it: a handler returns one kept new Response(null, { status: 404 }) for every request. One request is dropped, either by server.upgrade(req) followed by return kept, or by a client that leaves while the handler awaits. Every later GET then answers 500 with ERR_BODY_ALREADY_USED. It is right on 1.3.13. It is wrong on 1.4.2 and on main (620b50f).

With this PR the later GETs answer 404 again and bodyUsed stays false. test/js/bun/http/serve-reused-response.test.ts: 12 pass and 4 fail with src/ at main, 16 pass with this change.

@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: a755223c-60ce-4173-9e1a-489b999fd69c
📥 Commits

Reviewing files that changed from the base of the PR and between 620b50f and 630f9dd.

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

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.


Walkthrough

cancel_unread_body now preserves Used state for response bodies only when the body is neither Null nor Empty. Tests cover bodyless response reuse and used-state handling for responses with content or streams.

Changes

Response body reuse

Layer / File(s) Summary
Body state and reuse checks
src/runtime/server/RequestContext.rs, test/js/bun/http/serve-reused-response.test.ts
cancel_unread_body preserves Null and Empty bodies. Tests cover reuse after client aborts, WebSocket upgrades, and server.stop(true), plus used state and stream cancellation for responses with content.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 630f9

The bodyless-response reuse change is mergeable after normal checks. The separately reported response-reuse limitations remain outside its scope.

🚥 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 clearly and concisely describes the main change: bodyless Responses remain unused when their request is dropped.
Description check ✅ Passed The description explains the problem, fix, scope, limitations, and verification results. It does not use the exact template headings, but it contains the required information and is substantially comp…
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@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 reviewed this PR and didn't find any bugs. Because it changes the observable body state of a dropped Response in the Bun.serve lifecycle and the description names three open PRs that rewrite the same statement in cancel_unread_body, a human look to settle the ordering with those is still worthwhile.

What was reviewed:

  • The guard at src/runtime/server/RequestContext.rs:761 — Null and Empty own nothing the Used store would release, and do_render_with_body renders both as an empty body, so a kept bodiless Response answers again; Error, string, Blob and stream bodies still hit the Used arm.
  • The other Body::Value::Used stores in the file (release_body_stream, the stream-resolve teardown, the Locked render arm) only apply to streamed or rendered bodies, so no sibling site needs the same condition.
  • The new tests await the abort/close/open events, wire WebSocket onerror to reject, cover sync and async handlers plus abort, upgrade and stop(true), and keep a negative-contract case for bodies with content.
Extended reasoning...

The change is a six-line condition in cancel_unread_body in src/runtime/server/RequestContext.rs plus four new tests in test/js/bun/http/serve-reused-response.test.ts; it touches no security-sensitive surface. The guard restores pre-1.4.1 behavior for bodiless Responses and matches the Fetch spec, where bodyUsed is false for a null body. No CODEOWNERS entry covers the changed paths and the bug hunt ran dry. Deferring rather than approving because the PR alters user-visible server lifecycle semantics introduced by a recent fix (#41011) and the author lists three open PRs that rewrite the same statement, so a maintainer should decide merge order and whether the bodyUsed change is the intended contract.

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.

2 participants