http: don't retry a fetch with a ReadableStream body on keep-alive disconnect#32798
Conversation
…sconnect
An idempotent request (PUT) with a ReadableStream body sent over a
reused keep-alive connection that the peer resets mid-upload was
transparently retried by HTTPClient::on_close. A stream body is consumed
as it is uploaded, so the replay is silently truncated, and the chunks
still queued by the JS side get flushed onto the retry's not-yet-opened
socket ahead of the request headers. That advances request_sent_len past
the length of the rebuilt header buffer and send_initial_request_payload
panics, aborting the whole process:
panic: range start index 1031 out of range for slice of length 172
Two changes:
- on_close: only take the idempotent retry when the body is
HTTPRequestBody::Bytes. Stream and Sendfile bodies are consumed as
they are written and cannot be replayed (Go's net/http and curl apply
the same rule). The request now fails with ECONNRESET like any other
non-retryable request.
- write_to_stream: park until request_stage is Body or ProxyBody.
start_() registers the still-connecting socket in the abort tracker
(needed for abort-during-connect), which let drain_queued_writes write
body bytes to it before the headers went out. The buffered data is
re-flushed by on_writable's Body arm once the headers are on the wire.
|
Updated 8:22 PM PT - Jun 26th, 2026
❌ @robobun, your commit 1865111 has 1 failures in 🧪 To try this PR locally: bunx bun-pr 32798That installs a local version of the PR into your bun-32798 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
Warning Review limit reached
More reviews will be available in 4 minutes and 19 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 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 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 see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughHTTPClient now only retries closed requests when the original body is reusable bytes, and ChangesHTTP retry and write gating
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/web/fetch/fetch-keepalive.test.ts`:
- Around line 76-83: Shorten the regression note in the fetch keepalive test to
fit the 3-line comment limit by removing repeated explanation and collapsing the
retry/stream/panic behavior into one concise summary. Keep the key points tied
to the keep-alive retry case in the fetch keepalive test and preserve the
mention of the panic in send_initial_request_payload, but rewrite it as a
compact comment that still explains the bug scenario and subprocess abort.
- Around line 111-115: The keepalive test is terminating the socket too early in
the stream upload flow, so it may not exercise a consumed ReadableStream body or
the queued body-write path. Tighten the logic in fetch-keepalive.test.ts around
the stream upload check so the reset happens only after full headers and at
least one body byte have been received, then increment the counter and call
socket.terminate() from that guarded path. Use the existing socket.data.buffer
handling in the PUT /stream branch to ensure the test fails for the right
reason.
🪄 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: 2d1095e5-a32c-4499-8d25-2764c1e13d46
📒 Files selected for processing (2)
src/http/lib.rstest/js/web/fetch/fetch-keepalive.test.ts
…ment Review feedback: the server now waits for the full request headers plus at least one body byte before resetting the connection, so the test provably exercises a ReadableStream body that has started being consumed. Also shortens the test's header comment to three lines.
|
Looked at the #23092 suggestion from the issue-finder bot and left it out of the description: it is a different crash. #23092 panics inside This PR's panic is two statements later in the same function, at Both CodeRabbit comments are addressed in d36fad9: the test server now waits for the full request headers plus at least one body byte before resetting the connection, so the ReadableStream body has provably started being consumed, and the test's header comment is trimmed to three lines. Re-verified the fail/pass split after the change: the unfixed release binary still hits the panic 3/3, and the debug build without the |
darwin-aarch64 test-bun failed on a buildkite artifact download timeout before running any test; no other job in build 65218 failed.
CI statusThe diff is done and the gate proof holds (fail-before on the unfixed binary, pass-after with the fix, 3/3 reruns each way). CI is red only on lanes my change cannot touch, and the same lanes are red on unrelated PRs built at the same time. I have already used one retrigger, so I am not pushing more; a maintainer can retry the three job groups from Buildkite or re-run the build. Build: https://buildkite.com/bun/bun/builds/65236 (head
The only other noise is the |
What does this PR do?
fetch()with an idempotent method (PUT) and aReadableStreambody, sent over a reused keep-alive connection that the peer resets mid-upload, aborts the whole Bun process:in
send_initial_request_payload(src/http/lib.rs), at&temporary_send_buffer[self.state.request_sent_len..]. Any upstream, proxy, or load balancer that resets a reused connection mid-upload can kill a Bun client this way.Repro (panics 4/4 on
bun 1.4.0and current main, exits 0 with this PR):repro.ts
Cause
HTTPClient::on_closetakes the idempotent retry regardless of the body type. AReadableStreambody is consumed as it is uploaded, so the retry replays a truncated body to begin with.start_()registers its still-connecting socket in the abort tracker (needed so an abort during a slow connect is honored). The JS side is still pushing chunks from the first attempt, sodrain_queued_writesfinds that socket andwrite_to_streamwrites a body frame to it beforeon_open/ the request headers. On loopback the TCP connect has already completed so the write succeeds andrequest_sent_lenadvances by the frame size.on_openthen runssend_initial_request_payload, which slices the freshly rebuilt 172-byte header buffer at index 1031.1031is exactly one chunked frame of the 1024-byte chunk (400\r\n+ 1024 +\r\n); switching the repro to 512-byte chunks moves the panic index to exactly 519, confirming the body frame lands before the headers.Fix
on_close: only take the idempotent retry when the body isHTTPRequestBody::Bytes.StreamandSendfilebodies are consumed as they are written and cannot be replayed; Go'snet/http(Request.isReplayable) and curl apply the same rule. The request now fails withECONNRESETlike any other non-retryable request. TheGET/bodyless retry behavior is unchanged (covered by the existingtest/regression/issue/28706.test.ts).write_to_stream: park untilrequest_stageisBodyorProxyBody, mirroring the existingupgrade_state == Pendingpark in the same function. Body bytes must never reach the socket ahead of the request line, andrequest_sent_lenstill indexes the header buffer at that point. The buffered data is re-flushed byon_writable'sBodyarm once the headers are out.How did you verify your code works?
New test in
test/js/web/fetch/fetch-keepalive.test.ts(spawns a subprocess since the bug aborts the process). Without the fix it fails on the debug build withstreamRequests: 8instead of4(every attempt retried) and on a release build it additionally hits the panic. With the fix it passes 3/3.test/regression/issue/28706.test.tsand the stream-body / duplex / proxy / redirect / fetch-upgrade suites still pass.