Skip to content

http: remove lifetime-launder workarounds in client body/buffer and RareData socket-group paths - #35373

Merged
Jarred-Sumner merged 9 commits into
mainfrom
farm/bf002c9c/http-borrowck-c6
Jul 24, 2026
Merged

http: remove lifetime-launder workarounds in client body/buffer and RareData socket-group paths#35373
Jarred-Sumner merged 9 commits into
mainfrom
farm/bf002c9c/http-borrowck-c6

Conversation

@robobun

@robobun robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Part of the borrowck-audit cleanup: replace lifetime-laundering workarounds in the HTTP client with ordinary borrows, and delete the code those workarounds were propping up.

HTTPClient::to_result() / result dispatch

to_result() previously returned an HTTPClientResult<'_> whose only lifetime-carrying field, body: Option<&'a mut MutableString>, was derived from state.body_out_str (a NonNull back-reference to caller-owned storage disjoint from self). The &mut self in the signature tied that borrow to self, so every caller either laundered it through detach_lifetime() or destructured every field into locals and rebuilt the struct. dispatch_result_and_reset did the former; its own comment noted that state.reset() wrote through (*body_out_str).reset() while result.body was a live &'static mut to the same allocation.

to_result() now leaves body = None, so the result is fully owned and can be held across state.reset(). Each caller attaches the body from the NonNull afterwards. The two progress-update call sites drop their 40-line tuple-destructure blocks.

handle_on_data_headers header-accumulation buffer

to_read was held as a bun_ptr::RawSlice so subsequent &mut self calls would not trip the checker when it aliased self.state.response_message_buffer.

response_message_buffer is now mem::taken into a local at the top of the function, so to_read: &[u8] borrows either incoming_data or that local, both disjoint from &mut self. The short-read paths move the buffer back; every other path lets it drop after clone_metadata has deep-copied the parsed header slices. That makes three downstream response_message_buffer consumers unreachable, so they go in the same PR:

  • start_proxy_handshake's envelope_buf = mem::take(...) / drop(envelope_buf) pair always took an empty default (the original Bun fetch + HTTPS-over-CONNECT-proxy: raw upstream HTTP/1.1 leaks into response.body #30381 fix is now provided by the caller's take); replaced with a debug_assert.
  • handle_response_body_from_single_packet's if response_message_buffer.owns(incoming_data) cleanup is never entered; removed.
  • handle_response_body_chunked_encoding_from_single_packet's owns() in-place-decode branch (with its provenance-re-derivation unsafe) is never entered; the dispatcher already bounds incoming_data.len() to the 16 KiB scratch, so the function now copies into small unconditionally.

One behaviour difference: a chunked body that arrives in the same on_data as the tail of a split header block (i.e. the headers spanned two or more reads) is now decoded out of the scratch copy rather than in place inside response_message_buffer. That is one extra <= 16 KiB memcpy per such response; the single-read case is unchanged.

clone_metadata / pending_response

On main, state.pending_response: Option<Response<'static>> was an erased borrow into self.state.response_message_buffer, safe because that buffer lived until reset(). Once the buffer moves into a local, the same erase would dangle when handle_on_data_headers returns, enforced only by a "caller MUST invoke clone_metadata" comment on apply_multiplexed_headers. That contract is now a signature:

  • clone_metadata(&mut self, response: &picohttp::Response<'_>) deep-copies the argument.
  • apply_multiplexed_headers returns (HeaderResult, Response<'h>) borrowing its headers slice; the h2/h3 callers pass it straight to clone_metadata.
  • handle_on_data_headers keeps the parsed response as the 1xx loop's break value (scratch header array hoisted so the borrow can escape the loop), then hands it to handle_response_metadata / clone_metadata without a lifetime erase.
  • picohttp::Response::clone<'out> decouples the output lifetime from &self so the deep copy can be 'static.
  • InternalState.pending_response and picohttp::Response::detach_lifetime are deleted.

RareData::*_group accessors

ws_client_group / ws_upgrade_group / spawn_ipc_group / etc. took &VirtualMachine only to read vm.uws_loop(), but every caller reaches &mut RareData through vm.rare_data(), which already holds &mut VirtualMachine. That forced a raw-pointer split-borrow at six call sites:

let vm_ptr: *mut _ = vm;
unsafe { (*vm_ptr).rare_data().ws_client_group::<SSL>(&*vm_ptr) }

The accessors now take the *mut uws::Loop directly. The loop pointer is Copy and read before rare_data() is borrowed:

let loop_ = vm.uws_loop();
vm.rare_data().ws_client_group::<SSL>(loop_)

All nine call sites now follow this order.

Tests

The new tests in fetch-proxy-connect-tunnel-split-envelope.test.ts pin the inputs that previously took the branches this PR deletes:

Deleted Test
owns() in-place decode in _chunked_encoding_from_single_packet "chunked body in the same read as the buffered header tail"
owns() cleanup in handle_response_body_from_single_packet "content-length body in the same read as the buffered header tail"
envelope_buf take/drop in start_proxy_handshake existing "split 200 envelope" (#30381) test in the same file

The 100 Continue / multi-1xx tests exercise the moved short_read! put-back and the 1xx buffer.list.clear() path as regression guards.

Verification

bun run rust:check-all                  10 ok, 0 failed
bun bd test fetch-proxy-connect-tunnel-split-envelope.test.ts   5 pass
bun bd test fetch-redirect.test.ts / fetch-http2-client.test.ts / fetch-http3-client.test.ts   129 pass
bun bd test proxy.test.ts / proxy-stress-protocol.test.ts       164 pass
bun bd test websocket.test.js / socket.test.ts                  no new failures vs main debug
 src/http/H2Client.rs                               |   4 +-
 src/http/InternalState.rs                          |   7 -
 src/http/ProxyTunnel.rs                            |   2 -
 src/http/h2_client/ClientSession.rs                |  41 +-
 src/http/h3_client/ClientSession.rs                |  15 +-
 src/http/lib.rs                                    | 482 ++++++---------------
 src/http_jsc/websocket_client.rs                   |  17 +-
 src/http_jsc/websocket_client/WebSocketUpgradeClient.rs |  13 +-
 src/jsc/VirtualMachine.rs                          |  15 +-
 src/jsc/rare_data.rs                               |  46 +-
 src/picohttp/lib.rs                                |  45 +-
 src/runtime/api/bun/js_bun_spawn_bindings.rs       |  22 +-
 src/runtime/cli/test/parallel/Channel.rs           |  10 +-
 src/runtime/socket/socket_body.rs                  |  20 +-
 src/runtime/valkey_jsc/js_valkey.rs                |  25 +-
 src/sql_jsc/jsc.rs                                 |  18 +-
 test/js/web/fetch/fetch-proxy-connect-tunnel-split-envelope.test.ts |  80 +++-
 17 files changed, 329 insertions(+), 533 deletions(-)

Fixes #30767

- HTTPClient::to_result() now returns the result with body=None so it is
  fully owned ('static) and can be held across the state.reset() that
  follows; callers attach the caller-owned body buffer afterwards. This
  replaces the detach_lifetime() at dispatch_result_and_reset (which
  kept a live &'static mut to the body while state.reset() wrote through
  the same allocation) and collapses the 40-line tuple-destructure in
  both send_progress_update_* variants.

- handle_on_data_headers mem::take()s response_message_buffer into a
  local so to_read is a plain &[u8] borrow of either incoming_data or
  that local, both disjoint from &mut self. The short-read paths move
  the buffer back; every other path drops it after clone_metadata() has
  deep-copied the parsed header slices.

- RareData::*_group accessors take the uws loop pointer directly instead
  of &VirtualMachine. Every caller reaches &mut RareData through
  vm.rare_data(), which already holds &mut VirtualMachine; requiring a
  second &VirtualMachine just to read vm.uws_loop() forced a raw-pointer
  split-borrow at every call site. The loop pointer is read before
  rare_data() is borrowed, so no aliasing.

Net: 10 files, -152 lines, 7 unsafe blocks removed.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b85f5b60-41dd-4d8d-b458-e628c3567155

📥 Commits

Reviewing files that changed from the base of the PR and between 2609fa8 and cbda8de.

📒 Files selected for processing (17)
  • src/http/H2Client.rs
  • src/http/InternalState.rs
  • src/http/ProxyTunnel.rs
  • src/http/h2_client/ClientSession.rs
  • src/http/h3_client/ClientSession.rs
  • src/http/lib.rs
  • src/http_jsc/websocket_client.rs
  • src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/rare_data.rs
  • src/picohttp/lib.rs
  • src/runtime/api/bun/js_bun_spawn_bindings.rs
  • src/runtime/cli/test/parallel/Channel.rs
  • src/runtime/socket/socket_body.rs
  • src/runtime/valkey_jsc/js_valkey.rs
  • src/sql_jsc/jsc.rs
  • test/js/web/fetch/fetch-proxy-connect-tunnel-split-envelope.test.ts

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

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:42 AM PT - Jul 24th, 2026

@robobun, your commit cbda8de431cc957bb9554079f25f9222c567c40c passed in Build #79458! 🎉


🧪   To try this PR locally:

bunx bun-pr 35373

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

bun-35373 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. unsafe: raw-pointer borrowck workaround — should add compiler-checkable invariants #30767 - This issue describes exactly the raw-pointer borrowck workaround pattern (vm_ptr / rare_data() / ws_client_group split-borrow) that this PR eliminates by changing the *_group accessors to take *mut uws::Loop directly

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

Fixes #30767

🤖 Generated with Claude Code

Comment thread src/sql_jsc/jsc.rs
Comment thread src/http/lib.rs
robobun added a commit that referenced this pull request Jul 24, 2026
…peguard via BackRef

Stacked on #35373, which retargets the RareData *_group accessors to take
*mut uws::Loop. This PR finishes the rare_data/field-alias borrowck cleanup
cluster (three sites #35373 doesn't cover).

close_all_socket_groups was a &mut self method on RareData that didn't use
self (let _ = self;): it walks the per-VM uSockets loop's linked group list,
not RareData's embedded fields. Move it to VirtualMachine as a &self method
so global_exit and WebWorker::shutdown call it directly instead of
re-deriving &VirtualMachine through core::ptr::from_ref(self) while holding
&mut rare_data.

hot_reloader::on_file_update held the Watcher as a *mut so self could be
reborrowed in the loop body and the flush_evictions scopeguard didn't pin a
&mut across it. The reloader already stores a BackRef<Ctx> (Copy); have the
scopeguard capture that and reach the Watcher on drop, and reborrow via
self.get_context() at the two remove_at_index sites. Same BACKREF unsafe
obligation, one audited site instead of three.
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up: the trait doc on VirtualMachineSqlExt::postgres_socket_group at src/sql_jsc/jsc.rs:290-292 still describes the old *_group(.., &VirtualMachine) signature and the raw-pointer split-borrow this PR removes. Worth dropping that second sentence (the new comment block above lazy_group in rare_data.rs already covers the snapshot-loop pattern).

Also: #35376 is now stacked on this branch and covers the three remaining C4-cluster sites that don't depend on the *_group signature change (close_all_socket_groups move to VirtualMachine, the hot_reloader::on_file_update scopeguard).

robobun and others added 5 commits July 24, 2026 04:55
…_loop reads

- start_proxy_handshake: the mem::take is now a no-op (the sole caller
  already moved response_message_buffer out); replace it with a
  debug_assert on the empty buffer and keep only the
  must-not-touch-self-after-start note.
- handle_response_body_from_single_packet /
  handle_response_body_chunked_encoding_from_single_packet: the
  response_message_buffer.owns() branches are now unreachable from
  every caller since handle_on_data_headers holds the buffer in a
  local; drop them (removes one more unsafe block, the provenance
  re-derivation in the chunked path).
- socket_body.rs: hoist uws_loop() before rare_data() so it is read
  before the &mut VirtualMachine borrow, matching every other call
  site and the rare_data.rs doc comment.
- WebSocketUpgradeClient: rebind from bun_vm() locally so the line-244
  vm: &mut VirtualMachine does not stay live across the vm_ptr deref
  at poll_ref.ref().
- Drop stale trait doc on VirtualMachineSqlExt::postgres_socket_group.
These pin the behaviour of the three buffer paths the refactor touches:
the short-read put-back (byte-by-byte header delivery), 1xx interim
responses consumed from the accumulated buffer, and a chunked body
arriving in the same read as the buffered header tail (now decoded via
the scratch copy). Refactor only, so these pass on main too.
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

This is a refactor with no observable behaviour change (it removes eight unsafe blocks that existed only to route around the borrow checker, net -195 lines), so by construction there is no test that fails on main and passes here. The three new tests in fetch-proxy-connect-tunnel-split-envelope.test.ts pin the exact paths the handle_on_data_headers rewrite touches (short-read put-back, 1xx consumption from the accumulated buffer, chunked body decoded out of the header tail) and pass on both builds, which is the intended proof that the rewrite preserves behaviour.

Verification on the PR head (5007863):

bun run rust:check-all                                                      10 ok
bun bd test fetch-proxy-connect-tunnel-split-envelope.test.ts                4 pass
bun bd test fetch-redirect.test.ts / fetch-http2-client.test.ts / proxy*.test.ts   241 pass

Ready for review.

Comment thread src/sql_jsc/jsc.rs

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

My earlier findings (stale trait doc in sql_jsc/jsc.rs, dead envelope_buf in start_proxy_handshake) are both addressed at HEAD, and no new issues surfaced this run. Deferring to a human for the handle_on_data_headers rewrite and the removed owns() branches — the reasoning is sound but this is core HTTP client parsing / buffer-lifetime code and worth a maintainer's eyes.

What was reviewed:

  • to_result()'static with body = None: verified all three callers (dispatch_result_and_reset, both send_progress_update_*) attach body after state.reset(); no other callers exist.
  • handle_on_data_headers buffer take/put-back: traced every exit — short_read! and the empty-1xx path restore buffer; terminal/body paths let it drop after clone_metadata() deep-copies. Confirmed _from_single_packet variants are only reached via is_only_buffer=true / the ≤16 KiB gate, so the removed owns() branches are provably dead.
  • RareData::*_group(loop_) signature change: checked all nine call sites read vm.uws_loop() before rare_data() borrows, including the two socket_body.rs sites the description called out.
Extended reasoning...

Overview

Borrowck-audit refactor across 10 source files, net -195 lines. Three independent changes: (1) HTTPClient::to_result() now returns a fully-owned HTTPClientResult<'static> with body = None, and each caller attaches the body from state.body_out_str after state.reset() — replacing the prior detach_lifetime() launder and two 40-line destructure/rebuild blocks; (2) handle_on_data_headers now mem::takes response_message_buffer into a local so to_read is a plain &[u8] disjoint from &mut self, which makes three downstream response_message_buffer.owns(incoming_data) branches unreachable and they are removed; (3) RareData::*_group accessors take *mut uws::Loop directly instead of &VirtualMachine, eliminating the raw-pointer split-borrow at nine call sites (websocket client/upgrade, spawn IPC, test-parallel channel, Bun.connect, valkey, postgres/mysql).

Security risks

None identified. This is a lifetime/borrowck reshape with no change to what bytes are parsed, validated, or trusted. The removed unsafe blocks were provenance/lifetime workarounds, not validation. If anything the change is a net safety improvement: the acknowledged aliased-&mut in dispatch_result_and_reset (where state.reset() wrote through the same allocation as a live &'static mut) is gone, and the removed in-place chunked-decode branch had a hand-rolled provenance re-derivation.

Level of scrutiny

High. handle_on_data_headers is the HTTP/1.1 response-header state machine for every fetch() — the split-read accumulation, 1xx interim handling, proxy-tunnel handoff, and body dispatch all flow through it. The refactor changes when the accumulation buffer is dropped and what to_read borrows, and removes three downstream branches on the claim they're now unreachable. I traced each removed branch to confirm unreachability (the _from_single_packet variants are gated on is_only_buffer=true / len <= 16 KiB from the sole dispatcher, and response_message_buffer is empty at every dispatch point after the take), but this is exactly the kind of invariant a maintainer familiar with the H2/H3/proxy-tunnel entry points should confirm.

Other factors

My two earlier inline findings (stale VirtualMachineSqlExt trait doc; dead envelope_buf take in start_proxy_handshake) were both addressed in 44c420d / 5cd1a1b and are verified gone at HEAD. Three new tests in fetch-proxy-connect-tunnel-split-envelope.test.ts pin the split-read paths (byte-by-byte with 100 Continue, multi-1xx across reads, chunked body in the buffered header tail). The PR description lists a broad verification pass (proxy, HTTP/2, websocket, socket suites) and rust:check-all across all targets. This is a well-executed refactor; deferring only because the guidelines say not to auto-approve complex changes to critical paths.

Comment thread src/http/lib.rs Outdated
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Two corrections to the framing, plus a follow-up note:

"No observable behaviour change" isn't quite true. Deleting the owns() in-place branch in handle_response_body_chunked_encoding_from_single_packet means the split-header case (!needs_move — headers arrived across ≥2 reads and the chunked body lands in the same on_data as the header tail) now does small[..in_len].copy_from_slice + phr_decode_chunked in scratch, where main decoded in place inside response_message_buffer. That's one extra ≤16 KiB memcpy per such response. The single-read case is unchanged (it already copied). Say that in the body instead of "None of this changes behaviour."

The tests are for the deleted code paths, not for the borrowck reshape — the body should say so instead of "there is no fail-before test." Concretely, the buffer take/put-back made three things unreachable and the PR deletes them; the tests pin those:

Deleted Test
owns() in-place decode branch in ..._chunked_encoding_from_single_packet "chunked body in the same read as the buffered header tail" — this is exactly the input shape that used to take that branch
owns() cleanup in handle_response_body_from_single_packet (covered by the same test — non-chunked would need is_only_buffer + split headers, worth one more case)
envelope_buf take/drop in start_proxy_handshake (the original #30381 fix) existing "split 200 envelope" test in this file

The other two new tests exercise moved code (short_read! and the 1xx buffer.list.clear() path) — fine to keep, but they're regression guards, not the justification. Also regenerate the inline diffstat in the body; it still says 10 files / 342 lines with no test file.

Follow-up, not this PR: for non-compressed chunked bodies _from_single_packet is strictly worse than _from_multiple_packets — scratch copy + append_slice_exact (two copies, one malloc) vs append_slice + decode-in-tail + truncate (one copy). The <= 16 * 1024 dispatch should probably keep the single-packet path only for encoding.is_compressed() (where decompress_bytes from scratch skips materializing compressed_body). Needs a release-build bench before touching, so filing separately.

handle_on_data_headers now moves the response_message_buffer into a
local, so a pending_response: Option<Response<'static>> stored in
InternalState would dangle once this function returned. Instead of
documenting that every path either calls clone_metadata (which
deep-copies) or never reads the field again, carry the parsed response
as a local with its real lifetime and pass it into clone_metadata by
reference:

- clone_metadata(&mut self, &Response<'_>) deep-copies the argument; the
  compiler now checks the backing buffer outlives the copy.
- apply_multiplexed_headers returns (HeaderResult, Response<'h>) borrowing
  the headers slice; the h2/h3 callers pass it straight to clone_metadata
  and no longer rely on a 'call clone_metadata before freeing' contract.
- handle_on_data_headers keeps the parsed response as the 1xx loop's
  break value (shared_resp hoisted out of the loop so the borrow escapes).
- Response::clone<'out> decouples the output lifetime from &self so the
  deep copy can be 'static while the input borrows a local.
- InternalState.pending_response and picohttp::Response::detach_lifetime
  are deleted (no remaining callers).
- Add a content-length single-packet test alongside the chunked one.
@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Body updated per the second comment: called out the one extra <= 16 KiB memcpy in the split-header chunked case, reframed the tests as pinning the inputs that used to take the deleted branches (with a table), added the content-length single-packet case alongside the chunked one, and regenerated the diffstat (17 files now that clone_metadata / pending_response is in).

Filing the _from_single_packet vs _from_multiple_packets dispatch observation separately as suggested; leaving it out of this PR.

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

CI build 79458: the diff is green. The four reds are all marked flaky (passed on retry) and none touch this diff: webview-chrome (animation click), html-rewriter-leak (RSS threshold), no-orphans (macOS process cleanup), multi-run (CLI output race). Previous build 79420 had one [new] red, test-https-server-connections-checking-leak.js, which was a file-watcher EAGAIN panic on the runner (resource exhaustion, not this diff) and did not recur.

All of the fetch/proxy/h2/h3/websocket lanes that exercise the changed code passed.

robobun added a commit that referenced this pull request Jul 24, 2026
…peguard via BackRef

Stacked on #35373, which retargets the RareData *_group accessors to take
*mut uws::Loop. This PR finishes the rare_data/field-alias borrowck cleanup
cluster (three sites #35373 doesn't cover).

close_all_socket_groups was a &mut self method on RareData that didn't use
self (let _ = self;): it walks the per-VM uSockets loop's linked group list,
not RareData's embedded fields. Move it to VirtualMachine as a &self method
so global_exit and WebWorker::shutdown call it directly instead of
re-deriving &VirtualMachine through core::ptr::from_ref(self) while holding
&mut rare_data.

hot_reloader::on_file_update held the Watcher as a *mut so self could be
reborrowed in the loop body and the flush_evictions scopeguard didn't pin a
&mut across it. The reloader already stores a BackRef<Ctx> (Copy); have the
scopeguard capture that and reach the Watcher on drop, and reborrow via
self.get_context() at the two remove_at_index sites. Same BACKREF unsafe
obligation, one audited site instead of three.
@Jarred-Sumner
Jarred-Sumner merged commit e935630 into main Jul 24, 2026
53 of 54 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/bf002c9c/http-borrowck-c6 branch July 24, 2026 10:51
robobun added a commit that referenced this pull request Jul 24, 2026
…peguard via BackRef

Stacked on #35373, which retargets the RareData *_group accessors to take
*mut uws::Loop. This PR finishes the rare_data/field-alias borrowck cleanup
cluster (three sites #35373 doesn't cover).

close_all_socket_groups was a &mut self method on RareData that didn't use
self (let _ = self;): it walks the per-VM uSockets loop's linked group list,
not RareData's embedded fields. Move it to VirtualMachine as a &self method
so global_exit and WebWorker::shutdown call it directly instead of
re-deriving &VirtualMachine through core::ptr::from_ref(self) while holding
&mut rare_data.

hot_reloader::on_file_update held the Watcher as a *mut so self could be
reborrowed in the loop body and the flush_evictions scopeguard didn't pin a
&mut across it. The reloader already stores a BackRef<Ctx> (Copy); have the
scopeguard capture that and reach the Watcher on drop, and reborrow via
self.get_context() at the two remove_at_index sites. Same BACKREF unsafe
obligation, one audited site instead of three.
robobun added a commit that referenced this pull request Jul 25, 2026
… PRs merged

#35370, #35392, #35367, #35373 removed 26 markers on main (368->342);
8 of those overlapped with this PR's sites. After rebase this PR removes
342->293 = 49 markers.
Jarred-Sumner pushed a commit that referenced this pull request Jul 27, 2026
…areData socket-group paths (#35373)

Part of the borrowck-audit cleanup: replace lifetime-laundering
workarounds in the HTTP client with ordinary borrows, and delete the
code those workarounds were propping up.

## `HTTPClient::to_result()` / result dispatch

`to_result()` previously returned an `HTTPClientResult<'_>` whose only
lifetime-carrying field, `body: Option<&'a mut MutableString>`, was
derived from `state.body_out_str` (a `NonNull` back-reference to
caller-owned storage disjoint from `self`). The `&mut self` in the
signature tied that borrow to `self`, so every caller either laundered
it through `detach_lifetime()` or destructured every field into locals
and rebuilt the struct. `dispatch_result_and_reset` did the former; its
own comment noted that `state.reset()` wrote through
`(*body_out_str).reset()` while `result.body` was a live `&'static mut`
to the same allocation.

`to_result()` now leaves `body = None`, so the result is fully owned and
can be held across `state.reset()`. Each caller attaches the body from
the `NonNull` afterwards. The two progress-update call sites drop their
40-line tuple-destructure blocks.

## `handle_on_data_headers` header-accumulation buffer

`to_read` was held as a `bun_ptr::RawSlice` so subsequent `&mut self`
calls would not trip the checker when it aliased
`self.state.response_message_buffer`.

`response_message_buffer` is now `mem::take`n into a local at the top of
the function, so `to_read: &[u8]` borrows either `incoming_data` or that
local, both disjoint from `&mut self`. The short-read paths move the
buffer back; every other path lets it drop after `clone_metadata` has
deep-copied the parsed header slices. That makes three downstream
`response_message_buffer` consumers unreachable, so they go in the same
PR:

- `start_proxy_handshake`'s `envelope_buf = mem::take(...)` /
`drop(envelope_buf)` pair always took an empty default (the original
#30381 fix is now provided by the caller's take); replaced with a
`debug_assert`.
- `handle_response_body_from_single_packet`'s `if
response_message_buffer.owns(incoming_data)` cleanup is never entered;
removed.
- `handle_response_body_chunked_encoding_from_single_packet`'s `owns()`
in-place-decode branch (with its provenance-re-derivation `unsafe`) is
never entered; the dispatcher already bounds `incoming_data.len()` to
the 16 KiB scratch, so the function now copies into `small`
unconditionally.

One behaviour difference: a chunked body that arrives in the same
`on_data` as the tail of a split header block (i.e. the headers spanned
two or more reads) is now decoded out of the scratch copy rather than in
place inside `response_message_buffer`. That is one extra `<= 16 KiB`
`memcpy` per such response; the single-read case is unchanged.

## `clone_metadata` / `pending_response`

On `main`, `state.pending_response: Option<Response<'static>>` was an
erased borrow into `self.state.response_message_buffer`, safe because
that buffer lived until `reset()`. Once the buffer moves into a local,
the same erase would dangle when `handle_on_data_headers` returns,
enforced only by a "caller MUST invoke `clone_metadata`" comment on
`apply_multiplexed_headers`. That contract is now a signature:

- `clone_metadata(&mut self, response: &picohttp::Response<'_>)`
deep-copies the argument.
- `apply_multiplexed_headers` returns `(HeaderResult, Response<'h>)`
borrowing its `headers` slice; the h2/h3 callers pass it straight to
`clone_metadata`.
- `handle_on_data_headers` keeps the parsed response as the 1xx loop's
break value (scratch header array hoisted so the borrow can escape the
loop), then hands it to `handle_response_metadata` / `clone_metadata`
without a lifetime erase.
- `picohttp::Response::clone<'out>` decouples the output lifetime from
`&self` so the deep copy can be `'static`.
- `InternalState.pending_response` and
`picohttp::Response::detach_lifetime` are deleted.

## `RareData::*_group` accessors

`ws_client_group` / `ws_upgrade_group` / `spawn_ipc_group` / etc. took
`&VirtualMachine` only to read `vm.uws_loop()`, but every caller reaches
`&mut RareData` through `vm.rare_data()`, which already holds `&mut
VirtualMachine`. That forced a raw-pointer split-borrow at six call
sites:

```rust
let vm_ptr: *mut _ = vm;
unsafe { (*vm_ptr).rare_data().ws_client_group::<SSL>(&*vm_ptr) }
```

The accessors now take the `*mut uws::Loop` directly. The loop pointer
is `Copy` and read before `rare_data()` is borrowed:

```rust
let loop_ = vm.uws_loop();
vm.rare_data().ws_client_group::<SSL>(loop_)
```

All nine call sites now follow this order.

## Tests

The new tests in `fetch-proxy-connect-tunnel-split-envelope.test.ts` pin
the inputs that previously took the branches this PR deletes:

| Deleted | Test |
| --- | --- |
| `owns()` in-place decode in `_chunked_encoding_from_single_packet` |
"chunked body in the same read as the buffered header tail" |
| `owns()` cleanup in `handle_response_body_from_single_packet` |
"content-length body in the same read as the buffered header tail" |
| `envelope_buf` take/drop in `start_proxy_handshake` | existing "split
200 envelope" (#30381) test in the same file |

The 100 Continue / multi-1xx tests exercise the moved `short_read!`
put-back and the 1xx `buffer.list.clear()` path as regression guards.

## Verification

```
bun run rust:check-all                  10 ok, 0 failed
bun bd test fetch-proxy-connect-tunnel-split-envelope.test.ts   5 pass
bun bd test fetch-redirect.test.ts / fetch-http2-client.test.ts / fetch-http3-client.test.ts   129 pass
bun bd test proxy.test.ts / proxy-stress-protocol.test.ts       164 pass
bun bd test websocket.test.js / socket.test.ts                  no new failures vs main debug
```

```
 src/http/H2Client.rs                               |   4 +-
 src/http/InternalState.rs                          |   7 -
 src/http/ProxyTunnel.rs                            |   2 -
 src/http/h2_client/ClientSession.rs                |  41 +-
 src/http/h3_client/ClientSession.rs                |  15 +-
 src/http/lib.rs                                    | 482 ++++++---------------
 src/http_jsc/websocket_client.rs                   |  17 +-
 src/http_jsc/websocket_client/WebSocketUpgradeClient.rs |  13 +-
 src/jsc/VirtualMachine.rs                          |  15 +-
 src/jsc/rare_data.rs                               |  46 +-
 src/picohttp/lib.rs                                |  45 +-
 src/runtime/api/bun/js_bun_spawn_bindings.rs       |  22 +-
 src/runtime/cli/test/parallel/Channel.rs           |  10 +-
 src/runtime/socket/socket_body.rs                  |  20 +-
 src/runtime/valkey_jsc/js_valkey.rs                |  25 +-
 src/sql_jsc/jsc.rs                                 |  18 +-
 test/js/web/fetch/fetch-proxy-connect-tunnel-split-envelope.test.ts |  80 +++-
 17 files changed, 329 insertions(+), 533 deletions(-)
```

Fixes #30767

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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.

unsafe: raw-pointer borrowck workaround — should add compiler-checkable invariants

3 participants