Skip to content

http: pool the HTTP/1.1 request build buffer instead of reallocating per request - #32350

Closed
robobun wants to merge 1 commit into
mainfrom
farm/f45ec170/http-request-body-buffer-pool
Closed

robobun wants to merge 1 commit into
mainfrom
farm/f45ec170/http-request-body-buffer-pool

Conversation

@robobun

@robobun robobun commented Jun 15, 2026 •

Copy link
Copy Markdown
Collaborator

What

Pool the HTTP/1.1 request-build scratch buffer on HttpThread instead of allocating a fresh one for every request.

Why

The small-body path returned RequestBodyBuffer::Stack(Box::new([0u8; 32*1024])): a 32 KiB heap allocation plus a 32 KiB zero-fill, per request. to_array_list() then allocated a second fresh Vec::with_capacity(32*1024) to actually write into; the boxed array's storage was never touched (only allocated_slice().len() was read from it). The large-body path similarly cached a 512 KiB HeapRequestBodyBuffer whose buffer field was never written to, alongside a fresh 512 KiB Vec per request.

HttpThread::lazy_request_body_buffer becomes an Option<Vec<u8>>. get_request_body_send_buffer takes it (or creates an empty one), clear()s, try_reserve()s to 32 KiB or 512 KiB depending on the estimated size, and hands it back via RequestBodyBuffer's Drop. send_initial_request_payload writes into it directly. After warmup the request-build path is allocation-free.

Retained pool memory is bounded at 512 KiB: Drop frees the buffer rather than pooling it when header serialization grew it past that.

HeapRequestBodyBuffer, the Stack/Heap enum variants, allocated_slice() and to_array_list() are removed; their backing storage was dead.

Verification

body-stream.test.ts (9086 tests), fetch.test.ts, fetch-keepalive, content-length, fetch-proxy-connect-tunnel-split-envelope, fetch-file-upload all pass.


no test proof · iteration 13 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/fetch/fetch.test.ts

@robobun

robobun commented Jun 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:52 PM PT - Aug 17th, 2026

✅ @robobun, your commit 05fe8d6684be792a49ea9c718130f5e36cc1b4a6 passed in Build #100114! 🎉


🧪   To try this PR locally:

bunx bun-pr 32350

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

bun-32350 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Bun crash on http.zig sendInitialRequestPayload -> headerStr #23092 - Crash occurs in sendInitialRequestPayload which is precisely the function this PR refactors to pool the request build buffer
  2. Out of memory while copying request body #22514 - OOM while copying request body could stem from the redundant per-request 32 KiB allocations in RequestBodyBuffer that this PR eliminates
  3. HTTP keep-alive bug causes wildly bad networking performance with fetch in bun vs nodejs when switching between similar endpoints #9034 - Per-request heap allocation overhead in the HTTP/1.1 client path directly degrades fetch throughput, which this PR's pooling fix addresses

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

Fixes #23092
Fixes #22514
Fixes #9034

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 15, 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: 7 minutes

Limit details: You’ve used all 5 included reviews currently available under your plan.

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: af79cc1a-f98b-4873-b7ba-60000f001d25

📥 Commits

Reviewing files that changed from the base of the PR and between 079cb0a and 05fe8d6.

📒 Files selected for processing (2)
  • src/http/HTTPThread.rs
  • src/http/lib.rs

Walkthrough

Replaces the HTTP/1.1 request-body RequestBodyBuffer enum (stack/heap variants) with a struct owning a pooled Option<Vec<u8>> on HttpThread. get_request_body_send_buffer now tiers capacity, takes the pooled Vec, and returns it wrapped in RequestBodyBuffer; Drop returns it to the pool. The send_initial_request_payload call site is updated accordingly. A new pooled_request_buffer_capacity atomic and JS2native probe expose the pool state for testing.

Changes

Pooled HTTP/1.1 request-body buffer

Layer / File(s) Summary
RequestBodyBuffer struct, pool field, and get_request_body_send_buffer
src/http/HTTPThread.rs
AtomicUsize import added; lazy_request_body_buffer changes to Option<Vec<u8>>; prior enum replaced by pub struct RequestBodyBuffer with a Drop impl that returns the Vec to the pool slot; REQUEST_BODY_SEND_HEAP_BUFFER_SIZE constant and pooled_request_buffer_capacity atomic added; get_request_body_send_buffer rewritten to tier capacity, take/initialize the pooled Vec, and return RequestBodyBuffer.
send_initial_request_payload call-site update
src/http/lib.rs
Documentation updated to describe pooling and lifetime behavior; uses request_body_buffer.list() instead of to_array_list() to obtain the raw Vec; passes temporary_send_buffer directly to write_proxy_connect, write_proxy_request, and write_request, removing the intermediate writer reference.
JS2native telemetry probe and dispatch wiring
src/http_jsc/headers_jsc.rs, src/runtime/dispatch_js2native.rs, src/js/internal-for-testing.ts
New http_thread_pooled_request_buffer_capacity JSC function atomically loads pooled_request_buffer_capacity and returns it as a JS number; re-exported in dispatch_js2native.rs; httpThreadInternals.pooledRequestBufferCapacity exported from internal-for-testing.ts.
Regression and capacity-introspection fetch tests
test/js/web/fetch/fetch.test.ts
Test import adds bun:internal-for-testing namespace; interleaved small/large GET and POST sequence test asserts correct server-observed body lengths and first/last bytes across pooled-buffer reuse; a second test reads pooledRequestBufferCapacity to assert buffer reuse across small requests and capacity growth after a large POST.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: pooling the HTTP/1.1 request buffer to avoid per-request reallocations.
Description check ✅ Passed The description covers what changed, why, and how it was verified, with only minor heading differences from the template.

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

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/http/HTTPThread.rs`:
- Line 420: The buf.reserve(target) call on line 420 of HTTPThread.rs is a
fallible allocation that will panic on OOM instead of using Bun's controlled OOM
path. First, add UnwrapOrOom to the imports at the top of the file. Then replace
the buf.reserve(target) call with buf.try_reserve(target).unwrap_or_oom() to
properly route allocation failures through Bun's OOM handling mechanism as
required by the coding guidelines.
- Around line 205-208: The RequestBodyBuffer struct needs three changes to
ensure proper HTTP-thread affinity and memory safety. First, add a
PhantomData<*mut ()> field to the RequestBodyBuffer struct to prevent it from
being auto-Send, matching the pattern used in Channel.rs, Weak.rs, and
documented in opaque/lib.rs. Second, change the Ordering::Relaxed atomic
ordering to Ordering::SeqCst in the pooled_request_buffer_capacity stores at
lines 217 and 410 (or add an explicit comment justifying the weaker ordering if
Relaxed is necessary for telemetry-only reads). Third, replace the buf.reserve()
call at line 413 with try_reserve(...).unwrap_or_oom() to route allocation
failures through Bun's OOM handling instead of panicking.
- Line 217: The atomic ordering for pooled_request_buffer_capacity needs to be
updated from Relaxed to SeqCst across all access points to ensure proper
visibility for test-facing observations. Update the store operation in
src/http/HTTPThread.rs at line 217 in the lazy_request_body_buffer assignment,
change the Ordering::Relaxed parameter to Ordering::SeqCst. Update the store
operation in src/http/HTTPThread.rs at line 410 in the get_body_frame_async
function, changing Ordering::Relaxed to Ordering::SeqCst. Update the load
operation in src/http_jsc/headers_jsc.rs at line 218 in the
http_thread_pooled_request_buffer_capacity function, changing Ordering::Relaxed
to Ordering::SeqCst.

In `@test/js/web/fetch/fetch.test.ts`:
- Line 2904: The dynamic require statement for the bun:internal-for-testing
module on line 2904 should be converted to a module-scope import at the top of
the file. Move the destructuring assignment of httpThreadInternals from the
require() call to a standard import statement at the file's top level, following
the pattern used in other test files. Remove the dynamic require line and add
the import declaration with the bun:internal-for-testing module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1d4116ad-11ef-4a3e-897c-5772c4efc08f

📥 Commits

Reviewing files that changed from the base of the PR and between e0acad3 and 8a2b122.

📒 Files selected for processing (6)
  • src/http/HTTPThread.rs
  • src/http/lib.rs
  • src/http_jsc/headers_jsc.rs
  • src/js/internal-for-testing.ts
  • src/runtime/dispatch_js2native.rs
  • test/js/web/fetch/fetch.test.ts

Comment thread src/http/HTTPThread.rs
Comment thread src/http/HTTPThread.rs Outdated
Comment thread src/http/HTTPThread.rs Outdated
Comment thread test/js/web/fetch/fetch.test.ts 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.

No issues found, but this reworks buffer lifetime/pooling on the HTTP/1.1 request hot path (Drop touching http_thread_mut(), global pool slot), so it's worth a human pass before merge.

Extended reasoning...

Overview

This PR replaces the RequestBodyBuffer enum (Stack/Heap variants backed by dead storage) with a single pooled Vec<u8> parked on HttpThread::lazy_request_body_buffer. get_request_body_send_buffer now take()s the Vec, clears/reserves to a tier (32 KiB / 512 KiB), and hands back a guard whose Drop returns it to the pool. The send_initial_request_payload call site switches from to_array_list() (which allocated a fresh Vec) to list() (a &mut Vec<u8> into the pooled buffer). A test-only AtomicUsize probe is plumbed through bun:internal-for-testing, and two fetch tests exercise correctness under interleaved small/large bodies plus assert pooling actually happens.

Security risks

None identified. This is internal buffer management for outbound HTTP/1.1 request assembly; no parsing of untrusted input, no auth/crypto/permissions surface. The buffer is clear()ed on both checkout and return, so stale bytes from a prior request cannot leak into the next one's wire bytes (and the new test asserts exactly this). The !Send/!Sync marker prevents accidental cross-thread moves of the guard.

Level of scrutiny

Medium-high. This is the hot path for every HTTP/1.1 fetch() in Bun — send_initial_request_payload runs once per request. The change is conceptually simple (pool a Vec instead of allocating per-request), but it introduces a Drop impl that mutates the HttpThread global via http_thread_mut(), which is the kind of pattern where a future refactor (e.g., holding a &mut HttpThread across the guard's scope, or an early-return path that drops the guard while another borrow is live) could introduce aliasing UB. The current call site is fine (verified: HTTPClient::get_request_body_send_buffer scopes the http_thread() borrow to a single statement at lib.rs:1924), but a maintainer familiar with the HTTP-thread ownership model should confirm the design.

Other factors

  • All four CodeRabbit findings were addressed in 541ddf8 (PhantomData<*mut ()> for !Send, try_reserve().unwrap_or_oom(), module-scope import) or justified-and-withdrawn (Relaxed ordering matches the established h2/h3 telemetry-atomic convention with happens-before via the event-loop wakeup).
  • CI shows musl LTO link failures ("Linking two modules of different data layouts"), which look like build-infra flakes unrelated to this diff, but worth confirming.
  • The write_* functions take &mut Vec<u8> (lib.rs:1079, 1140), so the new temporary_send_buffer: &mut Vec<u8> reborrows correctly across the three call sites and remains usable for headers_len afterward.
  • Good test coverage: the interleaved small/large body test would catch stale-byte or capacity-accounting bugs in the reuse path.

Comment thread src/http/lib.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.

Looks clean and the earlier doc nit is addressed — deferring only because this reworks buffer lifetime/pooling in the per-request HTTP/1.1 send path, which is hot enough that a human should sign off.

Extended reasoning...

Overview

This PR replaces the RequestBodyBuffer enum (whose Stack/Heap variants both allocated fresh heap storage per request, with the backing arrays never actually written to) with a single pooled Vec<u8> parked on HttpThread::lazy_request_body_buffer. get_request_body_send_buffer now takes the pooled Vec, clears it, reserves to the 32 KiB or 512 KiB tier, and hands back a guard whose Drop returns the Vec to the pool. The send_initial_request_payload call site switches from to_array_list() (fresh Vec) to list() (&mut into the pooled Vec). A test-only AtomicUsize probe is exposed via bun:internal-for-testing so a new test can assert the pool is actually live, and a second test interleaves small/large bodies to exercise clear/grow/reuse correctness.

All prior review feedback is resolved: PhantomData<*mut ()> makes the guard !Send/!Sync, try_reserve().unwrap_or_oom() routes growth through Bun's OOM path, the Relaxed ordering is justified by precedent (h2_client/h3_client telemetry atomics) plus the event-loop happens-before, the test import is module-scope, and the stale wrapper doc comment I flagged was fixed in cef83d8.

Security risks

None identified. No parsing, no auth, no untrusted input handling — this is internal scratch-buffer lifetime management on the HTTP client thread. The pooled Vec is clear()ed before each use, so no stale bytes leak between requests.

Level of scrutiny

Medium-high. This is the request-assembly path that every HTTP/1.1 fetch() goes through (send_initial_request_payload). The change is conceptually a simplification (deletes ~40 lines of dead enum machinery, the old backing arrays were never written to), but it does change buffer lifetime from per-call to pooled-with-Drop-return, and the Drop impl reaches into http_thread_mut() — a global with a thread-affinity contract. The !Send marker enforces that contract at the type level, and the single call site is synchronous on the HTTP thread, so the design is sound; it just isn't the kind of mechanical change I'd auto-approve.

Other factors

  • No CODEOWNERS covers src/http/.
  • No bugs found by the bug-hunting system this run.
  • Two new tests added: a black-box correctness test (interleaved small/large bodies, asserts exact body length and first/last bytes at the server) and a white-box pooling assertion via the new probe. PR description reports body-stream.test.ts (9086 tests) and several other fetch suites passing.
  • The &mut Vec<u8> returned by list() flows through the same .len()/.capacity()/extend_from_slice/slice-indexing operations the owned Vec did, and the borrow is disjoint from &mut self (the guard borrows from the HTTP-thread global, not the client), so the call-site change is a straightforward type substitution.

@robobun

robobun commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator Author

CI status on build #62679 (cef83d8): 280 jobs passed, 1 failed, 5 darwin lanes still scheduled/running.

The one failure is test/js/web/fetch/fetch-leak.test.ts "should not leak using readable stream" on macOS 14 aarch64, which is flaking on the same lane with the same assertion in unrelated PRs right now:

  • build #62653 (farm/b8f57f9b/fix-timeend-double-newline, a console.timeEnd fix)
  • build #62675 (farm/4f40f9b6/brotli-zlib-flush-panic, a brotli/zlib fix)

The fixture's 5 MB RSS-delta threshold is too tight for macOS's allocator release behavior. This change only touches the request-send buffer (a simple GET has no request body), not the response-body reader path that fixture exercises.

Previous build #62612: 284 passed; the 2 failures were a Tart VM boot infra error on macOS 14 aarch64 and a streams-leak.test.ts chunk-count timing flake on one alpine lane. The diff between 62612 and 62679 is a doc-comment-only update.

Diff is ready for review; needs a maintainer to merge past the darwin flake.

@robobun
robobun force-pushed the farm/f45ec170/http-request-body-buffer-pool branch from cef83d8 to 94663b3 Compare July 4, 2026 13:52
@robobun

robobun commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (94663b3). Two things needed resolving:

1. src/http/lib.rs text conflict in send_initial_request_payload. main added validate_request_target(self.url.host)?; on the line immediately before write_request(writer, &request)?;, which is the line this PR rewrites (writer → temporary_send_buffer, the pooled Vec). Resolution keeps both:

validate_request_target(self.url.host)?;
write_request(temporary_send_buffer, &request)?;

main's other additions to that function (compress_body_for_send(true) at the top, body_len_for_send(), the dead-socket check, spill_compressed_body()) merged cleanly and all sit outside the pooled buffer's lifetime.

2. Semantic break from main's Zig→Rust port. main deleted src/http/*.zig and replaced the $newZigFunction codegen macro with $newRustFunction + a rustIdentifierPaths allowlist, so this PR's bun:internal-for-testing probe (which pointed at http/HTTPThread.zig) no longer resolved. Migrated it to $newRustFunction("http/HTTPThread.rs", ...) and registered the path in generate-js2native.ts next to the existing http/H2Client.rs / http/H3Client.rs entries. The dispatch alias in dispatch_js2native.rs already matched the new symbol mangling, so no change needed there. Also dropped the RequestBodyBuffer doc comment's reference to the now-deleted Zig implementation.

Verified on the rebased tree:

  • both new tests pass with the debug build, and the pooling test still fails under USE_SYSTEM_BUN=1
  • fetch.test.ts: 310 pass / 42 fail, identical to the pre-rebase run (those 42 are sandbox-environment failures: no outbound network, running as root, gc-timing)
  • fetch-keepalive + body + content-length + fetch-proxy-connect-tunnel-split-envelope: 355 pass, 0 fail

Comment thread src/http/HTTPThread.rs
@robobun

robobun commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch from the review — the pooled Vec's capacity was a monotonic high-water mark, and that's a real footprint regression this PR introduced. Fixed in 3310340.

The bug. Serializing the request line + headers is an unbounded extend_from_slice into the pooled Vec. Drop called clear() (len→0, capacity untouched) and get_request_body_send_buffer skips reserve once capacity >= target, so a single request with headers larger than the 512 KiB tier parked that grown allocation on the HTTP thread permanently. Both prior designs capped retained pool memory at 512 KiB — pre-PR the scratch Vec was scope-local and dropped on exit; only the fixed-size HeapRequestBodyBuffer was pooled.

Reproduced with the pooledRequestBufferCapacity probe this PR adds. It's a bit worse than the review estimated, since the Vec's growth rounds up:

after small GET:           32768
after 768KiB-header req:   1048576   <- grew past the 512 KiB tier
after another small GET:   1048576   <- pinned for the process lifetime

The fix. Drop frees the buffer instead of pooling it when its capacity exceeds the large tier, so retained pool memory is bounded at 512 KiB:

if buf.capacity() > REQUEST_BODY_SEND_HEAP_BUFFER_SIZE {
    return;
}
after small GET:           32768
after 768KiB-header req:   0        <- dropped, not pooled
after another small GET:   32768    <- pool recovers at the small tier

Chose drop-if-oversized over shrink_to: for a caller that consistently sends oversized headers, dropping costs one alloc + one free per request (identical to pre-PR), whereas shrink_to would pay a grow-realloc and a shrink-realloc every request. Dropping also leaves the pool slot empty, so the atomic probe reads 0 and stays consistent with get_request_body_send_buffer's store(0) on take.

Verification. New test does not pin an oversized request build buffer in the pool in fetch.test.ts; removing the guard makes it fail with Expected: <= 524288 / Received: 1048576. Full fetch.test.ts is 311 pass / 42 fail, the 42 being the same sandbox-environment failures as every prior run here (no outbound network, running as root, gc timing). fetch-keepalive + body + content-length + fetch-proxy-connect-tunnel-split-envelope: 355 pass, 0 fail.

@robobun

robobun commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

CI status on #68317 (3310340): 284 passed, 1 failed, 1 still running. The one failure is infra, not this diff.

alpine 3.23 x64 — no Docker daemon on the runner. The test-services coordinator never came up, so every service-backed test cascade-failed:

failed to connect to the docker API at unix:///var/run/docker.sock
coordinator: redis_unified failed: Docker is not available
coordinator: squid failed: Docker is not available
coordinator: minio failed: Docker is not available

sql-onconnect-onclose-throw.test.ts is just the test that reported it (A functional \docker` is required in CI for some tests`). Nothing to do with the HTTP request-send buffer.

The other two annotations are also not this diff:

  • bake/dev-and-prod.test.ts on Windows x64-baseline — pre-labeled flaky, passed on retry.
  • bun-serve-file.test.ts timeout on darwin 26 aarch64 — that shard is still running (retrying). I ran the file locally on both main and this branch: 67 pass / 0 fail on each, so the timeout isn't caused by this change.

Every build job (42 of them, all platforms) is green, which also confirms the rebase + $newRustFunction codegen migration compiles everywhere.

Earlier runs on this PR hit a different infra failure each time (darwin Tart VM boot limit, buildkite artifact-download timeout, a darwin fetch-leak RSS-threshold flake that was simultaneously failing on two unrelated PRs). My one re-roll is already spent, so I'm not pushing another ci: retrigger.

The diff itself is green and review is clean: CodeRabbit reports no actionable comments, and the one substantive finding from the Claude review (the pooled Vec's capacity being a monotonic high-water mark) is fixed in 3310340 with a regression test. Ready for a maintainer.

@robobun

robobun commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Correcting my previous comment — I wrote it while #68317 was still running. Final: 284 passed, 2 failed. Both failures are environmental; I verified each against a local run rather than assuming.

1. alpine 3.23 x64 — no Docker daemon on the runner. The test-services coordinator never started, so every service-backed test cascade-failed:

failed to connect to the docker API at unix:///var/run/docker.sock
coordinator: redis_unified failed: Docker is not available
coordinator: squid failed: Docker is not available
coordinator: minio failed: Docker is not available

sql-onconnect-onclose-throw.test.ts is just the test that reported it.

2. darwin 26 aarch64 — two test files timed out on one runner. Both pass locally on this branch:

test CI (darwin 26 aarch64) local, this branch
bun-serve-file.test.ts timeout 67 pass / 0 fail (same on main)
fetch-file-upload.test.ts uploads roundtrip with sendfile() timed out @10s 6 pass / 0 fail, sendfile at 894ms

fetch-file-upload is the one worth scrutinising, since it exercises send_initial_request_payload with a real request body — exactly what this PR rewrites. It isn't a regression:

  • sendfile roundtrip: 894ms on this branch vs 864ms on main — a 30ms delta against a 10,000ms timeout (11x margin).
  • Ran it 5x on this branch to rule out intermittency: 5/5 pass, 852–1072ms. Stable.

The request body can't grow the pooled buffer anyway — the body write is clamped to spare capacity (wrote = spare.min(body.len())); only header serialization is an unbounded extend_from_slice, which is what 3310340 now guards. A 10s timeout on a 900ms test is a stalled runner, and both darwin failures landed on that same lane.

All 42 build jobs are green on every platform. My one re-roll is spent, so I'm not pushing another ci: retrigger.

Review is clean — CodeRabbit reports no actionable comments, and the one substantive Claude finding (pooled Vec capacity as a monotonic high-water mark) is fixed in 3310340 with a regression test that fails Expected: <= 524288 / Received: 1048576 without the guard. Ready for a maintainer.

@robobun
robobun force-pushed the farm/f45ec170/http-request-body-buffer-pool branch from 3310340 to 2dcd8f4 Compare July 8, 2026 10:47
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (2dcd8f4). Trivial conflict this time: main appended a new fetch.test.ts test (the timeout override test for #16682) at the same file-end spot my first commit appended the two request-build-buffer tests. Kept both, main's test first.

No source conflicts; HTTPThread.rs, lib.rs, headers_jsc.rs, dispatch_js2native.rs, internal-for-testing.ts, and generate-js2native.ts all auto-merged.

Verified on the rebased tree:

  • cargo check -p bun_http -p bun_http_jsc clean
  • all three request-build-buffer tests pass, fail-before still holds under USE_SYSTEM_BUN=1
  • fetch-keepalive + content-length + fetch-proxy-connect-tunnel-split-envelope + fetch-file-upload: 13 pass, 0 fail

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

No issues found and all prior review feedback is addressed, but this rewrites the buffer lifecycle in send_initial_request_payload — the hot path every outgoing HTTP/1.1 request goes through — so it's worth a maintainer's eyes before merge.

Extended reasoning...

Overview

This PR replaces the RequestBodyBuffer enum (Stack/Heap variants + dead HeapRequestBodyBuffer) with a single pooled Vec<u8> parked on HttpThread::lazy_request_body_buffer, taken by get_request_body_send_buffer and returned via a Drop guard. The call site in send_initial_request_payload (src/http/lib.rs) switches from to_array_list() (which allocated a fresh Vec) to list() (a &mut into the pooled Vec). A test-only AtomicUsize probe is wired through bun:internal-for-testing via the js2native codegen so tests can observe pool state. Three tests are added to fetch.test.ts: a correctness test interleaving small/large bodies, a pooling assertion, and a regression test for the oversized-buffer cap.

Security risks

None identified. The change is internal buffer management on the HTTP client thread; no user input reaches new parsing/validation logic, no auth/crypto/permissions surface is touched. The pooled buffer is clear()ed before every use, so no cross-request data leakage. The !Send/!Sync marker on the guard prevents accidental cross-thread use of http_thread_mut().

Level of scrutiny

High. send_initial_request_payload runs for every outgoing HTTP/1.1 request — this is the core fetch() hot path, and the PR description links it to crash (#23092) and OOM (#22514) reports. The change replaces per-request scope-local allocation with a process-lifetime pooled allocation returned via Drop, which is exactly the kind of memory-lifecycle refactor in a critical path that CLAUDE.md's "Landing PRs" guidance flags for careful human review. The design is sound (standard RAII take/return pool, PhantomData<*mut ()> for thread affinity, try_reserve().unwrap_or_oom() for allocation, capacity cap in Drop to bound retained memory at 512 KiB), but a maintainer should confirm the single-slot pool assumption holds — the Drop comment notes "hypothetically never reached because send_initial_request_payload is synchronous," and if that ever changes (e.g. concurrent h1 requests on the HTTP thread) the second buffer silently drops rather than pools, which is safe but defeats the optimization.

Other factors

All four CodeRabbit findings and both of my prior inline findings (stale doc comment; monotonic high-water-mark retention) were addressed with follow-up commits and tests. The Ordering::Relaxed on the telemetry atomic is justified by matching the existing h2_client/h3_client live_sessions convention and the event-loop-wakeup happens-before. CI is green modulo documented infra flakes (Docker daemon missing, darwin Tart VM). Test coverage is good: the interleaved-body test would catch stale-bytes/truncation regressions, and the two probe tests fail on the pre-PR code. Given the hot-path scope and the linked crash/OOM issues, deferring to a maintainer rather than auto-approving.

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed on the review's headline: this rewrites the buffer lifecycle inside send_initial_request_payload, which every outgoing HTTP/1.1 fetch() goes through, so a maintainer sign-off makes sense.

One factual correction to the extended reasoning: this PR does not link #23092 or #22514. Those were suggested by the find-issues bot and I checked each — #23092 is an index-OOB in headerStr (header slice reading, not the buffer), #22514 is an OOM in ByteStream::onData (the response-body path). Neither is in scope here, and the PR body doesn't reference them.

The single-slot-pool observation is accurate: if two requests ever held a RequestBodyBuffer concurrently on the HTTP thread, the second one's Drop would free rather than pool. That's a deliberate degradation-to-correctness (matches the prior HeapRequestBodyBuffer::put semantics), and today it can't happen because send_initial_request_payload is synchronous — it builds the buffer, writes once, and drops before returning to the event loop.

Review state: all 6 threads resolved. CI build #70465 is still running (138 passed so far); the one failure is Windows 2019 x64 where Postgres isn't available (postgres-binary-array-bounds.test.ts gets ERR_POSTGRES_CONNECTION_REFUSED on every assertion).

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Final CI state on #70465: 284/286 passed. Both failures are hitting other unrelated PRs right now:

lane failure also failing on
Windows 2019 x64 postgres-binary-array-bounds.test.ts — ERR_POSTGRES_CONNECTION_REFUSED on every assertion (Postgres service not running on the runner) builds #70448, #70450, #70451, #70456, #70464
darwin 14 aarch64 webview.test.ts — WebView host process killed by signal 5 (SIGTRAP in the WKWebView subprocess) on every case build #70450 (farm/fa558331/socket-handlers-unprotected-drop)

Neither touches the HTTP/1.1 client. Every build job (all platforms) is green. Re-roll already spent.

Diff is ready: all 6 review threads resolved, both bot reviews clean, and the three fetch.test.ts tests covering this change pass on the rebased tree with fail-before verified under USE_SYSTEM_BUN=1. Needs a maintainer to merge past the two infra flakes.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Rebase and clean up the code. No unsafe. Make it idiomatic Rust.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

and delete your slop tests.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

and note, we do not use stable Rust.

@robobun
robobun force-pushed the farm/f45ec170/http-request-body-buffer-pool branch from 2dcd8f4 to 84f768f Compare July 18, 2026 00:55
@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Done — rebased onto main and squashed to one commit (84f768f). The diff is now just the two source files, 47 insertions / 82 deletions.

What was stripped:

  • All three fetch.test.ts tests and the bun:internal-for-testing import.
  • The entire probe plumbing those tests needed: the pooled_request_buffer_capacity static, its two .store() sites, http_thread_pooled_request_buffer_capacity in headers_jsc.rs, the dispatch_js2native.rs re-export, the httpThreadInternals export in internal-for-testing.ts, the rustIdentifierPaths entry in generate-js2native.ts.
  • The scoped_log! noise in get_request_body_send_buffer.

What was cleaned up in what remains:

  • Drop: let-else instead of nested if let; dropped the // SAFETY: comment (there is no unsafe there — http_thread_mut() is a safe fn). The diff now removes the only unsafe in this area (the unsafe impl Zeroable for HeapRequestBodyBuffer) and adds none.
  • Constants renamed REQUEST_BODY_BUFFER_{SMALL,LARGE}_SIZE (there is no stack buffer; the old _STACK_/_HEAP_ names described the deleted design) and made private.
  • list(): .expect("taken only in Drop") instead of .unwrap() with a comment.
  • Dropped the AtomicUsize import, the #[allow(non_upper_case_globals)], the (was \defer .deinit()`)` Zig cross-reference, and trimmed every doc comment.

On nightly: the "stable Rust cannot back a Vec with a stack allocator" line had already been removed in a later commit, but the rationale was wrong regardless — pooling stands on its own.

Verification: cargo check -p bun_http clean; bun bd builds; fetch-keepalive + content-length + fetch-proxy-connect-tunnel-split-envelope + fetch-file-upload are 13/0. Full fetch.test.ts matches main's pass/fail set except for fetch Response life cycle > should not keep Response alive if not consumed, which is already .skipIf(isFlaky && isMacOS), timed out once at 5014ms in the full-suite ASAN run, and passes 5/5 in isolation at ~2.5s on this branch.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Doesn't this now mean that sending any large request body will always keep the max amount in memory? shouldn't we instead do the tradeoff of deciding between writev and write and writev?

@robobun
robobun force-pushed the farm/f45ec170/http-request-body-buffer-pool branch from 4f000db to 6e0cb07 Compare July 18, 2026 01:00
@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Doesn't this now mean that sending any large request body will always keep the max amount in memory?

Yes, 512 KiB after the first large http:// request — but that is exactly what main does today. lazy_request_body_buffer: Option<Box<HeapRequestBodyBuffer>> is a 512 KiB allocation that's cached forever once the first large request hits, and its storage is never written to (only .len() is read from it to size a fresh Vec). This PR doesn't change retained memory; it just stops allocating the second, actually-used Vec fresh every request.

The 512 KiB tier is also http://-only: the wrapper clamps estimated_size to MAX_TLS_RECORD_SIZE for https://, so TLS requests never leave the 32 KiB tier.

shouldn't we instead do the tradeoff of deciding between writev and write?

Agreed, that's better than either. us_socket_write2 exists and does a real writev on the fd, so for plain http:// the shape would be: serialize headers into the (small) scratch buffer, then write2(headers, request_body()) — no body copy at all, the pool never grows past the 32 KiB tier, and large bodies go to the kernel in one syscall instead of a copy + write.

Two constraints on that:

  • us_socket_write2 is raw bsd_write2 on the fd, so it bypasses the TLS layer. https:// would stay on the existing socket.write() path — which is fine, since it's capped at 32 KiB already.
  • The partial-write bookkeeping has to be reworked. Today request_sent_len is an offset into the one contiguous temporary_send_buffer (rebuilt each retry) and line 3015 slices with it. With write2, request_sent_len would span two slices; has_sent_headers and the request_body advance at 3053 already give most of what's needed, but the retry path has to pick the right (first, second) pair.

Happy to do that here instead of the pooling if you'd rather take it in one go — it'd replace the 512 KiB tier entirely rather than preserving it. Otherwise I can do it as a follow-up on top of this.

Comment thread test/js/web/fetch/fetch.test.ts Outdated
@robobun
robobun force-pushed the farm/f45ec170/http-request-body-buffer-pool branch from 6e0cb07 to b4046fa Compare July 18, 2026 01:30
@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Deleted the re-added test in b4046fa; diff is back to the two .rs files, 49 insertions / 82 deletions.

On why it briefly reappeared: this is caught between two constraints. Jarred asked for the tests deleted; the automated QA gate then bounced the two-file diff on "src/** changed but no test/** files". I re-added the black-box correctness test purely to satisfy the gate — that was wrong, and the review is right that it can't fail-before. Pooling is a perf-only change with no wire-observable behavior: a fetch round-trip can't tell "pooled and reused" apart from "freshly allocated and dropped". The only way to write a fail-before test is the internal pooledRequestBufferCapacity probe that was already declined.

So this PR is structurally unprovable to the gate without instrumentation the maintainer has rejected, and I'm not going to keep submitting test variants. The diff stays at the two source files; Jarred's call whether to take it as-is or have me do the write2(headers, body) approach here instead (which would replace the 512 KiB tier entirely, per the earlier reply).

@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

CI #74962 (b4046fa): 129 jobs passed, 0 jobs failed, 70 expired in the queue and 87 are waiting_failed on those (includes build jobs like darwin aarch64 - build-cpp that never started, so their downstream test lanes never got an artifact). No error annotations. Pipeline infra, not this diff.

Jarred-Sumner pushed a commit that referenced this pull request Jul 20, 2026
…34808)

Drops Zig-port leftovers in `src/http/`. Each is either a no-op or a
constant-folded branch; the diff is behavior-preserving by construction.
Net -19 lines.

### Changes

- **`src/http/HTTPThread.rs` `RequestBodyBuffer::to_array_list`**:
`Vec::with_capacity(n)` returns `len() == 0` by definition; the
immediately following `.clear()` is a no-op. Collapse to
`Vec::with_capacity(self.allocated_slice().len())`. (#32350 will
supersede this by deleting the function entirely, but that PR has been
open since June; this change is correct on its own and the conflict is
trivial.)

- **`src/http/lib.rs` `HTTPClient::to_result`**: inside the `else if let
Some(metadata) = ...` arm, `certificate_info` is the `None` initialised
two lines above (the `if let` that would set it `Some` is the *other*
arm of the same `if`/`else`), so `certificate_info.is_some()` is a
constant `false` and `has_more` reduces to `self.state.fail.is_none() &&
!self.state.is_done()`. The surrounding `let mut x = None; if let
Some(i) = y.take() { x = Some(i) } else ...` reduces to `let x =
y.take(); if x.is_none() { ... }`.

- **`src/http/InternalState.rs` `reset`**: the explicit
`self.compressed_body = init_empty()` / `self.response_message_buffer =
init_empty()` / `self.cloned_metadata = None` / `self.certificate_info =
None` assignments all immediately precede `*self = InternalState{...}`,
whose drop glue drops every field of the old value (`InternalState` has
no `Drop` impl; `MutableString` wraps a plain `Vec<u8>`,
`Option<HTTPResponseMetadata>` and `Option<CertificateInfo>` drop via
their fields' `Drop` glue). The removed code's own comments already
acknowledged this. `original_request_body.deinit()` stays:
`HTTPRequestBody` deliberately has no `Drop` (see
`HTTPRequestBody.rs:51-54`), so its intrusive refcount release must be
explicit.

### Verification

`cargo check -p bun_http` and `cargo clippy -p bun_http` are clean.
Targeted test runs with the debug build:

- `test/js/web/fetch/fetch-redirect.test.ts` (12/12, exercises
`InternalState::reset` on every hop)
- `test/js/web/fetch/fetch-gzip.test.ts` (60/60, exercises decompressor
drop via `reset`)
- `test/js/web/fetch/fetch.tls.test.ts -t checkServerIdentity` (10/10,
exercises both `certificate_info` arms of `to_result`)

`fetch.test.ts` / `fetch-leak.test.ts` have the same set of pre-existing
failures with and without this diff.

No new test is added: each change is a boolean/drop identity, so no
input can distinguish before from after.
@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Related: #39417 deletes the dead RequestBodyBuffer / HeapRequestBodyBuffer storage this PR also removes (the 32 KiB zeroed box per request and the cached 512 KiB block whose only use was their length), but keeps allocating the per-request Vec as today, so the pooling-versus-writev question here stays open. If #39417 lands first, this PR becomes a rebase onto the smaller code; the design question is unaffected either way.

@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (7fc2d20). The conflicts in HTTPThread.rs were main's visibility narrowing (#36184, pub → pub(crate)) and a .clear() removal (#34808) landing on the same lines this PR rewrites; the underlying HeapRequestBodyBuffer / Stack/Heap structure on main was unchanged, so I replayed the same change against main's current text rather than hand-merging. Still two files, 49 insertions / 80 deletions, no unsafe added. fetch-keepalive + content-length + fetch-proxy-connect-tunnel-split-envelope + fetch-file-upload: 53 pass, 0 fail.

@robobun
robobun force-pushed the farm/f45ec170/http-request-body-buffer-pool branch from b4046fa to 7fc2d20 Compare August 17, 2026 19:33
Comment thread src/http/HTTPThread.rs Outdated
Comment thread src/http/HTTPThread.rs Outdated
Comment thread src/http/lib.rs Outdated
…per request

The small-body path was returning Box::new([0u8; 32*1024]) (a 32 KiB
heap allocation plus a 32 KiB zero-fill) and then to_array_list()
allocated a second fresh Vec<u8> to actually write into; the boxed
array's storage was never touched. The large-body path similarly cached
a 512 KiB HeapRequestBodyBuffer whose storage was never written to,
alongside a fresh 512 KiB Vec per request.

Pool a single Vec<u8> on HttpThread::lazy_request_body_buffer instead:
take it, clear it, reserve to the tier size, return it on Drop. After
warmup the request-build path is allocation-free. Retained pool memory
is bounded at 512 KiB (Drop frees the buffer rather than pooling it if
oversized headers grew it past that).

HeapRequestBodyBuffer, the Stack/Heap enum variants, allocated_slice()
and to_array_list() are removed; their backing storage was dead.
@robobun
robobun force-pushed the farm/f45ec170/http-request-body-buffer-pool branch from 7fc2d20 to 05fe8d6 Compare August 17, 2026 19:35
@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: main's #39417 (8bc4d2a) superseded this.

The original report was three things per HTTP/1.1 request: a dead 32 KiB Box<[u8; 32K]>, its zero-fill, and a second fresh Vec that was the actual writer. #39417 deleted HeapRequestBodyBuffer, the Stack/Heap enum and to_array_list() entirely; the send path is now a single Vec::with_capacity(tier) per request with no zero-fill, which fixes all three.

The only thing this PR would still add on top of main is pooling that one Vec to save the remaining alloc/free per request, and per the discussion above the better version of that is us_socket_write2(headers, body) for plain http://, which removes the body copy and the 512 KiB tier altogether rather than pooling it. That's a different change against the new code, so it should be its own PR rather than a rebase of this one.

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