http(h3): do not fail a retried request twice when the reconnect fails - #42900
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review. Walkthrough
ChangesHTTP/3 retry failure handling
Suggested reviewers: Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to The HTTP/3 reconnect failure path now rejects each affected request once without retaining invalid stream ownership. No actionable merge-blocking risk remains. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline note, I also checked the two other ways connect could bite the retry caller: the true outcomes (pooled-session enqueue and the sync ConnectResult::Socket arm) do not dispatch a terminal result before returning, and the ConnectResult::Err arm's unregister is index-and-pointer checked, so the old session's own teardown in on_conn_close unregistering again is a no-op rather than a registry corruption.
Extended reasoning...
The core change is a three-line removal of the redundant fail_from_h2 after a false return from ClientContext::connect, which already fails the request via PendingConnect::fail_session and frees the owning AsyncHTTP. I read connect in ClientContext.rs:116-193 to confirm the ownership claim: the pooled-reuse path only enqueues, the sync socket path only stores the socket pointer, and only the Err arm dispatches a terminal failure. I also confirmed unregister (ClientContext.rs:195-206) tolerates a second call for an already-removed session. Not approving because an inline finding is posted and a further verified finding was dropped from the posted set; a human should weigh the ECONNREFUSED-vs-original-error reporting choice and the LD_PRELOAD-based test's CI robustness.
2 verified lower-impact observations (convention, logging or cleanup points) were not posted.
|
Updated 10:56 AM PT - Sep 16th, 2026
✅ @robobun, your commit a4e091918f42c9b332eb73ae539b7e763520f440 passed in 🧪 To try this PR locally: bunx bun-pr 42900That installs a local version of the PR into your bun-42900 --bun |
b111217 to
f6909ee
Compare
A stream that closes before any response header is retried on a fresh connection. When ClientContext::connect could not open one it tore the new session down through PendingConnect::fail_session, which calls fail_from_h2 on every request queued on it. That dispatch frees the AsyncHTTP the client is part of, and ClientSession::retry_or_fail then failed the same client again through the freed memory. connect now takes the request back off the session it queued it on before it fails that session, so a false return leaves the request on no session and the caller is the only one to fail it. The request keeps the error of the stream that closed, and the initial connect sites in start_ keep reporting ConnectionRefused.
f6909ee to
bc7d5ab
Compare
|
Reworked after review: the fix moved from the caller to the callee.
How it reproduces, in one test on the debug ASan build:
With Those runs were on The |
There was a problem hiding this comment.
I reviewed the updated push and didn't find any bugs; the earlier note about the dead DNS TTL knob is addressed in this version. Because the fix sits on the raw-pointer/refcount lifecycle of the h3 client, a maintainer familiar with that code should still take a look.
What was reviewed:
- Session refcount across the new
ConnectResult::Errarm:ClientSession::new(1) +enqueue(2) →detach(1) →fail_session's finalderef(0), so the session is freed exactly once anddetach's "never the last ref" precondition still holds. detach_withon a stream that has noqstreamyet (connect never returned a socket) — only unlinks frompending, nullsclient.h3, and frees theStream.- The three
connect()callers (start_x2 insrc/http/lib.rs,retry_or_fail) all still fail the client themselves onfalse, andfail_sessionnow findspendingempty, so no secondfail_from_h2on a freedAsyncHTTP. - Test follows the existing
LD_PRELOADshim precedent infetch-http3-syscall-fault.test.ts(Linux +ccgated,bunEnv.LD_PRELOADchained, pipes drained concurrently,signalCode === nullasserted).
Extended reasoning...
Overview
The PR changes one arm of ClientContext::connect() in /home/claude/bun/src/http/h3_client/ClientContext.rs: when the QUIC connect fails synchronously, the request's just-enqueued Stream is detached from the fresh session before unregister and PendingConnect::fail_session. Previously fail_session drained pending and called fail_from_h2 on the queued client, which dispatches and frees the AsyncHTTP; then every caller of connect() failed the same client again on false. The retry caller (ClientSession::retry_or_fail) has no defer_terminal_dispatch guard, so that was a genuine UAF. The test adds a Linux-only LD_PRELOAD shim that refuses the second UDP connect(2) probe so the retry's reconnect fails inside connect(), and asserts the fixture prints HTTP3HandshakeFailed then ECONNREFUSED and exits 0 without a signal.
Security risks
None specific to this change. It does not touch TLS validation, credential handling, or input parsing; it only reorders teardown so a request is failed once. The rejectUnauthorized: true in the test is used as a handshake-failure trigger, not weakened.
Level of scrutiny
Medium-high: the diff is five lines but sits in raw-pointer, intrusive-refcount lifecycle code on the HTTP thread. I traced the refcounts (new + enqueue → detach → fail_session's final deref) and confirmed detach_with is safe on a stream with no qstream. The &mut HTTPClient held by connect() across detach's write to client.h3 through the stored backref is the same shape the pre-existing start_ callers already have and the backref is derived from that same &mut, so it is not a new aliasing problem. Still, this is the kind of code where a maintainer who owns the h3 client should confirm the ownership story matches their model (including the note in the description that #40385 fixes the same double-failure from the other side).
Other factors
The prior review's only inline note (the unused BUN_CONFIG_DNS_TIME_TO_LIVE_SECONDS env and its comment) is gone in this push. Two github-actions[bot] inline comments landed on the changed lines just before the commit; their content is not visible here, so I cannot confirm whether they were addressed, which is another reason for a human to glance rather than auto-approve. The bug hunt exited on a dry streak with no findings. The test reuses the compiled-shim pattern from fetch-http3-syscall-fault.test.ts, drains stdout/stderr concurrently, and asserts output before exit code as the harness conventions require; I did not run it locally.
|
A note for a reader who arrives from the automated reviews above. The second automated review could not see whether the two inline notes on An earlier version of this comment said that the CodeRabbit walkthrough described the first version of this PR. CodeRabbit has since reviewed the current head, and its walkthrough is accurate now. |
Problem
fetch()whose QUIC connection dies before the response header can abort the process. ASan:heap-use-after-free READ of size 8inHTTPClient::fail_from_h2(src/http/lib.rs:2108), fromClientSession::retry_or_fail(src/http/h3_client/ClientSession.rs:288). Release builds panic:fetch on the HTTP thread holds a ticket.ClientContext::connect. When no connection opens, connect fails that session withPendingConnect::fail_session, which fails every request queued on it. That dispatch frees theAsyncHTTPthe client is part of. Then the retry fails the same client again.Fix
connecttakes the request back off the session before it fails that session. Afalsereturn leaves the request on no session, so the caller is its only failure path, which is what the other two callers assume.enqueuejust queued is its only entry, sodetachleavesfail_sessionnothing to fail. The teardown, the registry removal and the session's last reference do not change.start_still reportsConnectionRefusedfor its own failed connect.test/js/web/fetch/fetch-http3-client.test.ts, one new test (main aborts with an empty stdout). Also the three otherfetch-http3-*suites,serve-http3andserve-protocols.Background
retry_or_failre-sends a stream that closed before any response header, once, on a fresh connection.ClientContext::connectfinds a pooled connection or opens one, and queues the request.enqueuebinds aStreamto the request before the QUIC connect, because that stream has to exist when the handshake completes.HTTPClient::start_setsdefer_terminal_dispatch_until_connecting_is_completebefore its own connect call, so a failure inside that frame is recorded and dispatched later. That flag is why the two initial connect sites survived the double failure.Notes
Fail-before. With
src/andpackages/back on55c11065f2, the new test givesexitCode: 1and an empty stdout. That run, the passing run and the suites above were on55c11065f2plus this change, built with LLVM 21. The branch has since merged main, which needs LLVM 23 (#42851). The build environment used here does not have it, so on the merged tree onlycargo checkandcargo clippyforbun_httpwere run locally, and CI is the test run for it. The three commits that merge brought in touch none of the files involved. The ASan frames are the report above:A release build aborts as well, so the fault is not an ASan artifact:
on_async_http_callback_rawresets the client's stage before the dealloc, so the once-only guard infail_from_h2cannot stop the second dispatch. Making that guard survive the reset is a separate change.How the test reaches it. A connect to a resolved hostname probes each address with a throwaway UDP
connect(2), and gives up when no entry is reachable (packages/bun-usockets/src/quic.c,us_quic_connect_result). AnLD_PRELOADshim allows the first probe and refuses every later one, so the reconnect fails insideconnect.rejectUnauthorizedagainst the suite's self-signed certificate fails the handshake, which is what closes the stream before any header and starts the retry.localhostanswers fromis_localhost_nameas[::1, 127.0.0.1]without the resolver, so no connect waits for DNS, and the shim refuses the IPv6 entry the way a host without an IPv6 route does, which pins both connects to the same address. Linux only, and only where a C compiler exists, like the DPLPMTUD shim test infetch-http3-syscall-fault.test.ts. 5 runs, 5 passes, about 500 ms each on the debug ASan build.Other ways to reach the same failure. Any synchronous failure of the QUIC connect does it: a cached resolver error, an IP literal whose family the shared client endpoint cannot serve,
lsquic_engine_connectreturning NULL, or the shared client UDP endpoint dying on a hardrecvmsgerror and the poll registration for its replacement failing. The last one needs no resolver, so it reaches this path for an IP-literal origin too. One test is enough: all of them end in the samereturn false, and the endpoint-replacement route needs several iterations of a loop to line up.Earlier shape. The first version of this PR removed the retry's failure call instead, and documented
connectas owning the request. Review pushed back: it left bothif !connect { self.fail(..) }arms instart_dead, it made the bool unusable by every caller, and it set the opposite contract from #40385, which removes the same double failure from the callee side. This version fixes the callee, which also keeps the closed stream's error in the rejection instead of replacing it withECONNREFUSED.Scope.
retry_or_failis also edited by #41564 (a retry budget) and #42579 (no replay of a non-idempotent request), and #40598 changes which pre-header closes retry. None of them touch this branch, so this applies on top of any of them, and #40385 keeps the same contract.no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/fetch/fetch-http3-client.test.ts