Skip to content

Bun.serve: settle the request body stream when the response ends after req.clone() - #42017

Merged
Jarred-Sumner merged 4 commits into
mainfrom
robobun/9d823229/serve-clone-unread-body-leak
Sep 8, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
robobun/9d823229/serve-clone-unread-body-leak

Conversation

@robobun

@robobun robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A Bun.serve handler that calls req.clone() and reads neither body leaks about 7.3 KB per request, independent of body size. Full GC keeps it.
  • The root is the protect()ed pull promise of the request body's ByteStream. end_request_streaming (src/runtime/server/RequestContext.rs) reached that stream only through the body Value, and clone() re-points Locked.readable at a tee branch. It aborted that reader-less branch (a no-op) and returned before erroring the source the context holds in request_body_readable_stream_ref. finalize_without_deinit then dropped that ref silently.

Fix

  • end_request_streaming rejects a Locked body as before, then errors and releases the ByteStream behind request_body_readable_stream_ref whenever that stream has not already ended. finalize_without_deinit leaves the ref to that call.
  • Correct because the context is the stream's only producer and nothing feeds it once request streaming ends. Erroring the source settles the parked pull and errors both branches, so a pending clone read rejects with the AbortError an un-cloned read already gets.
  • Self-reviewed: 2 concerns raised, 2 addressed (a guard so the non-clone paths do not deliver a second error, and a test for the client-abort path).
  • Verified: three new tests in test/js/web/fetch/body-clone.test.ts fail on 1.4.3 and with src/ reverted, and pass with the fix.

Background

  • req.body, clone() and textStream() wrap an incoming body in a native ByteStream source that the RequestContext feeds from the socket.
  • A pull with nothing buffered parks. Its promise stays protect()ed until the producer settles it, and it roots the whole tee.
  • clone() tees that stream and each branch pulls at once. A synchronous handler's response ends before uWS delivers the body bytes, and detach_response stops reading them. So only end_request_streaming can settle that pull.
Notes
  • The has_received_last_chunk guard keeps the non-clone paths byte-identical. There, to_error_instance reaches the same ByteStream through the body Value and errors it first, and the context still holds its ref. ByteStream::on_data does tolerate a repeated AbortReason (the done arm returns early, a stored Err is a plain enum value), but the fix does not want to depend on that.
  • Repro from the report on release 1.4.3, 20k requests, req.clone() only: 4000:+40MB 8000:+69MB 12000:+97MB 16000:+123MB 20000:+149MB => ~7811 B/request. no-clone, clone-consume-clone and clone-consume-original plateau. heapStats() per leaked request: +3 ReadableStream, +3 ReadableStreamDefaultController, +1 ReadableStreamDefaultReader, +1 BytesInternalReadableStreamSource, +1 NativeStreamSourceAdapter, +1 ReadRequest, +1 StreamTeeState, +1 Uint8Array, +3 Promise, +3 FullPromiseReaction; protectedObjectTypeCounts: +1 Promise, +1 Uint8Array. That graph is exactly what the protected pull promise reaches.
  • Why the body size does not matter: the response of a synchronous handler ends inside uWS's request callback, before uWS delivers the body bytes of the same packet, and detach_response() clears the body handler. The bytes are never buffered; only the tee machinery is retained.
  • req.body.tee() done by hand did not leak: the body Value still pointed at the native stream, so to_error_instance errored the ByteStream directly. Only the native clone paths (Request.prototype.clone, BunRequest clone, with or without .body observed first) re-point the body at a branch. The first new test covers all three.
  • Two tests pin the observable behaviour of a clone().text() started in the handler, for a body the client never sends: it rejects when the response ends first, and when the client disconnects while the handler is still parked. Before, both stayed pending forever. req.text() without a clone already rejects in both cases.
  • finalize_without_deinit is the first place that sees the held ref when on_abort takes the is_dead_request() shortcut with a Used (textStream()) body. Dropping the ref there left that read pending too; it now goes through the same erroring path thirty lines later.
  • The fetch client's Response.clone() with nothing read does not leak (checked separately, 500 iterations, zero retained streams).
  • Suites run on the debug ASAN build: test/js/web/fetch/body-clone.test.ts (65 pass), test/js/bun/http/serve-body-leak.test.ts (15 pass, HTTP/1 and HTTP/2), test/js/bun/http/serve.test.ts (295 pass; 2 failures that also fail on the release binary in this container: root port range, /bun:info loopback), serve-http2-lifecycle.test.ts, serve-pending-promise-abort-leak.test.ts, bun-serve-routes.test.ts, bun-serve-body-json-async.test.ts, test/js/web/fetch/body.test.ts, body-stream.test.ts, body-mixin-errors.test.ts, wpt/textstream-wpt.test.ts, fetch-abort-stream-body.test.ts.
  • Related open PRs that touch the same function but not this bug: Bun.serve: keep delivering a request body that is still arriving after the response finishes #33524 (keep delivering a late body after the response), Bun.serve: release the request body slot once the body is complete #39660 (release the body slot once complete).

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

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/web/fetch/body-clone.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/162] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 242 extern-C blocks audited
[2/162] gen cpp.rs (cppbind)
[2/162] cargo bun_runtime → libbun_runtime.a
FAILED: rust-target/x86_64-unknown-linux-gnu/debug/libbun_runtime.a 
/workspace/bun/build/release/bun /workspace/bun/scripts/build/stream.ts rust --console --cwd=/workspace/bun --env=CARGO_TERM_COLOR=always --env=BUN_CODEGEN_DIR=/workspace/bun/build/debug/codegen --env=CC=/usr/lib/llvm-21/bin/clang --env=CXX=/usr/lib/llvm-21/bin/clang++ --env=AR=/usr/lib/llvm-21/bin/llvm-ar --env=CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/usr/lib/llvm-21/bin/clang++ --env=CARGO_HOME=/root/.cargo --env=RUSTUP_HOME=/root/.rustup --env=RUSTUP_TOOLCHAIN=nightly-2026-07-20 --env=CARGO_PROFILE_RELEASE_LTO=off --env=CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 --env=CARGO_PROFILE_RELEASE_DEBUG_ASSERTIONS=true --env=CARGO_ENCODED_RUSTFLAGS='-Crelocation-mod
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (427e0a08e)

test/js/web/fetch/body-clone.test.ts:
(pass) Request with streaming body can be cloned [0.30ms]
(pass) Response with streaming body can be cloned [0.15ms]
(pass) Request with large streaming body can be cloned [5.63ms]
(pass) Request with large streaming body can be cloned (pull) [4.43ms]
(pass) Response with chunked streaming body can be cloned [30.88ms]
(pass) Request with streaming body can be cloned multiple times [0.33ms]
(pass) Request with string body can be cloned [0.11ms]
(pass) Response with string body can be cloned [0.07ms]
(pass) Request with ArrayBuffer body can be cloned [0.15ms]
(pass) Response with ArrayBuffer body can be cloned [0.09ms]
(pass) Request with Uint8Array body can be cloned [0.11ms]
(pass) Response with Uint8Array body can be cloned [0.07ms]
(pass) Request with mixed body types can be cloned [0.30ms]
(pass) Response with mixed body types can be cloned [0.21ms]
(pass) Request with non-ASCII string body can be cloned [0.10ms]
(pass) Response with non-ASCII string body can be cloned [0.06ms]
(pass) Request with streaming non-ASCII body can be cloned [0.12ms]
(pass) Response with streaming non-ASCII bod
... (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/web/fetch/body-clone.test.ts
bun test v1.4.3 (f42e98025)

test/js/web/fetch/body-clone.test.ts:
(pass) Request with streaming body can be cloned [37.67ms]
(pass) Response with streaming body can be cloned [12.72ms]
(pass) Request with large streaming body can be cloned [25.39ms]
(pass) Request with large streaming body can be cloned (pull) [32.80ms]
(pass) Response with chunked streaming body can be cloned [51.79ms]
(pass) Request with streaming body can be cloned multiple times [14.25ms]
(pass) Request with string body can be cloned [8.76ms]
(pass) Response with string body can be cloned [7.99ms]
(pass) Request with ArrayBuffer body can be cloned [11.92ms]
(pass) Response with ArrayBuffer body can be cloned [9.39ms]
(pass) Request with Uint8Array body can be cloned [9.30ms]
(pass) Response with Uint8Array body can be cloned [8.50ms]
(pass) Request with mixed body types can be cloned [22.29ms]
(pass) Response with mixed body types can be cloned [20.63ms]
(pass) Request with non-ASCII string body can be cloned [7.49ms]
(pass) Res
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 812ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/123] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 242 extern-C blocks audited
[2/123] gen cpp.rs (cppbind)
[2/123] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v0.0.0 (/workspace/bun/src/
... (truncated)
diff hotspot
src/runtime/server/RequestContext.rs |  49 ++++++------
 test/js/web/fetch/body-clone.test.ts | 141 +++++++++++++++++++++++++++++++++++
 2 files changed, 164 insertions(+), 26 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                  reads  edits  tests
src/runtime/server/RequestContext.rs      9      5     19
test/js/web/fetch/body-clone.test.ts      6      6     19

…r req.clone()

A handler that called req.clone() and returned without reading either
body left the request body's native ByteStream with an unsettled pull.
The pull promise stays protected until the producer settles it, and it
roots both tee branches, the tee reader and every controller, so each
such request leaked about 7 KB that no GC could reclaim.

end_request_streaming() only reached the ByteStream through the body
Value. clone() re-points Locked.readable at a tee branch, so the abort
went to a branch with no reader (a no-op) and the early return skipped
the context's own reference to the source, which finalize then dropped
without erroring it. The context now always errors the ByteStream it
feeds when request streaming ends, whatever the body Value looks like.
A read pending on either branch rejects with the same AbortError an
un-cloned body read gets, instead of never settling.
- end_request_streaming only errors the ByteStream when it has not
  already received its last chunk, so the ordinary req.body paths where
  to_error_instance errored the same stream do not deliver a second
  error.
- The leak test reports the fixture's stderr and exit code together when
  it fails instead of asserting stderr is empty.
- New test: a read parked on the clone's branch also rejects when the
  client disconnects while the handler is still parked.
@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 12 days. After that, they cost $0.25 per reviewed file.

Or wait 11 minutes for your next included review.

Check out review usage here.

View limit details

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: c7d53af1-f3cb-4000-8ae2-48f628fe1a6b

📥 Commits

Reviewing files that changed from the base of the PR and between d745f03 and 759d592.

📒 Files selected for processing (2)
  • src/runtime/server/RequestContext.rs
  • test/js/web/fetch/body-clone.test.ts

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

@github-actions github-actions Bot added the claude label Sep 8, 2026
@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:40 AM PT - Sep 8th, 2026

❌ @robobun, your commit 759d592 has 2 failures in Build #113032 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42017

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

bun-42017 --bun

@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. All review threads are resolved.

How I reproduced it, on release bun 1.4.3:

// bun leak.mjs
const N = 20000;
const server = Bun.serve({ port: 0, hostname: "127.0.0.1",
  fetch(req) { req.clone(); return new Response("k"); } });
const rss = () => (process.memoryUsage().rss / 1048576) | 0;
Bun.gc(true); const base = rss();
for (let i = 1; i <= N; i++) {
  await (await fetch(server.url, { method: "POST", body: "x".repeat(100) })).text();
  if (i % 4000 === 0) { Bun.gc(true); await Bun.sleep(30); Bun.gc(true); console.log(i, "+" + (rss() - base) + "MB"); }
}
server.stop(true); process.exit(0);

4000:+40MB 8000:+69MB 12000:+97MB 16000:+123MB 20000:+149MB, about 7.8 KB per request, linear, and it survives two full GCs. heapStats() shows the retained graph per request: 3 ReadableStream, 3 controllers, 1 reader, 1 StreamTeeState, 1 BytesInternalReadableStreamSource, and one protected Promise plus one protected Uint8Array. The protected pull promise is the single root that reaches all of it.

Tests: three new cases in test/js/web/fetch/body-clone.test.ts. All three fail on 1.4.3 and on a debug build with src/ reverted, and pass with the fix.

CI (build 113032 on 759d592): the new tests and every Bun.serve/fetch suite pass on all lanes. The two red jobs are unrelated to this diff: test/js/node/test/parallel/test-crypto-dh-leak.js on x64-asan also fails on main, and test/cli/install/bun-patch.test.ts failed on Windows 2019 x64 only (a bun patch test; it passed on retry in the previous build of this PR).

@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 nit, I also checked whether the ? on body.to_error_instance(...) at RequestContext.rs:2423 can now leak request_body_readable_stream_ref (since finalize_without_deinit no longer deinits it directly) — to_error_instance with a CommonAbortReason builds a DOMException without entering user JS, so that early-exit path is not reachable in practice, and the callers that discard the result continue teardown regardless.

Extended reasoning...

The one confirmed finding is a test-hygiene nit (unbounded poll loop). The additional concern worth recording for a human reviewer is the interaction between the removed .deinit() in finalize_without_deinit and the ? early-return inside end_request_streaming: if to_error_instance could fail, the strong stream ref would be neither errored nor released on that path. This was examined and ruled out because the AbortReason(ConnectionClosed) branch constructs the error without calling into user-observable JS, so the JsResult cannot be Err here; and even on the discarded-Err path, finalize_without_deinit continues past the call. The core lifecycle change — moving the release of request_body_readable_stream_ref from finalize_without_deinit into end_request_streaming and always settling the ByteStream — touches request-teardown ordering in Bun.serve, so a human look is still appropriate; deferring rather than approving.

Comment thread test/js/web/fetch/body-clone.test.ts Outdated
Comment thread src/runtime/server/RequestContext.rs Outdated
Comment thread src/runtime/server/RequestContext.rs Outdated
Comment thread src/runtime/server/RequestContext.rs Outdated
Comment thread src/runtime/server/RequestContext.rs Outdated
Comment thread src/runtime/server/RequestContext.rs Outdated
Comment thread src/runtime/server/RequestContext.rs Outdated
@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed two follow-ups: a65d3c3 bounds the handler-reached poll in the client-disconnect test and disposes the client socket with using (the review nit), and 759d592 shortens the new comments in RequestContext.rs to one line each. No behaviour change; the three new tests still pass on the debug build. CI is running on 759d592.

@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 found no issues

No high-confidence issues detected in this change.

@Jarred-Sumner
Jarred-Sumner merged commit 8afe3cb into main Sep 8, 2026
10 of 11 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/9d823229/serve-clone-unread-body-leak branch September 8, 2026 21:26
Jarred-Sumner pushed a commit that referenced this pull request Sep 9, 2026
…nstead of ending it (#42125)

### Problem
- After `req.clone()` in `Bun.serve`, or `res.clone()` on a `fetch()`
response, a reader on the original body ends with `{ done: true }` when
the body fails mid-stream. 100 KB of an announced 160 KB reads as
complete. The clone, and an un-cloned body, reject.
- Cause: `Body::Value::to_error_instance`
(`src/runtime/webcore/Body.rs:1366`) errors a native `ByteStream` but
cancels any other stream. After `clone()` the body holds a tee branch,
and cancel closes it.

### Fix
- `to_error_instance` calls `ReadableStream::error()` with the body's
error instead. Every caller is a producer that reports a failed body, so
none wants a clean end.
- The streamed `maxRequestBodySize` arm in `RequestContext.rs` now
rejects through the body first, as the buffering arm does. Otherwise the
original's branch got `The connection was closed.` and the clone got
`Request body exceeded maxRequestBodySize`.
- Self-reviewed: 2 concerns raised, 1 addressed (the parity fix above),
1 declined (see Notes).
- Verified: six new tests in `test/js/web/fetch/body-clone.test.ts`. The
three original-side tests fail on main `b5ba14b6` and pass here.
Neighbouring suites stay green (list in Notes).

### Background
- A streamed body is `Value::Locked` and holds a `ReadableStream`. For
an incoming request or a fetch response its source is a native
`ByteStream`.
- `clone()` tees that stream: the body keeps branch 1, the clone gets
branch 2. When the body fails, the server or client errors the source.
The tee forwards that to both branches, one microtask after
`to_error_instance` ran on branch 1.
- `cancel()` is the consumer-side end: pending reads resolve `done:
true`. `error()` is the producer-side failure: pending reads reject. The
fetch abort listener already uses it (#35093).

<details><summary>Notes</summary>

- Found while re-checking the `req.clone()` abort work from #42017. That
PR settles the native source so the clone's branch rejects. The
original's branch still went through the `abort()` arm here.
- Callers of `to_error_instance`:
`RequestContext::end_request_streaming` and the two `maxRequestBodySize`
arms, `FetchTasklet` on failure, the fetch `AbortSignal` listener in
`Response.rs`, `HTMLRewriter` `fail()`.
- Repro on main `b5ba14b6` (release and debug+ASAN), 102400 of 163840
bytes delivered, then the peer goes away. `Bun.serve` + `req.clone()`,
reader on `req.body`: `done:true` at 102400. `fetch()` + `res.clone()`,
reader on `res.body`: `done:true` at 102400. Reading the clone instead,
or not cloning: rejects (`AbortError: The connection was closed.` /
`ECONNRESET`). With the fix all of them reject. A `fetch()` aborted
through its `AbortSignal` already rejected on both sides (the abort
listener errors the branch itself).
- The six tests: serve client disconnect, serve chunked upload over
`maxRequestBodySize`, fetch server disconnect, each for the original and
the clone. The clone-side cells pass on main too and pin the symmetry.
- In the streamed cap arm the byte stream is errored through the held
ref only when the body did not already reach it
(`has_received_last_chunk`), the same guard `end_request_streaming` uses
since #42017, so the un-cloned path does not see a second error.
- `HTMLRewriter`: `transform(res).clone()` with a failing input already
rejected on both sides before this change, because `fail()` errors the
output `ByteStream` directly and the tee forwards it. No change there.
- Declined review concern: rename `ReadableStream::abort()` (which
cancels) at its three remaining callers. It stays. Those callers
(`FetchTasklet::start_request_stream` on an already-aborted signal,
`RequestContext::on_abort` for the response body,
`FileSink::handle_reject_stream`) are consumers cancelling their source,
which is what cancel is for.
- Erroring a tee branch from outside the tee is safe:
`readableStreamDefaultControllerEnqueue`/`Close` check
`CanCloseOrEnqueue` and `readableStreamDefaultControllerError` returns
early on a non-readable stream, so the tee's later chunk, close, and
error steps for that branch are no-ops. `ReadableStream__error` goes
through `webStreamControllerError`, which is a no-op on a closed or
errored stream.
- On the server disconnect path the original and the clone reject with
two distinct `AbortError` objects (one from the body error, one from the
source error through the tee). That was already true for a pending
`.text()` on the original versus a read on the clone.
- Suites run on the debug+ASAN build: body-clone (85), html-rewriter
(180), serve-body-leak (15), http-server-chunking (13),
serve-http2-lifecycle (23), fetch-file-upload (11),
fetch-abort-stream-body, body-mixin-errors, textstream-wpt,
serve-pending-promise-abort-leak (27), regression/22353, `serve.test.ts
-t "request body|streaming|abort|clone|maxRequestBodySize"`.
- Local-only failures seen while running neighbouring suites, identical
on the unfixed release build in this container: `fetch.stream.test.ts`
"Content-Length response works (multiple parts)" (5 s timeouts when the
eight variants run concurrently under debug+ASAN, 0.8 s alone),
`express-memory-leak.test.ts` (20 s budget, the body-less variant alone
takes 19.9 s here), and `fetch.test.ts` "abort should work even if the
socket was closed before the redirect" (connects to `[::]`, which this
container's egress proxy refuses).

</details>

<!-- robobun:evidence:begin -->

---

**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/web/fetch/body-clone.test.ts

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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