Skip to content

Bun.serve: keep delivering a request body that is still arriving after the response finishes - #33524

Closed
robobun wants to merge 6 commits into
mainfrom
farm/eca3d6c3/request-body-after-response
Closed

robobun wants to merge 6 commits into
mainfrom
farm/eca3d6c3/request-body-after-response

Conversation

@robobun

@robobun robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Repro

// bun repro.mjs
import * as net from "node:net";
const sleep = ms => new Promise(r => setTimeout(r, ms));

const events = [];
const srv = Bun.serve({
  port: 0, hostname: "127.0.0.1",
  fetch(req) {
    // body consumption STARTED synchronously, inside the handler:
    req.text().then(
      t => events.push(`text() resolved len=${t.length}`),
      e => events.push(`text() REJECTED ${e.name}: ${e.message} (req.signal.aborted=${req.signal.aborted})`),
    );
    return new Response("early"); // respond BEFORE the body is consumed
  },
});

// one keep-alive socket: POST with the FULL 12-byte body in the SAME packet as
// the headers, then a second request to prove the connection survived.
const s = net.connect(srv.port, "127.0.0.1");
s.on("error", () => {});
let out = ""; s.on("data", d => (out += d));
await new Promise(r => s.on("connect", r));
s.write("POST /late HTTP/1.1\r\nHost: x\r\nContent-Length: 12\r\n\r\nHELLO-WORLD!");
await sleep(250);
s.write("GET /next HTTP/1.1\r\nHost: x\r\n\r\n");
await sleep(250); s.end();

console.log("responses on the one connection =", (out.match(/HTTP\/1\.1 200/g) || []).length);
console.log("events:", JSON.stringify(events));
srv.stop(true);

Before:

responses on the one connection = 2          <- the connection stayed ALIVE
events: ["text() REJECTED AbortError: The connection was closed. (req.signal.aborted=false)"]

After:

responses on the one connection = 2
events: ["text() resolved len=12"]

The body had fully arrived, the client never aborted, req.signal.aborted is false, and the socket went on to serve the next keep-alive request. The "respond 202 immediately, ingest the upload in the background" pattern lost the body every time, with an error indistinguishable from a real client disconnect.

Cause

Response completion was treated as request completion.

  • uWS's HttpResponseData::markDone() (run by end()/tryEnd()) clears inStream, so no further request-body bytes can ever reach the handler once the response is out.
  • RequestContext::end_request_streaming() then rejects a still-Locked request body on every response-finalize path, unconditionally, with CommonAbortReason::ConnectionClosed.

A pending read therefore only resolved when the last body chunk happened to land before the response finalized — which is why the same req.text() resolves fine behind a streaming response that is still open when the bytes arrive.

Fix

  • uWS: add an opt-in HttpResponseData::keepRequestBodyOnDone. When set, markDone() leaves inStream and onAborted armed instead of clearing them. It is reset per request in HttpContext::onData, and once the parser sees the body's last chunk, so a stale flag can never keep handlers pointing at a freed context.
  • RequestContext sets it (and arms onAborted) the moment a consumer asks for the body — .text(), .json(), req.body, … — while the body is still arriving.
  • When the response finishes first, the context now parks instead of tearing the body down: it keeps resp and the body handler, skips end_request_streaming(), and hands its base ref to the body callback. The last chunk releases it; detach_response() disarms everything on every other exit.
  • A peer that genuinely vanishes mid-upload still rejects the read with AbortError: The connection was closed. — now reported by the abort handler that parking keeps armed, rather than guessed at from response completion.

Delivering a chunk runs user JS, which drains microtasks and can observe a socket close (and drop the last ref), so the body callback holds a ref across the delivery. Without it the new path is an ASan use-after-poison.

HTTP/3 is unchanged: the QUIC stream carries the body and is torn down with the response, so parking is gated on !HTTP3.

Verification

test/js/bun/http/serve-request-body-after-response.test.ts covers the body arriving in the same packet as the headers, arriving after the response was sent, a streamed req.body, and a client that disconnects mid-upload (still rejects, and server.pendingRequests returns to 0 in all cases). The first three fail on main with AbortError: The connection was closed.

serve.test.ts's "should resolve pending promise if requested ended with pending read" asserted the old AbortError; it now asserts the read resolves with the bytes. Its point — that the promise settles rather than hanging — is unchanged.

Suites run against the debug+ASan build (failures identical to main / the 1.4.0 release binary)
suite result
test/js/bun/http/serve.test.ts 242 pass, 4 fail — all 4 also fail on the release binary (IPv6 requestIP, root-range port, bun:info loopback, #6583)
test/js/bun/http/ (dir) failures byte-identical to main's debug build
test/js/node/http/ 223 pass / 5 fail on both main and this branch
test/js/bun/websocket/ green on release; the 10s-timeout flakes under ASan differ run to run

Not covered here

bun's node:http server has the same defect with a different face: the identical pattern emits end with zero data events (real node delivers the bytes). That one additionally needs the IncomingMessage readable to be allowed to flow after res.end() — NodeHTTPResponse decides "nobody is reading the body" synchronously inside end(), before _read() has run on its nextTick — so it is a separate change on top of the keepRequestBodyOnDone mechanism this PR adds. Happy to do it as a follow-up.

…r the response finishes

A request-body read that was still pending when the response finished was
force-rejected with `AbortError: The connection was closed.`, even when the
body had fully arrived, the client had not aborted, `req.signal.aborted` was
false, and the keep-alive connection went on to serve the next request. Any
"respond 202 now, ingest the upload in the background" handler silently lost
its body, with an error indistinguishable from a real client disconnect.

Response completion is not request completion. uWS's `markDone()` cleared
`inStream`, so no further body bytes could reach the handler, and
`end_request_streaming()` then rejected the pending consumer on every
response-finalize path.

Add an opt-in `keepRequestBodyOnDone` to uWS that keeps the request-body and
abort handlers armed past `markDone()`. Bun sets it when a consumer asks for
the body (`.text()`, `.json()`, `req.body`, ...) while the body is still
arriving, and parks the request context when the response finishes first:
the body keeps flowing to its consumer, and the context is released once the
last chunk is delivered. The body read is still rejected with the
connection-closed AbortError when the peer actually goes away mid-upload,
which the now-armed abort handler reports.

Delivering a chunk runs user JS, which drains microtasks and can observe a
socket close, so the body callback holds a ref across the delivery.
@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:23 PM PT - Jul 6th, 2026

❌ @robobun, your commit dc6da69 has some failures in Build #69464 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33524

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

bun-33524 --bun

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun.write with new Response(req.body) hangs #13237 - Bun.write with new Response(req.body) hangs because the response completing kills the request body stream before it can be consumed

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

Fixes #13237

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 35 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: a24875fe-fc0a-426b-945c-2f5c296d8604

📥 Commits

Reviewing files that changed from the base of the PR and between 198bf77 and dc6da69.

📒 Files selected for processing (2)
  • packages/bun-uws/src/HttpContext.h
  • test/js/bun/http/serve-request-body-after-response.test.ts

Walkthrough

This PR adds a keepRequestBodyOnDone mechanism spanning uWS C++ internals, FFI bindings, and Rust RequestContext logic, allowing request bodies to continue being delivered after an HTTP response has been sent. It introduces new draining/parking state, flag bits, helper methods, and tests validating post-response body delivery and abort handling.

Changes

Keep Request Body On Done

Layer / File(s) Summary
uWS keepRequestBodyOnDone flag and lifecycle
packages/bun-uws/src/HttpContext.h, packages/bun-uws/src/HttpResponse.h, packages/bun-uws/src/HttpResponseData.h
Adds keepRequestBodyOnDone member and setKeepRequestBodyOnDone setter; markDone() and clearOnWritableAndAborted() conditionally preserve inStream/onAborted; flag reset per request and on body completion.
FFI bridge for setKeepRequestBodyOnDone
src/uws_sys/libuwsockets.cpp, src/uws_sys/Response.rs
Exposes uws_res_set_keep_request_body_on_done as a C-ABI wrapper and adds Rust Response/AnyResponse methods and FFI binding.
RequestContext flags and draining state machine
src/runtime/server/RequestContext.rs
Widens FlagsBits to u32, adds HAS_BODY_ABORT_HANDLER/IS_DRAINING_REQUEST_BODY flags with accessors, and introduces helpers (park_for_request_body_drain, detach_response_after_complete, finish_request_body_stage, etc.) to keep the context alive while draining.
Response end paths honor parking
src/runtime/server/RequestContext.rs
Updates end, end_stream, end_already_responded_stream, end_without_body, render_bytes, try_end branches, and do_sendfile to park teardown instead of immediately detaching/dereferencing when a body consumer is pending.
Request body consumption paths recognize draining
src/runtime/server/RequestContext.rs
Updates on_buffered_body_chunk, on_start_streaming_request_body, on_start_buffering, and on_request_body_readable_stream_available to mark consumers and finish parked stages instead of running normal abort/413 handling.
Tests for post-response body delivery
test/js/bun/http/serve-request-body-after-response.test.ts, test/js/bun/http/serve.test.ts
Adds a new test suite validating body delivery, streaming, disconnect handling, and keep-alive behavior after response completion; updates an existing test to expect a successful pending read rather than an AbortError.

Possibly related PRs

  • oven-sh/bun#32120: Modifies the same HttpResponse.h/HttpResponseData.h callback teardown logic (clearOnWritableAndAborted, markDone, writableUserData) affected in this PR.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: continuing request-body delivery after the response finishes.
Description check ✅ Passed The description covers what the PR does and how it was verified with repro steps and test coverage.
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.

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

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Checked #13237 against this branch: it does not fix it, so I'm leaving the Fixes line out.

That repro awaits the write before responding, so the response never completes early and the parking path here is never reached:

async fetch(req) {
  await Bun.write("test.txt", new Response(req.body)); // hangs here
  return new Response("ok");                           // never gets this far
}

It still hangs on this branch:

$ ./build/debug/bun-debug issue13237.mjs
HANG (timed out)

The body stream is alive and unconsumed the whole time the handler is blocked; the hang is on the Bun.write side, reading a Response whose body is a ReadableStream. #32906 looks like the one that covers it.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Comment thread src/runtime/server/RequestContext.rs
Comment thread packages/bun-uws/src/HttpResponseData.h
Comment thread packages/bun-uws/src/HttpContext.h
Comment thread test/js/bun/http/serve-request-body-after-response.test.ts Outdated
…uest body

Three fixes from review, all on the parking path:

`clearOnWritableAndAborted()` nulls `onAborted` unconditionally, and the
end/tryEnd/sendTerminatingChunk shims all call it, so it undid markDone()'s
`keepRequestBodyOnDone` preservation. A parked context was left with no abort
handler on the socket: a peer that disconnected mid-upload was never reported,
the pending read never settled, and the context leaked. The sync-handler tests
missed it because `to_async()` re-arms `onAborted` right after the response
renders; an async handler renders from a later microtask and nothing re-arms.
Honour `keepRequestBodyOnDone` there too, and cover sync / async /
streaming-response handlers in the tests.

`park_for_request_body_drain()` read `resp` through `should_close_connection()`
before checking `has_body_abort_handler()`, which is the only proof the socket
is still alive. `end_already_responded_stream()` documents that `resp` may
already be freed, so the guards have to run in the other order.

uws disarms the idle timeout when it delivers the body's last chunk, expecting
the response on its way out to re-arm it. A parked response already went out,
so nothing did: the keep-alive socket was left with no timeout and stuck at
`isIdle = false`, so neither the idle sweep nor closeIdleConnections() could
reap it. Re-arm in `finish_request_body_drain()`, and restore `isIdle` when the
body is no longer kept and the response is done.

Also wire the test client's error/close events to reject its pending waiters,
so a server-side regression fails with the socket error rather than a timeout.
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 198bf77 addressing all four review findings. Two of them were real bugs in the parking path, and one of those was a genuine miss in my test coverage, so summarising here:

clearOnWritableAndAborted() undid markDone()'s preservation. All three response-finishing shims (uws_res_end, uws_res_end_stream, uws_res_try_end) call it, and it nulled onAborted unconditionally — so a parked context had no abort handler, a peer that vanished mid-upload was never reported, the pending read never settled, and the context leaked. It now honours keepRequestBodyOnDone.

My tests missed it because they only used a sync handler, where to_async() runs right after the response renders and re-arms onAborted, masking the bug. An async handler renders from a later microtask and nothing re-arms. The tests now cover sync / async / streaming-response as a matrix; reverting just the guard hangs exactly the two lanes that were broken, and leaves the sync one passing.

Idle timeout + isIdle were left stale after a parked drain. uws disarms the timeout when it delivers the body's last chunk, expecting the outgoing response to re-arm it — but a parked response already went out. With idleTimeout: 1 and a split-packet body the socket was never reaped (STILL OPEN after 15s); now it closes in 3.9s. finish_request_body_drain() re-arms the timeout, and setKeepRequestBodyOnDone(false) restores isIdle once the response is no longer pending.

Guard ordering in park_for_request_body_drain() now puts the liveness proof before any resp dereference, and the test client rejects its waiters on error/close instead of hanging.

Verification
new file 8 pass / 0 fail under bun bd (debug + ASan)
fail-before 5 of the 8 fail on the released binary
load-bearing reverting the clearOnWritableAndAborted() guard hangs the async + streaming-response disconnect lanes
serve.test.ts 238 pass / 4 fail — the same 4 that fail on the released binary (IPv6 requestIP, root-range port, bun:info loopback, #6583)
test/js/node/http/ unchanged; keepRequestBodyOnDone is never set on that path. The one extra red line vs. my earlier run is short sends + client destroy…, which takes ~4.6-4.7s against a 5s limit under ASan and passes 3/3 with headroom

@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: 1

🤖 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 `@test/js/bun/http/serve-request-body-after-response.test.ts`:
- Around line 4-8: The top block comment is too long for the repo’s 3-line
limit; shorten the explanation in the request-body test while keeping the same
rationale. Update the comment near serve-request-body-after-response so it is
compressed into at most three lines, and apply the same trimming to the other
long comment referenced in the review, preserving only the essential context
about the pending read and live keep-alive upload behavior.
🪄 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: e04ef7a1-404f-48c5-ad89-fca28adf2951

📥 Commits

Reviewing files that changed from the base of the PR and between 48ff9eb and 198bf77.

📒 Files selected for processing (8)
  • packages/bun-uws/src/HttpContext.h
  • packages/bun-uws/src/HttpResponse.h
  • packages/bun-uws/src/HttpResponseData.h
  • src/runtime/server/RequestContext.rs
  • src/uws_sys/Response.rs
  • src/uws_sys/libuwsockets.cpp
  • test/js/bun/http/serve-request-body-after-response.test.ts
  • test/js/bun/http/serve.test.ts

Comment thread test/js/bun/http/serve-request-body-after-response.test.ts Outdated
Comment thread packages/bun-uws/src/HttpResponse.h
robobun added 2 commits July 6, 2026 20:28
Finishing a parked request body restores isIdle via setKeepRequestBodyOnDone,
and that can run mid-segment: when the body's last chunk and a pipelined next
request arrive together, the drain sets isIdle = true and then the next
request's lambda sets HTTP_RESPONSE_PENDING without clearing it. A following
closeIdleConnections() then closes the socket out from under the in-flight
request, whose later render corks a freed socket (heap-use-after-free in
us_socket_group). The same window already existed for a sync-handled request
pipelined ahead of an async one via markDone().

Clear isIdle where HTTP_RESPONSE_PENDING is set: a just-routed request is
in-flight by definition, so closeIdleConnections() must not see it as idle.

Verified with an ASan repro (body tail + pipelined GET in one segment, then
closeIdleConnections during the async handler's window). A hermetic bun:test
is omitted: the bug requires the tail and next request to land in a single
socket read, and the test runner's event loop perturbs that coalescing, so
the test would pass with and without the fix.
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: green diff, flaky unrelated lanes

The last two CI runs failed on entirely different, non-overlapping sets of tests, which is the signature of infrastructure/timing flake rather than a fault in this diff (a real regression fails the same test across runs):

  • Build 69315: fetch-http3-client, fs (existsSync tempdir), fetch-tls-abortsignal-timeout, inspect — 4 files.
  • Build 69464 (after ci: retrigger): bake/dev-and-prod, install/migration/complex-workspace, napi, postgres-binary-numeric, fetch.stream, spawn-pipe-leak, net-mongodb-pattern-leak, next-pages SSR, cron, node-http-connect — 12 files, none shared with the prior run.

None touch the Bun.serve request-body / uWS-teardown / parking path this PR changes. I verified the only HTTP-adjacent failures locally on the debug+ASan build:

  • node-http-connect: the 9 CONNECT tests that exercise the changed uWS code (Content-Length / Transfer-Encoding body delivery to the connect socket) all pass. The one red line, Should be compatible with node.js > tests should run on bun, is a 5s-timeout flake that fails identically on main.
  • fetch-http3-client (session-retired-on-GOAWAY) and fetch-tls-abortsignal-timeout: both pass locally; the H3 path is a no-op in this change (gated !HTTP3).

I've used my one re-roll already, so I'm not going to keep pushing ci: retrigger commits. The diff's own tests are green:

  • test/js/bun/http/serve-request-body-after-response.test.ts: 8/8 under bun bd (debug+ASan); 5/8 fail on the released binary (fail-before).
  • test/js/bun/http/serve.test.ts: 238 pass, the 4 reds are pre-existing env failures that also fail on the released binary.

Ready for a maintainer to re-run the flaky lanes or merge.

Jarred-Sumner pushed a commit that referenced this pull request Sep 8, 2026
…r req.clone() (#42017)

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

<details><summary>Notes</summary>

- 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: #33524
(keep delivering a late body after the response), #39660 (release the
body slot once complete).

</details>

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

---

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

<details><summary>fails on main (without fix)</summary>

```console
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 (427e0a0)

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)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
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 (f42e980)

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)
```

</details>

<details><summary>diff hotspot</summary>

```
src/runtime/server/RequestContext.rs |  49 ++++++------
 test/js/web/fetch/body-clone.test.ts | 141 +++++++++++++++++++++++++++++++++++
 2 files changed, 164 insertions(+), 26 deletions(-)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 1

<details><summary>evidence per changed file</summary>

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

</details>

<!-- robobun:evidence:end -->
@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-06, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
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.

1 participant