Skip to content

http: release h2/h3 sessions and proxy tunnels through their holders' pointers, not &mut receivers - #37870

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/59bcf6db/http-session-release-through-holder-ptr
Aug 12, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
farm/59bcf6db/http-session-release-through-holder-ptr

Conversation

@robobun

@robobun robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Under bun run rust:miri, tearing down an h2 session or a proxy tunnel is reported as undefined behaviour: "deallocation through is forbidden ... the strongly protected tag disallows deallocations", pointing at the method's own receiver. The reduction in fetch: release the FetchTasklet through its raw pointer, not a &mut receiver #37703 is this shape, and the ASAN use-after-free trace in http: fix use-after-free when aborting an h2 request after its custom TLS context is evicted #31788 is the same shape observed at runtime, in abort_by_http_id.
  • Cause: four refcount releases in bun_http (h2 on_close and maybe_release, ProxyTunnel::detach_and_deref, h3 detach) go through the method's &mut self. For the h2 and tunnel sites that release is normally the last one, so the object is freed while a reference argument to it is still live, which is UB whether or not the reference is used again.
  • The keep-alive guards had the same defect one step removed: they were built from the receiver, so on failure paths the free still happened inside the method, at guard drop.
  • Two h2 entry points, adopt and abort_by_http_id, held no ref of their own while their body could tear the session down; adopt then read a field of the freed session. The adopt case was not triggered deterministically.

Fix

  • Every h2 entry point that can end with the session released now takes the pointer its holder stores (socket slot, registry, pool, or what create returned) and runs through one wrapper: take a guard from that pointer, run the former &mut self body, then release the socket ref the body gave up (recorded in a flag) and the guard's ref, both after the borrow has ended.
  • Property to check: inside a body no release can be the last one, because the wrapper's guard holds a ref, and the two releases that can be last run with no reference to the session in existence. The points at which refs are released relative to other work are unchanged.
  • Proxy tunnel: receive and on_writable build their guard from the client's handle pointer; detach_and_deref is deleted (start now builds the TLS wrapper before allocating the tunnel, and the pool fallback releases through the handle it was given); adopt moves the pool's handle into the client instead of re-deriving one from the receiver.
  • h3 detach's release is never the last one (the connection's own ref outlives every stream), so it keeps &mut self, says why, and releases through the stream's backref.
  • Verification: a new source lint fails on main at the five receiver-release sites and passes on this branch; the existing h2, h3 and proxy suites pass on an ASAN debug build; clippy and fmt are clean. The lint is the only test that fails without the change.

Background

  • Intrusive refcount: ClientSession and ProxyTunnel carry their own ref_count; deref(ptr) decrements it and frees the allocation at zero. Each holder (socket ext slot, context registry, keep-alive pool, HTTPClient.proxy_tunnel) owns one count, and hand-offs between holders move a count rather than bump it.
  • Holders of an h2 session: the TLS socket's ext slot is tagged with the session, HTTPContext.active_h2_sessions lists it so later requests can multiplex onto it, and the keep-alive pool holds it while it is parked with no streams.
  • Protectors: under both of Miri's aliasing models (Stacked Borrows and Tree Borrows), a &mut T argument is protected for the whole call, so freeing the pointee during the call is UB even if the argument is never touched again. This is why the fix moves the free to after the body returns instead of avoiding later uses of self.
  • bun_ptr::ThisPtr / ScopedRef: a copyable raw handle to a refcounted object, and an RAII guard that bumps on construction and releases on drop. SessionPtr in this PR is ThisPtr<ClientSession>.
  • RefPtr (tunnels): the owning handle stored by the client or the pool; RefPtr::deref() releases the count that handle owns, so releasing through it is releasing through the holder's pointer.
Original description

Problem

Four intrusive-refcount releases in bun_http went through the method's own receiver, &mut self coerced to *mut Self at the call:

src/http/h2_client/ClientSession.rs:848   on_close(&mut self)          unsafe { ClientSession::deref(self) }
src/http/h2_client/ClientSession.rs:959   maybe_release(&mut self)     unsafe { ClientSession::deref(self) }
src/http/ProxyTunnel.rs:751               detach_and_deref(&mut self)  unsafe { ProxyTunnel::deref(self) }
src/http/h3_client/ClientSession.rs:192   detach(&mut self, stream)    unsafe { ClientSession::deref(self) }

(grep -rnP '\bderef\(\s*self\s*\)' src at 9a543cc; the two SubprocessPipeReader.rs hits are being converted in their own PR.)

For the h2 and tunnel sites the release is normally the last one. An h2 session is held by the socket ext slot, the context registry and (while parked) the pool; on_close leaves the registry and then releases the slot's ref, and maybe_release does the same on a connection it cannot pool, so the session is freed inside a method that still has a &mut self argument pointing at it. detach_and_deref was reached from HTTPContext::release_socket with the only remaining ref to the tunnel. Freeing an allocation while a reference argument to it is live is rejected by both aliasing models regardless of whether the reference is used again (Tree Borrows, the model bun run rust:miri uses: "deallocation through is forbidden ... the strongly protected tag disallows deallocations", pointing at the receiver; Stacked Borrows: "deallocating while item [Unique] is strongly protected"). The reduction in #37703's description is this exact shape. The keep-alive guards had the same problem one step removed: ClientSession::ref_scope(&mut self) built a ScopedRef from the receiver and every socket event took one, so on the fail_all paths the free happened at the guard's drop, still inside on_data / on_writable / resume_receive_by_http_id; ProxyTunnel::receive / on_writable built theirs from NonNull::from(&mut *self), and when delivering the bytes completes or fails the request (which releases the client's ref, or hands it to a full pool) that guard's drop is the last release.

Two entry points additionally held no ref at all while their body could reach one of these releases. adopt: both callers in HTTPContext::connect (registry match and pool resume) hold nothing of their own, so when the first flush of the new stream fails, attach -> fail_all -> on_close freed the session and adopt then read self.encoder_poisoned from the freed allocation (needs a TLS write to fail synchronously during the adopt; I could not trigger it deterministically). abort_by_http_id: the ASAN trace in #31788 is this shape observed, a custom TLS context dropping re-entrantly from the aborted request's result callback, on_close freeing the session at its guard drop while abort_by_http_id's &mut self was live up the stack, and the tail of abort_by_http_id then running on freed memory. #31788 fixes that (and two unrelated defects) by giving abort_by_http_id a ref_scope() guard of its own, which moves the free to that guard's drop, still inside the method; here every entry point gets the guard from enter, taken from the holder's pointer, and the free happens after the body's borrow has ended. The two PRs overlap in that one line and are otherwise independent.

The h3 site is different: the per-stream ref detach releases is provably never the last one (the connection's ref is released only by on_conn_close / fail_session, and both drain pending through detach first), so &mut self is a sound receiver there. The function now says so, and releases through the pending entry's own backref so that every release in the crate goes through the pointer of the holder whose ref it is.

Fix

h2 (h2_client/ClientSession.rs, HTTPContext.rs, HTTPThread.rs, lib.rs): every entry point that can end with the session released takes a SessionPtr (bun_ptr::ThisPtr<ClientSession>, the pointer its holder has: socket ext tag, registry entry, pool entry, or what create returned) and goes through ClientSession::enter, which takes a guard from that pointer, runs the former &mut self body through a call-scoped reborrow, and after it returns releases the socket-ext ref if the body gave it up (a socket_ref_owed cell set by fail_streams and by maybe_release's close branch, where the deref(self) calls were) and then the guard's own ref, both through the holder's pointer. Inside a body no release can be the last one (the guard holds one), and the two that can be last now run with no reference to the session in existence. Converted entries: on_data, on_writable, on_close, adopt, the leader's attach_leader, enqueue, abort_by_http_id, stream_body_by_http_id, resume_receive_by_http_id, drain_response_body_by_http_id; the bodies are private, so the type has no &mut self path to a release left. ActiveSocketExt::session_mut becomes session() -> Option<SessionPtr>, HTTPThread holds its own ref_guard() across the resume + drain pair (what its ref_scope() was for), connect() adopts after its registry scan has finished instead of from inside the loop that maybe_release swap-removes from, and the registry releases its ref through the entry it stored rather than whatever pointer the caller passed. Order of operations within each entry is unchanged; the releases happen at the same points relative to everything else, just after the body's borrow has ended.

proxy tunnel (ProxyTunnel.rs, HTTPContext.rs, lib.rs): receive and on_writable take the client's handle pointer (RefPtr::data) and build their guard from it, the same contract the SSL callbacks in the file already use for ref_scope. detach_and_deref is deleted: start() now builds the SSL wrapper before allocating the tunnel, so its failure path has nothing to release, and the pool fallback in release_socket releases through the RefPtr it was handed, as close_proxy_tunnel, AsyncHTTP and the shutdown path already do. adopt takes the pool's RefPtr and moves it into the client instead of re-deriving a handle from its receiver with RefPtr::from_raw(from_mut(&mut *self)), so the ref the client eventually releases is the one the pool held.

h3 (h3_client/ClientSession.rs): detach documents why its release is never the last one and performs it through the stream's session backref.

Test

test/internal/source-lints/self-receiver-deref.test.ts bans Type::deref(self) (and the other raw-pointer release entry points of the refcount traits) and ScopedRef / *RefGuard::new|adopt(self), checks its patterns against positive and negative spellings, and ratchets the two SubprocessPipeReader.rs sites at their exact count until their own conversion lands. Against main it reports exactly

src/http/h2_client/ClientSession.rs:206: SessionRefGuard::new(self)
src/http/h2_client/ClientSession.rs:848: ::deref(self)
src/http/h2_client/ClientSession.rs:959: ::deref(self)
src/http/h3_client/ClientSession.rs:192: ::deref(self)
src/http/ProxyTunnel.rs:751: ::deref(self)

and passes with this branch. It is deliberately limited to the bare receiver; the ptr::from_mut(self) family is what #37703's lint covers, and the two compose.

Verification

Debug (ASAN) build: test/js/web/fetch/fetch-http2-client.test.ts, fetch-http2-adversarial.test.ts, fetch-http2-leak.test.ts, fetch-proxy-connect-tunnel-split-envelope.test.ts, fetch-proxy-tls-intern-race.test.ts, fetch-http3-client.test.ts, fetch-http3-adversarial.test.ts, fetch-http3-cold-post.test.ts, and test/js/bun/http/proxy.test.{ts,js} plus the seven proxy-stress-* suites all pass (two proxy.test.js auth cases need NO_PROXY unset in my container because it lists localhost; they pass with it unset, and fail identically on the released binary with it set). bun test test/internal/source-lints/ passes; cargo clippy -p bun_http and cargo fmt --check are clean.

Not touched here, noted while reading: the pool's handle in maybe_release (NonNull::from(&mut *self)) and the h2 Stream/h3 Stream backrefs are still pointers derived from a receiver, but none of them is a release site, and the re-entrant HTTPContext borrows described on unregister_h2_raw are a separate problem.

… pointers

ClientSession::on_close and maybe_release (h2), ProxyTunnel::detach_and_deref
and h3 ClientSession::detach released an intrusive ref by passing their own
&mut self receiver to deref(). For the h2 and tunnel sites that release is
normally the last one, so the allocation was freed while a reference argument
to it was still live, which both aliasing models reject (protected tag). The
h2 ref_scope() guards and the tunnel's receive/on_writable guards had the same
shape one step later: built from the receiver, dropped inside the method,
and the last release whenever the body released the other holders' refs.

h2: the socket events, adopt, the leader attach, enqueue and the per-request
wakeups now take a SessionPtr (ThisPtr) and run their &mut bodies through
ClientSession::enter, which holds a guard from that pointer, runs the body
through a call-scoped reborrow, and afterwards releases the socket-ext ref the
body gave up (socket_ref_owed) and its own. The registry releases its ref
through the entry it stored. ActiveSocketExt no longer hands out a
&mut ClientSession, and connect() adopts after its registry scan has finished.

proxy: receive/on_writable take the client's handle pointer; detach_and_deref
is gone (start() builds the wrapper before allocating the tunnel, the pool
fallback releases through its RefPtr like close_proxy_tunnel does); adopt()
moves the pool's RefPtr into the client instead of re-deriving one from its
receiver.

h3: detach's release is provably never the last one (the connection ref is
released only after pending is drained); it now goes through the stream's
own session backref and says why &mut self is fine there.

A source lint bans the bare-receiver spellings (Type::deref(self),
ScopedRef/RefGuard::new(self)); the two subprocess PipeReader sites being
converted separately are allowlisted at their exact count.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4c715928-922e-4ac2-96f2-9c6041e63cf8

📥 Commits

Reviewing files that changed from the base of the PR and between 9a543cc and 133215c.

📒 Files selected for processing (7)
  • src/http/H2Client.rs
  • src/http/HTTPContext.rs
  • src/http/HTTPThread.rs
  • src/http/ProxyTunnel.rs
  • src/http/h2_client/ClientSession.rs
  • src/http/h3_client/ClientSession.rs
  • src/http/lib.rs

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.


Walkthrough

HTTP/2 session callbacks now use owning SessionPtr handles and deferred release. HTTP socket, registry, pooling, request, and cleanup paths were updated. Proxy tunnel callbacks now use intrusive handles and raw pointers with explicit ownership ordering.

Changes

HTTP session ownership

Layer / File(s) Summary
Session handle and lifetime model
src/http/H2Client.rs, src/http/h2_client/ClientSession.rs, src/http/h3_client/ClientSession.rs
ClientSession now exposes SessionPtr entry points, defers socket-reference release, and validates stream ownership during detach.
HTTP/2 socket and registry integration
src/http/HTTPContext.rs
HTTP/2 lookup, reuse, registry removal, socket events, and cleanup now use session handles.
Proxy tunnel ownership and callbacks
src/http/ProxyTunnel.rs
Proxy tunnel creation, adoption, writable handling, receive handling, and cleanup now use intrusive ownership operations.
HTTP request and resolution wiring
src/http/HTTPThread.rs, src/http/lib.rs
HTTP request operations, ALPN resolution, waiters, and proxy callbacks now call the handle-based APIs.

Possibly related PRs

  • oven-sh/bun#37703: Refactors intrusive-reference lifetimes in different components.

Suggested reviewers: jarred-sumner, cirospaciari


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

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced as a source-tree fact rather than a crash: at 9a543cc, grep -rnP '\bderef\(\s*self\s*\)' src/http lists the four sites in the description, and bun test test/internal/source-lints/self-receiver-deref.test.ts run against main's src/http reports them (plus the receiver-built ref_scope guard); the same lint passes on this branch. The one observed runtime instance of the shape is the ASAN trace in #31788 (abort_by_http_id continuing on a session that a re-entrant on_close freed under it); the other consequence I know of (adopt reading encoder_poisoned after a failed first flush freed the session) needs a synchronous TLS write failure I could not trigger deterministically. Everything else here is the contract, not a crash.

Fix: #37870 (this PR). The h2/h3/proxy suites listed in the description pass on the debug ASAN build. Relationship to #31788 is described in the comment below; the overlap is one line.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. http: fix use-after-free when aborting an h2 request after its custom TLS context is evicted #31788 - Fixes the same use-after-free in h2_client/ClientSession::abort_by_http_id by adding a ref_scope() guard, which this PR subsumes structurally by routing every h2 entry point through ClientSession::enter with a held ref_guard().

🤖 Generated with Claude Code

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #31788, but they touch one line in common, so noting the relationship.

#31788 fixes a custom-TLS-context eviction cluster (three defects: the re-entrant teardown reaching abort_by_http_id, the deliver loop's current stream, and the PendingConnect list). Its fix for the first one is a let _guard = self.ref_scope(); at the top of abort_by_http_id. This PR removes ref_scope and routes abort_by_http_id, along with every other entry point, through ClientSession::enter, which holds a guard taken from the holder's pointer for the body, so that scenario is covered here as well: the nested on_close releases the registry and socket refs, the outer guard keeps the session alive through rearm_timeout / maybe_release (which returns at the registry sentinel), and the free happens after the body's borrow has ended instead of at a guard drop inside the &mut self method. The other two defects in #31788 are independent of this change.

The ASAN trace in #31788 is also the one observed instance of the shape this PR is about: the session freed by on_close's guard drop while abort_by_http_id's &mut self was live up the stack. I have added a pointer to it in the description. Whichever of the two PRs lands second needs a small rebase in ClientSession.rs; on this side it amounts to dropping that added line, since enter supplies the guard.

@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 the automated bug-hunting pass found no issues. Because it restructures intrusive-refcount release ordering across the h2/h3/proxy-tunnel paths — memory-safety-critical native code with subtle aliasing-model reasoning — a human look is still warranted.

Checked: the new ClientSession::enter pattern's guard/release ordering (the socket_ref_owed deferred release runs while _keep_alive still holds a ref, so it's never the last); connect()'s registry scan now completes before adopt can swap-remove from active_h2_sessions; ProxyTunnel::start's reordering (wrapper built before tunnel alloc) so the error path has nothing to release; and the h3 detach release going through the stream's backref with the never-last-ref invariant documented.

Extended reasoning...

Overview

This PR eliminates a class of aliasing-model violations where intrusive-refcount releases (Type::deref(self)) and keep-alive guards (ScopedRef::new(self)) were performed through a method's own &mut self receiver — freeing the allocation while a live reference argument still points at it, which both Stacked Borrows and Tree Borrows reject. It touches 7 source files in src/http/ (h2 ClientSession, h3 ClientSession, ProxyTunnel, HTTPContext, HTTPThread, H2Client, lib.rs) plus a new source-lint test.

The h2 fix introduces a SessionPtr (ThisPtr<ClientSession>) type and an enter() wrapper: every public entry point that can leave the session released now takes the holder's pointer, runs the body through a scoped &mut reborrow under a keep-alive guard, and performs the socket-ext ref release (via a new socket_ref_owed: Cell<bool>) after the borrow ends. Ten entry points are converted; ActiveSocketExt::session_mut becomes session() -> Option<SessionPtr>. The proxy-tunnel fix converts on_writable/receive to take NonNull<Self>, deletes detach_and_deref, reorders start() to build the SSL wrapper first, and makes adopt take the pool's RefPtr by value. The h3 change is documentation plus releasing through the stream's backref.

Security risks

None identified. This is a memory-safety refactor of internal refcount plumbing; no user-facing API, input parsing, or auth surface changes. The changes strictly tighten aliasing invariants.

Level of scrutiny

High. This is native memory-safety code in the HTTP client hot path. The refactor changes when and through which pointer heap allocations are freed, restructures the ownership contract between socket ext slots / registry / pool / guards, and adds a deferred-release mechanism (socket_ref_owed). Getting the ordering wrong in any converted entry point could turn a theoretical Miri violation into a real UAF. The PR description is exceptionally thorough and the verification (ASAN + full h2/h3/proxy test suites) is appropriate, but the change is large enough (~400 lines across tightly-coupled modules) and the reasoning subtle enough that a maintainer familiar with the bun_ptr refcount traits and the HTTP thread's ownership model should confirm the enter pattern and the connect() iteration reordering.

Other factors

  • The new source-lint test is well-constructed (positive/negative pattern checks, ratcheted allowlist for the two SubprocessPipeReader.rs sites being converted separately).
  • No prior human reviews on the PR; only a rate-limited CodeRabbit placeholder.
  • The PR explicitly notes what it does not fix (NonNull::from(&mut *self) in maybe_release's pool path, re-entrant HTTPContext borrows) — those are worth a maintainer's eye on whether they belong here or in follow-ups.

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:56 PM PT - Aug 12th, 2026

🔄 @robobun, the build for ea3156e3 was cancelled — 133215cb is building instead in Build #93574. Stay tuned...

Comment thread src/http/HTTPContext.rs
Comment on lines +117 to +121
///
/// An h2 session is handed out as a [`h2::SessionPtr`] rather than a `&mut`:
/// its entry points release the slot's ref themselves (the socket closing, or
/// the last request finishing on an unpoolable connection), so a `&mut`
/// argument to them could be freed while still live.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/HTTPContext.rs
Comment on lines +129 to +130
/// `client_mut`/`pooled_mut` share one SAFETY argument instead of two
/// open-coded ones. INVARIANT: see [`ActiveSocketExt`] trait doc.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/HTTPContext.rs
Comment on lines +148 to +149
// INVARIANT: see the trait doc — while tagged as the session the slot
// holds the socket-ext ref, which is what `this_ptr` requires.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/HTTPContext.rs
Comment on lines +198 to +200
/// entry to `Option<&'a mut h2::ClientSession>`, for the field writes and
/// idle-frame handling that cannot release the session. Anything that can
/// (`adopt`, the socket events) goes through a [`h2::SessionPtr`] instead.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/HTTPContext.rs
Comment on lines +203 to +208
/// on the session (taken in `release_socket`, released in
/// `add_memory_back_to_pool` or handed to the socket ext by `existing_socket` /
/// `connect`); the session is a distinct heap allocation that outlives the
/// holder. HTTP-thread-only, so no concurrent `&mut`. Centralises the SAFETY
/// argument shared by `PooledSocket::h2_session_mut` and
/// `ExistingSocket::h2_session_mut`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/HTTPContext.rs
Comment on lines +381 to +384
/// Tail of [`Self::unregister_h2_raw`]: swap-remove the entry at `idx`
/// from `list`, fix up the swapped-in entry's index, and release the ref
/// taken in [`Self::register_h2`] through the pointer the registry held.
/// `session` only identifies the entry being removed.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/HTTPContext.rs
Comment on lines +449 to +456
/// Take `session` out of `active_h2_sessions`, releasing the registry's
/// ref. Takes the context as a raw pointer because it is reached on
/// re-entrant call paths (`connect` → `adopt` → `maybe_release` /
/// `fail_all` → `fail_streams`) where an ancestor stack frame already
/// holds `&mut HTTPContext<SSL>`. Upgrading the session's `ctx` backref to
/// a second `&mut Self` there would alias; this entry point instead
/// projects `active_h2_sessions` through a raw place expression so no
/// intermediate `&mut Self` is formed.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/HTTPContext.rs
Comment on lines +462 to +463
/// be live for the duration of the call; it is only used to find and
/// identify the registry entry, which is what gets released.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/HTTPContext.rs
Comment on lines +684 to +686
// `t` is the strong ref the caller transferred; releasing it
// through the handle (usually the last ref) mirrors
// `HTTPClient::close_proxy_tunnel`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/HTTPContext.rs
Comment on lines +900 to +903
// Listed sessions are kept alive by the registry's ref. The
// scan only reads; `adopt` runs after the iteration is over
// because it may end by unregistering the session it was
// given (swap-removing it from this Vec) and releasing it.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/HTTPContext.rs
Comment on lines +915 to +918
// Same guard as the pool path: a session whose TLS
// handshake ran with reject_unauthorized=false never
// validated the peer hostname, so a strict caller
// must not multiplex onto it.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/HTTPContext.rs
Comment on lines +1000 to +1001
// The `&mut` ends with the statement: `register_h2`
// forms a fresh `&*session`, which under Stacked

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/HTTPContext.rs
Comment on lines +1004 to +1007
// The pool's ref (carried by `found`) becomes the
// socket ext's; `adopt` goes through that handle and
// may release it again if the session turns out to be
// unusable, so nothing touches `session` afterwards.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/HTTPContext.rs
Comment on lines +1022 to +1023
// `adopt` moves the pool's strong ref into
// `client.proxy_tunnel`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/HTTPThread.rs
Comment on lines +842 to +844
// The resume may tear the session down and
// release the socket's ref; hold one across
// the second call.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/ProxyTunnel.rs
Comment on lines +167 to +176
/// INVARIANT (module): `this` is the `HTTPClient.proxy_tunnel` handle's
/// pointer (the callbacks read it from the client; `on_writable` /
/// `receive` are passed it), so the tunnel is live and the guard's
/// eventual release, which is the last one whenever the guarded call
/// released the client's ref, goes through the holder's own pointer
/// rather than through a `&mut self` that would still be live at that
/// point. `ScopedRef::new` bumps via raw `CellRefCounted::ref_count_raw`
/// field projection — touching only `ref_count`, never the whole tunnel —
/// so it does not alias the caller's `&SSLWrapper` (see ALIASING NOTE).
/// HTTP-thread-only.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/ProxyTunnel.rs
Comment on lines +616 to +618
// `RefPtr::new` owns the tunnel's initial ref (`ref_count == 1` from
// `Default`); the client holds it until `close_proxy_tunnel` or the
// hand-off to the keep-alive pool.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/ProxyTunnel.rs
Comment on lines +625 to +629
// `this` is not used below: start() synchronously fires on_open() /
// write_encrypted(), which re-derive `&mut HTTPClient` from the raw ctx
// pointer and touch only tunnel fields disjoint from `wrapper` via
// `addr_of!` (see ALIASING NOTE), so the `&SSLWrapper` formed here is
// never aliased by a `&mut`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/ProxyTunnel.rs
Comment on lines +672 to +675
/// Outer socket became writable. `this` is the client's `proxy_tunnel`
/// handle (see [`Self::ref_scope`]): the flush below can complete or fail
/// the request, which releases that handle, leaving the guard's ref as the
/// last one. A `&mut self` receiver would then be freed while still live.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/ProxyTunnel.rs
Comment on lines +707 to +709
/// Encrypted bytes arrived on the outer socket. Same contract as
/// [`Self::on_writable`]: delivering the decrypted data can release the
/// client's handle, so the guard taken from it may hold the last ref.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/ProxyTunnel.rs
Comment on lines +734 to +736
/// Forget the outer socket. Holders call this before releasing their
/// handle (`RefPtr::deref`), so a tunnel that outlives them (a deferred
/// `on_close` deref is still pending) never retains a dangling socket.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/ProxyTunnel.rs
Comment on lines +767 to +769
///
/// `tunnel` is the pool's handle; it moves into `client.proxy_tunnel` as
/// is, so the ref the client later releases is the one the pool held.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/http/ProxyTunnel.rs
Comment on lines +779 to +781
// `tunnel` holds the strong ref, so the pointee is live for these
// writes; nothing re-enters the tunnel until the client's next socket
// event, after this borrow has ended.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +41 to +48
/// Set when a `&mut self` body has torn the session down (`fail_streams`,
/// or `maybe_release` closing an unpoolable socket) and the socket-ext ref
/// is therefore due to be released. The body cannot release it itself: at
/// that point it is normally the last ref, and freeing the session while a
/// `&mut self` argument to it is live is undefined behaviour even if the
/// reference is never used again. The [`SessionPtr`] entry point that ran
/// the body takes the flag and releases the ref once the borrow has ended
/// (see [`ClientSession::enter`]).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +138 to +147
/// A live session as its holders point at it: the socket ext slot, the
/// context's registry, the keep-alive pool, or the pointer `create` returned.
///
/// Every `pub(crate)` entry point that can leave the session released (socket
/// events, the registry / pool hand-offs in `HTTPContext::connect`, the
/// per-request wakeups from `HTTPThread`) takes one of these instead of
/// `&mut self` and goes through [`ClientSession::enter`], so the releases
/// happen through the holder's pointer after the body's `&mut` borrow has
/// ended. Callers that need the session alive across two entry points hold a
/// [`bun_ptr::ThisPtr::ref_guard`] of their own across both.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +215 to +218
/// INVARIANT: `session` is a session the caller holds a ref on (a socket
/// ext slot's tag, a registry or pool entry, or the pointer `create`
/// returned), so it is live for as long as the caller uses the handle.
/// HTTP-thread-only.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +225 to +234
/// Run a `&mut self` body on the session behind `this`, then perform the
/// releases it asked for.
///
/// The guard keeps the session alive while the body re-enters clients
/// (delivering bodies, failing requests) and releases the registry ref, so
/// no release inside the body is ever the last one. `body` receives a
/// reborrow that ends when it returns; only then is the socket-ext ref it
/// may have given up (`socket_ref_owed`) released, followed by the guard's
/// own ref, both through `this`. When the body tore the session down that
/// second release frees it, with no reference to it live anywhere.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +259 to +261
/// Socket onClose / onTimeout entry point. The socket is already gone, so
/// every stream fails and the socket ext's ref is released; unless a
/// caller holds its own guard, the session is freed before this returns.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +266 to +267
/// Multiplex `client` onto an established (registered or pool-resumed)
/// session; see [`Self::adopt_client`].

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +272 to +274
/// Open the first stream on a session `create` just returned, for the
/// client whose connect negotiated h2. Unlike [`Self::adopt`] this does not
/// wait for the server's SETTINGS: the leader's stream carries the preface.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +279 to +280
/// Called from the HTTP thread's shutdown queue when a fetch on this
/// session is aborted; see [`Self::abort_request`].

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +285 to +286
/// HTTP-thread wake-up from `scheduleRequestWrite`; see
/// [`Self::stream_request_body`].

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +296 to +297
/// HTTP-thread wake-up from `scheduleResponseBodyDrain`; see
/// [`Self::drain_response_body`].

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +326 to +329
/// Allocate a session for a socket whose ALPN just selected h2 and list it
/// in the context's registry. The returned handle carries the socket ext's
/// ref: the caller tags the socket with it (`tag_as_h2`) and then opens the
/// leader's stream with [`Self::attach_leader`].

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +436 to +437
/// Park a request that was coalesced onto this session's connect until the
/// server's SETTINGS arrive; see [`Self::park`].

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +683 to +684
/// JS just enabled `response_body_streaming` on the request, so flush any
/// body bytes that arrived between metadata delivery and `getReader()`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +706 to +707
/// New request body bytes (or end-of-body) are available in the request's
/// ThreadSafeStreamBuffer.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +795 to +797
/// Parse frames into per-stream state, deliver each ready stream to its
/// client, then pool or close if no streams remain. Structured "parse all
/// → deliver all" because delivering may free the client.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +918 to +923
/// Tear the session down once its socket is gone (or, via `fail_all`,
/// about to be closed): leave the registry, fail every parked and attached
/// request, and hand the socket-ext ref to the enclosing entry point for
/// release. Runs exactly once per session: the socket is dead afterwards,
/// so no further socket event reaches it, and every `fail_all` caller
/// returns straight away.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +949 to +950
/// The socket-ext ref is no longer wanted; `enter` releases it once this
/// body has returned. See `socket_ref_owed`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@Jarred-Sumner
Jarred-Sumner merged commit 2055fe9 into main Aug 12, 2026
8 of 21 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/59bcf6db/http-session-release-through-holder-ptr branch August 12, 2026 20:58
Comment thread src/http/h2_client/ClientSession.rs
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

On the deleted test: commit 133215c was authored by Jarred-Sumner, who removed the source-lint file and then merged, so dropping it was a maintainer call rather than an unstated change on my side. The description's Test section describes the branch as it was before that commit. If a lint guarding against receiver releases (::deref(self), guards built from self) is wanted after all, it can be restored in a follow-up.

Jarred-Sumner pushed a commit that referenced this pull request Sep 13, 2026
…back closes the session (#42647)

### Problem
- A server `RST_STREAM` can make the HTTP/2 fetch client read and free a
stream twice. A debug ASAN build of main (09bb546) reports
`AddressSanitizer: heap-use-after-free` in `ClientSession::handle_data`
(`src/http/h2_client/ClientSession.rs:848`, the `s.state !=
StreamState::Closed` read). The stream was freed by `drop_stream` in
`ClientSession::fail_streams` (`ClientSession.rs:943`). A release build
has no report, only heap corruption.
- It needs a request with a custom TLS context (for example `tls: {
serverName }`) whose entry left the 60-entry context cache. That request
then holds the last ref. Its terminal callback drops the context, the
drop closes the session's socket, and `on_close` runs `fail_streams`
inside the deliver loop.

### Fix
- While `delivering` is set, `fail_streams` fails each client and leaves
the streams in `streams` and `by_http_id`. The deliver loop already
removes a stream that has no client, through `remove_stream`, so each
stream is freed once.
- Outside the loop `fail_streams` behaves as before.
- Correct because the loop's tail is safe on a session that is already
closed: `maybe_release` returns at the `registry_index` sentinel, and a
write to the closed socket writes nothing.
- Verified: `test/js/web/fetch/fetch-http2-client.test.ts` (two new
ASAN-only tests, 67 pass). With `src/` at main the RST test fails. Also
`fetch-http2-leak.test.ts` (7 pass) and
`fetch-http2-adversarial.test.ts` (20 pass).

### Background
- `ClientSession` is one HTTP/2 connection of the fetch client.
`streams` maps a stream id to a heap `Stream`. Each `Stream` points at
the `HTTPClient` of one `fetch()`.
- `handle_data` parses frames, then delivers each ready stream to its
client in a loop, with `delivering` set. A terminal delivery runs the
result callback at once, on the HTTP thread.
- Custom TLS contexts are refcounted. The cache holds one ref and each
request holds one.

<details><summary>Notes</summary>

This is the part of #31788 that main still needs. #31788 was closed as
stale, and all five of its `src/` files conflict with main now.

- Its first defect, `abort_by_http_id` with no ref on the session, is
fixed on main: since #37870 every entry point goes through
`ClientSession::enter`, which holds a `RefPtr`. The abort test from
#31788 passes on main. It is included here because main has no test that
evicts a TLS context.
- Its third part, a `ctx` field on `PendingConnect`, has no reproduction
and is not included.

ASAN report on main, trimmed:

```
ERROR: AddressSanitizer: heap-use-after-free READ of size 1 thread T2 (HTTP Client)
    #0 <State as PartialEq>::eq            src/http/h2_client/Stream.rs:104
    #2 ClientSession::handle_data          src/http/h2_client/ClientSession.rs:848
    #4 ClientSession::enter                src/http/h2_client/ClientSession.rs:242
    #6 Handler<true>::on_data              src/http/HTTPContext.rs:1386
freed by thread T2 (HTTP Client) here:
    #12 drop_stream                        src/http/h2_client/ClientSession.rs:211
    #13 ClientSession::fail_streams        src/http/h2_client/ClientSession.rs:943
```

The two tests share one helper. The child holds a stream open on a
context made by `serverName`, runs 61 more TLS configs to evict it,
writes `evicted` to stderr, and then ends the held request: one test
aborts it, the other has the server send `RST_STREAM(CANCEL)`. Both
check that a later `fetch()` still works. They run only under ASAN and
carry a 30 s timeout, because each child makes 62 TLS handshakes and a
regression needs time to print its report. Without the longer timeout
the failing run on main shows a timeout at 5 s, not the report.

The tests are serial on purpose: each one fills the context cache, which
would evict the context that a concurrent test depends on.

One older test in the same file changed: `concurrent requests multiplex
on one h2 session`. Its server held each stream for 100 ms and the test
expected 8 open at once. On a loaded machine it saw 7 (2 of 9 runs of
the file). The server now answers when the eighth stream is open, so the
test uses no timer. After the change the file passed 4 of 4 runs on the
same loaded machine.
</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: 1 FAILED
$ 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/fetch-http2-client.test.ts"
bun test v1.4.3 (b993710)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [1309.07ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [1215.67ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [1497.63ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body streams as raw DATA frames [730.67ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [2353.52ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is delivered as DATA frames [2514.64ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body larger than initial send window [927.77
... (truncated)

release without fix: 2 skipped
bun test v1.4.3-canary.1 (f92be71)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [110.34ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [115.04ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response trailers are consumed without breaking the body [99.30ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > connection-specific request headers are stripped before HPACK [111.10ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > cold-start: parallel requests coalesce onto one TLS connect [135.66ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [147.00ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [160.06ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > keep-alive: sequential requests reuse one h2 session [139.51ms]
(pass) fetch() over H
... (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/fetch-http2-client.test.ts"
bun test v1.4.3 (b993710)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [1532.08ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [1265.37ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is delivered as DATA frames [1731.76ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body streams as raw DATA frames [501.02ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [1802.42ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [1743.84ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent ReadableStream uploads route each chunk to its own stream 
... (truncated)

release with fix: 2 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 710ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/5] 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/brotli)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92m   Compiling�[0m bun_clap v0.0.0 (/workspace/bun/src/clap)
�[1m�[92m   Compiling�[0m bu
... (truncated)
```

</details>

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

```
src/http/h2_client/ClientSession.rs          |  12 ++-
 test/js/web/fetch/fetch-http2-client.test.ts | 118 ++++++++++++++++++++++++---
 2 files changed, 117 insertions(+), 13 deletions(-)
```

</details>

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

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

```
file                                          reads  edits  tests
src/http/h2_client/ClientSession.rs               2      1     24
test/js/web/fetch/fetch-http2-client.test.ts      1      2     24
```

</details>

<!-- robobun:evidence:end -->
usrbinkat pushed a commit to usrbinkat/bun that referenced this pull request Sep 15, 2026
…back closes the session (oven-sh#42647)

### Problem
- A server `RST_STREAM` can make the HTTP/2 fetch client read and free a
stream twice. A debug ASAN build of main (09bb546) reports
`AddressSanitizer: heap-use-after-free` in `ClientSession::handle_data`
(`src/http/h2_client/ClientSession.rs:848`, the `s.state !=
StreamState::Closed` read). The stream was freed by `drop_stream` in
`ClientSession::fail_streams` (`ClientSession.rs:943`). A release build
has no report, only heap corruption.
- It needs a request with a custom TLS context (for example `tls: {
serverName }`) whose entry left the 60-entry context cache. That request
then holds the last ref. Its terminal callback drops the context, the
drop closes the session's socket, and `on_close` runs `fail_streams`
inside the deliver loop.

### Fix
- While `delivering` is set, `fail_streams` fails each client and leaves
the streams in `streams` and `by_http_id`. The deliver loop already
removes a stream that has no client, through `remove_stream`, so each
stream is freed once.
- Outside the loop `fail_streams` behaves as before.
- Correct because the loop's tail is safe on a session that is already
closed: `maybe_release` returns at the `registry_index` sentinel, and a
write to the closed socket writes nothing.
- Verified: `test/js/web/fetch/fetch-http2-client.test.ts` (two new
ASAN-only tests, 67 pass). With `src/` at main the RST test fails. Also
`fetch-http2-leak.test.ts` (7 pass) and
`fetch-http2-adversarial.test.ts` (20 pass).

### Background
- `ClientSession` is one HTTP/2 connection of the fetch client.
`streams` maps a stream id to a heap `Stream`. Each `Stream` points at
the `HTTPClient` of one `fetch()`.
- `handle_data` parses frames, then delivers each ready stream to its
client in a loop, with `delivering` set. A terminal delivery runs the
result callback at once, on the HTTP thread.
- Custom TLS contexts are refcounted. The cache holds one ref and each
request holds one.

<details><summary>Notes</summary>

This is the part of oven-sh#31788 that main still needs. oven-sh#31788 was closed as
stale, and all five of its `src/` files conflict with main now.

- Its first defect, `abort_by_http_id` with no ref on the session, is
fixed on main: since oven-sh#37870 every entry point goes through
`ClientSession::enter`, which holds a `RefPtr`. The abort test from
oven-sh#31788 passes on main. It is included here because main has no test that
evicts a TLS context.
- Its third part, a `ctx` field on `PendingConnect`, has no reproduction
and is not included.

ASAN report on main, trimmed:

```
ERROR: AddressSanitizer: heap-use-after-free READ of size 1 thread T2 (HTTP Client)
    #0 <State as PartialEq>::eq            src/http/h2_client/Stream.rs:104
    oven-sh#2 ClientSession::handle_data          src/http/h2_client/ClientSession.rs:848
    oven-sh#4 ClientSession::enter                src/http/h2_client/ClientSession.rs:242
    oven-sh#6 Handler<true>::on_data              src/http/HTTPContext.rs:1386
freed by thread T2 (HTTP Client) here:
    oven-sh#12 drop_stream                        src/http/h2_client/ClientSession.rs:211
    oven-sh#13 ClientSession::fail_streams        src/http/h2_client/ClientSession.rs:943
```

The two tests share one helper. The child holds a stream open on a
context made by `serverName`, runs 61 more TLS configs to evict it,
writes `evicted` to stderr, and then ends the held request: one test
aborts it, the other has the server send `RST_STREAM(CANCEL)`. Both
check that a later `fetch()` still works. They run only under ASAN and
carry a 30 s timeout, because each child makes 62 TLS handshakes and a
regression needs time to print its report. Without the longer timeout
the failing run on main shows a timeout at 5 s, not the report.

The tests are serial on purpose: each one fills the context cache, which
would evict the context that a concurrent test depends on.

One older test in the same file changed: `concurrent requests multiplex
on one h2 session`. Its server held each stream for 100 ms and the test
expected 8 open at once. On a loaded machine it saw 7 (2 of 9 runs of
the file). The server now answers when the eighth stream is open, so the
test uses no timer. After the change the file passed 4 of 4 runs on the
same loaded machine.
</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: 1 FAILED
$ 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/fetch-http2-client.test.ts"
bun test v1.4.3 (b993710)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [1309.07ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [1215.67ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [1497.63ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body streams as raw DATA frames [730.67ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [2353.52ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is delivered as DATA frames [2514.64ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body larger than initial send window [927.77
... (truncated)

release without fix: 2 skipped
bun test v1.4.3-canary.1 (f92be71)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [110.34ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [115.04ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response trailers are consumed without breaking the body [99.30ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > connection-specific request headers are stripped before HPACK [111.10ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > cold-start: parallel requests coalesce onto one TLS connect [135.66ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [147.00ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [160.06ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > keep-alive: sequential requests reuse one h2 session [139.51ms]
(pass) fetch() over H
... (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/fetch-http2-client.test.ts"
bun test v1.4.3 (b993710)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [1532.08ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [1265.37ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is delivered as DATA frames [1731.76ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body streams as raw DATA frames [501.02ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [1802.42ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [1743.84ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent ReadableStream uploads route each chunk to its own stream 
... (truncated)

release with fix: 2 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 710ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/5] 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/brotli)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92m   Compiling�[0m bun_clap v0.0.0 (/workspace/bun/src/clap)
�[1m�[92m   Compiling�[0m bu
... (truncated)
```

</details>

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

```
src/http/h2_client/ClientSession.rs          |  12 ++-
 test/js/web/fetch/fetch-http2-client.test.ts | 118 ++++++++++++++++++++++++---
 2 files changed, 117 insertions(+), 13 deletions(-)
```

</details>

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

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

```
file                                          reads  edits  tests
src/http/h2_client/ClientSession.rs               2      1     24
test/js/web/fetch/fetch-http2-client.test.ts      1      2     24
```

</details>

<!-- robobun:evidence:end -->
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