Skip to content

HTTP client: remove unsafe from src/http (h1/h2/h3 client, proxy tunnel, HTTP thread, keep-alive pool) - #40385

Open
Jarred-Sumner wants to merge 13 commits into
claude/fetch-zero-unsafefrom
claude/httpcore-zero-unsafe
Open

Jarred-Sumner wants to merge 13 commits into
claude/fetch-zero-unsafefrom
claude/httpcore-zero-unsafe

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Aug 24, 2026 •

Copy link
Copy Markdown
Collaborator

What

Same programme as #40055 … #40383, applied to the fetch client core below the JS layer. Stacked on #40202 (base branch claude/fetch-zero-unsafe; also carries #40210's two bun_ptr commits). Retarget to main once #40202 lands.

src/http/ (excluding websocket_client/, which #40055 already did): every file → 0. lib.rs 64, HTTPContext.rs 25, HTTPThread.rs 22, session_cache.rs 18, AsyncHTTP.rs 14, ProxyTunnel.rs 12, lshpack.rs 11 (→ new bun_lshpack_sys crate), ssl_config.rs 9, ThreadSafeStreamBuffer.rs 7, SendFile.rs 3, h2_client/* 13, h3_client/* 28, and the singles — all → 0. No new unsafe in src/runtime/** or src/http_jsc/** (several removed there).

  • Request ownership: the HTTP thread no longer ptr::reads the caller's AsyncHTTP and copies state back. AsyncHTTP::schedule() moves its HTTPClient into a Box<RequestCell> (built in place) and parks it on the request; the HTTP thread takes the box; RequestRef = BackRef<RequestCell> is the socket ext tag / h2-h3 stream back-ref / tunnel owner / pending-connect waiter; a terminal result is stored on the cell and ThreadState::flush_completions() (after every socket event / drain step / h3 callback) resets the client to the owner's configuration, parks the cell back on the owner, sets handed_back, then runs the callback — re-scheduling (install retries) reuses the cell, and no callback runs under a live &mut HTTPClient.
  • Thread state: ThreadState (HTTP-thread only, Cell/RefCell) replaces the &'static mut globals; cross-thread queues live on static HTTP_THREAD: HttpThread (Guarded/atomics/OnceLock<LoopWaker>).
  • ProxyTunnel = SSLWrapper<BackRef<ProxyTunnel>> + a deferred TunnelEvent queue. h2/h3 sessions are &self + Cell/RefCell, Rc<Stream>, typed ref slots; h3 DNS pending is an index ticket. RequestUrl = pre-resolved components + optional owned backing; HTTPResponseMetadata owns its headers; SSLConfig strings are SecretCString; TLS session resumption via boringssl_sys::SslSession RAII + ssl_session_sink.
  • Primitives: bun_core::heap::new_with (in-place Box construction), FfiSliceMut, SecretCString; bun_uws_sys::{LoopWaker, SocketGroupCell, Socket::{ext_word, connect_group_tagged}, ssl_session_sink, quic::ClientHandler}; MiniEventLoop::run_uws_loop_forever; boringssl_sys::SSL::{peer_leaf_certificate, is_init_finished, configure_client, alpn_selected, set_session}, X509::to_der; bun_sys::{linux::sendfile_at, c::sendfile_plain}; bun_url::{ParsedURL, URL::from_parts}; bun_picohttp::decode_chunked; bun_lshpack_sys; HTTPClientResultCallback::{Raw, Handler(Arc), ThreadOwned}; Bun__defaultMaxHttpHeaderSize() HOST_EXPORT.

Parity deltas (intentional, small): terminal teardown happens at completion-flush right after the callback rather than just before; h3 same-thread DNS completion is observed one loop turn later; an h3 retry after a refused re-connect surfaces the original error; UnsupportedProxyProtocol also enforced on the custom-TLS cache-hit path; install/S3 retries start from pristine request config. Pre-existing issues fixed in passing: write_to_stream released the JS stream-buffer lock after a synchronous tunnel on_close could have reset the request; on_handshake expect-panicked on a missing SSL*; h2 encode_header wrote into uninitialised Vec capacity; h2 give_up_socket_ref could overwrite a parked ref; h2 attach compress-failure didn't flush the preface; h3 connect-error path detached a freed stream.

Perf (release, taskset 8 cores, perf stat -e instructions:u on the client process, interleaved ×8): 20k keep-alive fetch() against a local server — after the rebase onto #40478/#40391/#35886, −0.37 % vs the parent branch (7/8 rounds lower; an earlier base measured −2.0 %). 2k fresh-TLS and 200 × 1 MB concurrent: within noise.

Retry path (install/S3 re-schedule the same AsyncHTTP): RequestCell::arm() runs on every schedule() — first time it captures the owner's config (flags, redirect budget, hostname, if_modified_since, ownership of http_proxy/unix_socket_path), every time it resets the client to that config, rebuilds headers/url/method from the AsyncHTTP, and installs the result callback afresh. Per-attempt resources are still released on the HTTP thread at completion. Intended delta: a retry starts from the owner's forced_protocol/disable_keepalive/redirect budget rather than the previous attempt's learned values. test/cli/install/bun-install-retry.test.ts 10/10.

Testing

Debug+ASAN, --timeout 60000: 84 files across test/js/web/fetch/* (incl. http2/http3/proxy/redirect/keepalive/leak/unix/upgrade), test/js/bun/http/{proxy*,fetch*,tls-keepalive}, test/js/bun/s3/*, test/js/node/http/*, 445 test/js/node/test/parallel/test-http*/https-* — green except known sandbox reds (fetch.test.ts root-permission ×4 + 150 ms AbortSignal.timeout; fetch-tcp-stress/fetch-leak › URLSearchParams debug timeouts that pass on release; test-http-client-req-error-dont-double-fire.js identical under system bun). bun-install-registry.test.ts 245/246. Hand-driven: 1000 keep-alive fetches on one socket; redirect ×20; gzip/br/zstd/deflate; chunked streaming upload; 500× abort mid-body + GC; self-signed reject / rejectUnauthorized:false / CA / checkServerIdentity; NODE_TLS_REJECT_UNAUTHORIZED=0; HTTP + CONNECT proxy with tunnel reuse; server close mid-response/mid-chunk; Worker terminated mid-download ×5 — exit 0, no ASAN output. clippy clean; rust-check-all windows-msvc + apple-darwin pass.

@robobun

robobun commented Aug 24, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 3:17 PM PT - Sep 6th, 2026

❌ @Jarred-Sumner, your commit 95995c5 has 3 failures in Build #111593 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 40385

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

bun-40385 --bun

Comment thread src/http/lib.rs Outdated
Comment thread src/http/lib.rs
Comment thread src/http/lib.rs Outdated
Comment thread src/http/lib.rs
Comment thread src/http/lib.rs Outdated
Comment thread src/http/h3_client/ClientSession.rs Outdated

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

This pass found no issues — the earlier rounds' fixes (RequestCell::arm() restoring result_callback/OwnerConfig on re-schedule, the dead on_data tunnel branch, the stale field docs, and the retry_or_fail → retry_or_fail_busy delegation) all check out at the current tip. Given the scope — a full ownership rework of the h1/h2/h3 client, proxy tunnel, HTTP thread, and keep-alive pool across 82 files — a human review is still the right call before merge.

Also examined and ruled out: release_in_flight_for_exit dropping RequestCell without releasing proxy_tunnel/custom_ssl_ctx — those RefPtr fields drop with the box, and the loop never ticks again so the socket ext aliasing noted in the comment is moot.

Extended reasoning...

Overview

This PR is the fetch-client-core instalment of the zero-unsafe programme (stacked on #40202). It rewrites the request ownership model — AsyncHTTP now carries a Box<RequestCell> that moves to the HTTP thread and back instead of the old ptr::read bitwise-copy — and converts every src/http/ file (h1/h2/h3 client sessions, ProxyTunnel, HTTPThread, HTTPContext, keep-alive pool, TLS session cache, lshpack) to &self + Cell/RefCell/BackRef/RefPtr. New primitives land in bun_core (heap::new_with, SecretCString, FfiSliceMut), boringssl_sys (SslSession, configure_client), bun_uws_sys (LoopWaker, SocketGroupCell, ssl_session_sink), and a new bun_lshpack_sys crate. 82 files, ~900 KB diff.

Security risks

The change touches TLS handshake configuration, session-resumption caching, rejectUnauthorized propagation across pooled sockets and h2 sessions, proxy CONNECT tunneling, and the checkServerIdentity park/resume path. The PR description calls out one intended tightening (UnsupportedProxyProtocol now also enforced on the custom-TLS cache-hit path) and several pre-existing issues fixed in passing. The bug-hunting pass did not surface new security regressions, but the amount of TLS-adjacent surface rewritten here is exactly the class REVIEW.md flags for careful human review.

Level of scrutiny

High. This is production-critical, memory-safety-sensitive code (the HTTP client every fetch(), bun install, and S3 request goes through), with a wholesale ownership-model change and intentional behaviour deltas listed in the description. It is well outside the "simple, mechanical, obvious" bar for auto-approval.

Other factors

Three prior automated review rounds each found issues (two 🔴 retry-path regressions, then dead code and stale docs, then a duplication) and all were addressed with follow-up commits (86f9d13, c7d3f63, 5123b3d). This fourth pass is clean; the one candidate raised and refuted was whether release_in_flight_for_exit leaks RefPtr fields when dropping in-flight RequestCells at process exit — it does not (Drop on RefPtr<ProxyTunnel>/RefPtr<HTTPContext> releases them, and the thread parks forever afterwards so nothing dereferences the socket ext). The PR carries extensive test evidence (84 test files under debug+ASAN, bun-install-retry.test.ts 10/10 covering the previously-broken retry path, hand-driven stress scenarios) and a −2.0 % keep-alive perf claim, but the design decisions (deferred completion flush, OwnerConfig capture semantics, h3 DNS ticket indirection) warrant a maintainer's sign-off.

@Jarred-Sumner
Jarred-Sumner force-pushed the claude/fetch-zero-unsafe branch from c6d0dbf to 8eeaf3b Compare August 27, 2026 07:29
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/httpcore-zero-unsafe branch from 5123b3d to 6b50eea Compare August 27, 2026 10:19

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

Beyond the inline nit, I also checked the MaybeUninit::new(AsyncHTTP::init(..)) overwrites in NetworkTask::for_manifest/for_tarball for leaking a parked Box<RequestCell> on retry — retries re-schedule() the existing AsyncHTTP rather than re-calling for_*, so the overwritten slot never holds a handed-back cell. Also checked h3 ClientSession::close_with for double-decrementing LIVE_SESSIONS — every caller gates on self.closed first, and closed.set(true) is the first line.

Extended reasoning...

This run's inline finding is a nit (dead unsafe lifetime erasure in run_command.rs whose SAFETY comment now points at a signature this PR changed). Two further candidates on the newly-pushed retry/ownership rework were investigated and ruled out: the NetworkTask MaybeUninit overwrite path is only reached on fresh hive slots (retries go through schedule() on the existing AsyncHTTP, not a fresh for_manifest/for_tarball), and h3 close_with's LIVE_SESSIONS decrement is guarded by closed.set(true) at entry with all call sites checking closed first. Noting these here since both touch the retry/teardown semantics that earlier review rounds flagged and that commits 4c49315 and 6b50eea reworked.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/runtime/cli/run_command.rs — nit: do_preconnect's unsafe { from_raw_parts(...) } lifetime erasure is now dead — this PR changed async_http::preconnect(url: URL<'static>) to preconnect(url: &URL<'_>) (AsyncHTTP.rs:360, called at line 877), and ParsedURL::from_url copies the href, so the 'static requirement the SAFETY comment names no longer exists. Per REVIEW.md "Delete dead code in the same PR that makes it dead" and the PR's own claim of "No new unsafe in src/runtime/** (several removed there)", this leftover should be removed and url_str used directly.

    Extended reasoning...

    No runtime failure — the erasure is a no-op on process-lifetime CLI argv storage. The cost is an unnecessary unsafe block with a stale justification ("so URL<'static> (which AsyncHTTP::preconnect requires)") pointing at a signature this PR changed, left in a diff whose stated purpose is eliminating exactly this class of lifetime-erasure unsafe. Fix: delete lines 847-852 and pass url_str (the &Box<[u8]> loop variable, which derefs to &[u8]) directly to URL::parse; the resulting URL<'_> is accepted by the new preconnect(&url) and every intermediate check (is_http, hostname.is_empty, has_valid_port).

    Verification: nit — The unsafe lifetime erasure is now dead and its SAFETY comment is stale. Base branch src/http/AsyncHTTP.rs:388: pub fn preconnect(url: URL<'static>) — required 'static. HEAD src/http/AsyncHTTP.rs:360-365: pub fn preconnect(url: &URL<'_>) { Box::new(PreparedPreconnect { url: bun_url::ParsedURL::from_url(url) }).start(); } — takes a borrowed &URL<'_>. src/url/lib.rs:417-422…

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Re the outside-diff note on do_preconnect: the from_raw_parts lifetime erasure is removed in 964fd54 — preconnect(&URL<'_>) takes the borrowed URL directly now.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@Jarred-Sumner
Jarred-Sumner force-pushed the claude/fetch-zero-unsafe branch 2 times, most recently from f9e3e61 to 436a9d3 Compare August 29, 2026 07:27
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/httpcore-zero-unsafe branch from 964fd54 to 443fcd3 Compare August 29, 2026 08:29

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@Jarred-Sumner
Jarred-Sumner force-pushed the claude/fetch-zero-unsafe branch from 5e88e58 to 95995c5 Compare September 6, 2026 17:48
Jarred-Sumner and others added 12 commits September 6, 2026 18:50
…l) to zero unsafe

The HTTP thread no longer bit-copies the caller's AsyncHTTP into a
ThreadlocalAsyncHTTP and copies it back. Scheduling a request moves its
HTTPClient into a heap RequestCell (one memcpy, on the scheduling thread);
the HTTP thread takes the cell, addresses it everywhere through a BackRef
(socket ext tag, h2/h3 streams, proxy tunnel, pending-connect waiters),
and once the terminal result is produced hands the cell (client reset to
the owner's configuration) back to the AsyncHTTP before running the result
callback, so an owner may schedule the same request again. Terminal
delivery runs right after the socket event that produced it, never while a
request is still borrowed. Per-thread statics become ThreadState, reached
from every socket tag; cross-thread queues live on a Sync HttpThread
static.

- HTTPContext/KeepAlivePool/PooledSocket are Cell-based with counted
  RefPtr holders; the socket ext word is a typed ActiveSocket tag.
- ProxyTunnel: SSLWrapper<BackRef<ProxyTunnel>>; callbacks that arrive
  while the owning request is busy are queued and replayed by it.
- h2 ClientSession / h3 ClientSession are &self + Cell/RefCell with typed
  ref slots (socket_ref/conn_ref/released_ref); streams are Rc.
- h3 DNS-pending connects are tickets in ThreadState, not raw pointers
  through the resolver.
- RequestUrl keeps pre-resolved component slices (and the cached port)
  over the caller's or an owned href.
- session cache uses an SslSession RAII type and a typed session sink;
  SSLConfig owns SecretCString fields; lshpack moves to bun_lshpack_sys
  with a caller-buffer decode contract; sendfile/chunked-decode/BoringSSL
  calls go through small safe wrappers in their sys crates;
  bun_core::heap::new_with builds large values in place.
- Callers (fetch, install, s3, CLI, run --image) read results from
  HTTPClientResult (now carrying `elapsed`) instead of a copied-back
  AsyncHTTP; RawResultCallback replaces the fn-pointer pair.
…gured

The cell handed back after a terminal result kept the previous attempt's
per-hop rewrites (redirect budget, flags, proxy, hostname, unix socket
path) and lost its result callback, so an install retry delivered to
nobody. The request's client now lives in its RequestCell from the start
(AsyncHTTP::client_mut() configures it before scheduling); the first
schedule takes the owner-set fields a run may rewrite, and every later
one puts them back and sets the callback afresh. Per-attempt buffers are
freed on the HTTP thread at hand-back. S3 tasks now drop their AsyncHTTP
(and with it the cell) instead of clearing two fields of it. Completed
cells are found by slot instead of a scan, and AsyncHTTP::init records
the URL once.
…d unix_socket_path and RefPtr drop-release onto the RequestCell model
…s; h2 SessionPtr doc names RefPtr::from_this
…ePool/RequestCell model

Two lazily-allocated pools per context (TCP 64, unix 128) keyed by
Transport, longest-idle eviction on a full pool via a per-context park
sequence, ssl_config matched by content hash instead of a parked strong
ref, unix TLS entries carry the verified hostname in target_hostname,
connect_socket consults the pool, the custom-TLS-context path dials unix
sockets too, and the TLS session cache key includes the socket path.
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/httpcore-zero-unsafe branch from 443fcd3 to 11fcb8a Compare September 6, 2026 19:58

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

Jarred-Sumner pushed a commit that referenced this pull request Sep 17, 2026
#42900)

### Problem

- An HTTP/3 `fetch()` whose QUIC connection dies before the response
header can abort the process. ASan: `heap-use-after-free READ of size 8`
in `HTTPClient::fail_from_h2` (`src/http/lib.rs:2108`), from
`ClientSession::retry_or_fail`
(`src/http/h3_client/ClientSession.rs:288`). Release builds panic:
`fetch on the HTTP thread holds a ticket`.
- The retry queues the request on a new session through
`ClientContext::connect`. When no connection opens, connect fails that
session with `PendingConnect::fail_session`, which fails every request
queued on it. That dispatch frees the `AsyncHTTP` the client is part of.
Then the retry fails the same client again.

### Fix

- `connect` takes the request back off the session before it fails that
session. A `false` return leaves the request on no session, so the
caller is its only failure path, which is what the other two callers
assume.
- Correct because the session is one call old: the request `enqueue`
just queued is its only entry, so `detach` leaves `fail_session` nothing
to fail. The teardown, the registry removal and the session's last
reference do not change.
- The retried request keeps the error of the stream that closed.
`start_` still reports `ConnectionRefused` for its own failed connect.
- Verified: `test/js/web/fetch/fetch-http3-client.test.ts`, one new test
(main aborts with an empty stdout). Also the three other `fetch-http3-*`
suites, `serve-http3` and `serve-protocols`.

### Background

- The h3 fetch client pools one QUIC connection per origin.
`retry_or_fail` re-sends a stream that closed before any response
header, once, on a fresh connection.
- `ClientContext::connect` finds a pooled connection or opens one, and
queues the request. `enqueue` binds a `Stream` to the request before the
QUIC connect, because that stream has to exist when the handshake
completes.
- `HTTPClient::start_` sets
`defer_terminal_dispatch_until_connecting_is_complete` before 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.

<details><summary>Notes</summary>

**Fail-before.** With `src/` and `packages/` back on `55c11065f2`, the
new test gives `exitCode: 1` and an empty stdout. That run, the passing
run and the suites above were on `55c11065f2` plus 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 only `cargo check` and `cargo clippy` for `bun_http` were
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:

```
READ of size 8 at 0x... thread T4 (HTTP Client)
  #2 <bun_http::HTTPClient>::fail_from_h2                src/http/lib.rs:2108
  #3 <ClientSession>::retry_or_fail                      src/http/h3_client/ClientSession.rs:288
  #4 h3_client::callbacks::on_conn_close                 src/http/h3_client/callbacks.rs:151
freed by thread T4 (HTTP Client) here:
  #7 <AsyncHTTP>::on_async_http_callback_raw             src/http/AsyncHTTP.rs:783
  #10 <bun_http::HTTPClient>::fail_from_h2               src/http/lib.rs:2122
  #11 <PendingConnect>::fail_session                     src/http/h3_client/PendingConnect.rs:149
  #12 <ClientContext>::connect                           src/http/h3_client/ClientContext.rs:179
  #13 <ClientSession>::retry_or_fail                     src/http/h3_client/ClientSession.rs:287
```

A release build aborts as well, so the fault is not an ASan artifact:
`on_async_http_callback_raw` resets the client's stage before the
dealloc, so the once-only guard in `fail_from_h2` cannot 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`). An `LD_PRELOAD` shim allows the first probe
and refuses every later one, so the reconnect fails inside `connect`.
`rejectUnauthorized` against the suite's self-signed certificate fails
the handshake, which is what closes the stream before any header and
starts the retry. `localhost` answers from `is_localhost_name` as `[::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 in
`fetch-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_connect`
returning NULL, or the shared client UDP endpoint dying on a hard
`recvmsg` error 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 same
`return 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 `connect` as owning the request.
Review pushed back: it left both `if !connect { self.fail(..) }` arms in
`start_` 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 with
`ECONNREFUSED`.

**Scope.** `retry_or_fail` is 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.

</details>

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

---

**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

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants