Skip to content

fetch: send every request header instead of silently dropping past the 250th - #36433

Open
robobun wants to merge 7 commits into
mainfrom
farm/d18a441d/fetch-request-header-cap
Open

fetch: send every request header instead of silently dropping past the 250th#36433
robobun wants to merge 7 commits into
mainfrom
farm/d18a441d/fetch-request-header-cap

Conversation

@robobun

@robobun robobun commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

fetch() silently dropped every user request header past the 250th. The request still went out and resolved 200, but with those fields missing on the wire. A late Authorization, signature, or forwarded header just vanished with no error and no warning.

Repro

import net from "node:net";
const N = 300;
let done; const cap = new Promise(r => (done = r));
const srv = net.createServer(s => {
  let b = Buffer.alloc(0);
  s.on("data", d => {
    b = Buffer.concat([b, d]);
    if (b.indexOf("\r\n\r\n") >= 0) {
      s.end("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok");
      done(b.toString("latin1").split("\r\n\r\n")[0]);
    }
  });
});
await new Promise(r => srv.listen(0, "127.0.0.1", r));
const headers = {};
for (let i = 0; i < N; i++) headers["x-" + String(i).padStart(4, "0")] = "v";
const res = await fetch(`http://127.0.0.1:${srv.address().port}/`, { headers });
await res.text();
const lines = (await cap).split("\r\n").slice(1);
srv.close();
const got = lines.filter(l => l.startsWith("x-")).length;
console.log(`status ${res.status}: sent ${N} user headers, origin received ${got} (${lines.length} fields total)`);

Before: status 200: sent 300 user headers, origin received 250 (255 fields total)
Node/undici: status 200: sent 300 user headers, origin received 300 (307 fields total)

Cause

src/http/lib.rs build_request() wrote user headers into the fixed 256-slot SHARED_REQUEST_HEADERS_BUF scratch array with MAX_USER_HEADERS = 256 - 6 reserved for defaults, and the loop did // Silently drop excess headers to stay within the fixed-size request header buffer. on overflow. Request-side sibling of the response-side cap fixed in #34923, except this one never surfaced an error.

Fix

Mirror #34923: keep the 256-slot inline array for the common case and spill to a per-HTTP-thread Vec<picohttp::Header> sized from header_names.len() + MAX_DEFAULT_HEADERS when that would overflow. Every user header is written. The will_append guard and MAX_USER_HEADERS bound are gone since the buffer is always large enough now. No request-side field-count cap, matching Node/undici.

Verification

$ bun bd test test/js/web/fetch/fetch_headers.test.js
(pass) Headers > sends every request header field on the wire (251 user headers)
(pass) Headers > sends every request header field on the wire (300 user headers)
 10 pass  0 fail

The new cases fail on the released binary with Expected: 251 / Received: 250 and Expected: 300 / Received: 250.


[review] gate passed · iteration 1 · 4 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/fetch-header-count-limit.test.ts test/js/web/fetch/fetch_headers.test.js
bun test v1.4.0 (739f86cd3)

test/js/bun/http/fetch-header-count-limit.test.ts:
54 |   const res = await fetch(`http://127.0.0.1:${port}/test`, { headers });
55 |   expect(res.status).toBe(200);
56 | 
57 |   const { customCount } = await res.json();
58 |   // There is no request-side field-count cap; every header reaches the origin.
59 |   expect(customCount).toBe(300);
                           ^
error: expect(received).toBe(expected)

Expected: 300
Received: 250

      at <anonymous> (/workspace/bun/test/js/bun/http/fetch-header-count-limit.test.ts:59:23)
(fail) fetch with many headers does not crash [1540.55ms]
(pass) fetch with exactly 250 custom headers sends all of them [876.23ms]
(pass) user-supplied Host/User-Agent/Accept are sent alongside >250 other headers [957.69ms]

test/js/web/fetch/fetch_headers.test.js:
(pass) Headers > Headers should work [13.79ms]
(pass) Headers > Header names must be valid [6.93ms]
(pass) Headers > Header value
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (d2b837ebc)

test/js/bun/http/fetch-header-count-limit.test.ts:
(pass) fetch with many headers does not crash [10.98ms]
(pass) fetch with exactly 250 custom headers sends all of them [4.37ms]
(pass) user-supplied Host/User-Agent/Accept are sent alongside >250 other headers [3.38ms]

test/js/web/fetch/fetch_headers.test.js:
(pass) Headers > Headers should work [0.71ms]
(pass) Headers > Header names must be valid [0.14ms]
(pass) Headers > Header values must be valid [0.09ms]
(pass) Headers > isomorphic-encodes latin-1 (obs-text) request header values on the wire [2.66ms]
(pass) Headers > sends every request header field on the wire (251 user headers) [4.73ms]
(pass) Headers > sends every request header field on the wire (300 user headers) [5.66ms]
(pass) Headers > Invalid values for well-known headers name the header, not its index [0.12ms]
(pass) Headers > repro 1602 [0.43ms]
(pass) Headers > toJSON() > should provide lowercase header names [0.08ms]
(pass) Headers > toJSON() > should handle numeric string header names [0.04ms]

 13 pass
 0 fail
 28 expect() calls
Ran 13 tests across 2 files. [184.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/fetch-header-count-limit.test.ts test/js/web/fetch/fetch_headers.test.js
bun test v1.4.0 (739f86cd3)

test/js/bun/http/fetch-header-count-limit.test.ts:
(pass) fetch with many headers does not crash [1552.62ms]
(pass) fetch with exactly 250 custom headers sends all of them [873.43ms]
(pass) user-supplied Host/User-Agent/Accept are sent alongside >250 other headers [970.76ms]

test/js/web/fetch/fetch_headers.test.js:
(pass) Headers > Headers should work [14.04ms]
(pass) Headers > Header names must be valid [7.06ms]
(pass) Headers > Header values must be valid [5.96ms]
(pass) Headers > isomorphic-encodes latin-1 (obs-text) request header values on the wire [56.25ms]
(pass) Headers > sends every request header field on the wire (251 user headers) [898.70ms]
(pass) Headers > sends every request header field on the wire (300 user headers) [1238.53ms]
(pass) Headers > Invalid values for well-known headers name the header, not its index [5.76ms]
(pass) Headers > repro 1602 [12.72ms]
(pass) Headers > toJSON() > should provide 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 950ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92m   Compiling�[0m bun_collections v0.0.0 (/workspace/bun/src/collections)
�[1m�[92m   Compiling�[0m
... (truncated)
diff hotspot
src/http/H2Client.rs                              |  10 +-
 src/http/lib.rs                                   | 115 ++++++++++------------
 test/js/bun/http/fetch-header-count-limit.test.ts |  44 +++++----
 test/js/web/fetch/fetch_headers.test.js           |  37 +++++++
 4 files changed, 117 insertions(+), 89 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                               reads  edits  tests
src/http/H2Client.rs                                   2      1      0
src/http/lib.rs                                        4      6      0
test/js/bun/http/fetch-header-count-limit.test.ts      2      4      0
test/js/web/fetch/fetch_headers.test.js                1      2      0

…e 250th

build_request() wrote user headers into a fixed 256-slot per-thread
scratch array (minus 6 reserved for defaults), silently dropping any
header past the 250th. The request then resolved 200 with those fields
missing on the wire, so a late Authorization or signature header just
vanished with no error.

Mirror the response-side fix: keep the 256-slot inline array for the
common case and spill to a per-HTTP-thread Vec sized from the user
header count when it would overflow. Every user header is now written,
matching Node/undici which have no request-side field-count cap. The
will_append guard and MAX_USER_HEADERS bound are gone since the buffer
is always large enough.
@robobun

robobun commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced with the loopback capture in the PR body: 300 user headers in, 250 on the wire (255 fields), no error. After the fix all 300 arrive.

bun bd test test/js/web/fetch/fetch_headers.test.js and bun bd test test/js/bun/http/fetch-header-count-limit.test.ts pass. The new it.each([251, 300]) cases in fetch_headers.test.js and the updated fetch with many headers does not crash case in fetch-header-count-limit.test.ts fail on the released binary with Received: 250.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

HTTP header buffering

Layer / File(s) Summary
Inline and overflow scratch storage
src/http/lib.rs
HTTP-thread scratch storage now exposes a 256-entry inline header array and a resizable overflow vector.
Large-header request construction
src/http/lib.rs, src/http/H2Client.rs
HTTPClient::build_request sizes storage from user headers and defaults, preserves header overrides beyond the inline capacity, and updates HTTP/HTTP2 request-buffer safety notes.
Large-header fetch coverage
test/js/web/fetch/fetch_headers.test.js, test/js/bun/http/fetch-header-count-limit.test.ts
Fetch tests send large custom-header sets and verify received headers, response status, and special-header overrides.

Suggested reviewers: jarred-sumner

🚥 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: fetch now sends all request headers instead of dropping those beyond the old limit.
Description check ✅ Passed The description covers the problem, cause, fix, repro, and verification, though its headings differ 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: 1

🤖 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/lib.rs`:
- Around line 2372-2383: Audit request header validation and construction paths
surrounding the header buffer sizing, including JS Headers/fetch option handling
and proxy or tunnel requests, to confirm whether a header-count or
aggregate-size cap is required after the request-side field-count cap removal.
If an upstream limit exists, enforce or reuse it before `header_names` reaches
the sizing logic; otherwise preserve the current `needed` calculation and
document the validated unbounded behavior.
🪄 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: 4b5a203a-b5d8-4c45-b91f-bd42f3a9d3ef

📥 Commits

Reviewing files that changed from the base of the PR and between e7ddfeb and 195b14c.

📒 Files selected for processing (2)
  • src/http/lib.rs
  • test/js/web/fetch/fetch_headers.test.js

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.

LGTM — targeted fix that swaps the fixed 256-slot request-header scratch for an overflow Vec when header_names.len() + 6 exceeds it, removing the silent 250-header truncation.

What was reviewed:

  • Bounds: post-loop appends max out at 6 defaults (Connection/UA/Accept/Host/Accept-Encoding + one of Content-Length|Transfer-Encoding), so header_count <= needed and the slice index can't overrun.
  • Lifetime erasure over the Vec-backed slice: all four build_request() call sites (lib.rs:2985, lib.rs:3475, H2Client.rs:136→ClientSession.rs:448, h3_client/encode.rs:48) serialize the returned Request immediately before any subsequent build_request() could resize; same invariant the pre-existing static-array detach_lifetime already relied on.
  • will_append removal is safe now that the buffer is always large enough; override flags revert to unconditional as they were before the cap was added.
  • Tests cover the boundary (251) and well past it (300), assert exact header set equality, and were verified to fail on the released binary.
Extended reasoning...

Overview

The PR fixes silent request-header truncation in fetch(): build_request() in src/http/lib.rs previously wrote user headers into a fixed 256-slot per-HTTP-thread scratch array, reserving 6 slots for defaults and dropping anything past index 250 with no error. The fix keeps the inline array as the fast path and spills to a per-HTTP-thread Vec<picohttp::Header> sized to header_names.len() + MAX_DEFAULT_HEADERS when needed. The now-redundant will_append guard and MAX_USER_HEADERS bound are removed. Two it.each([251, 300]) regression tests in test/js/web/fetch/fetch_headers.test.js capture the raw wire bytes via a net.Server and assert every x-NNNN header arrives.

Security risks

None introduced. The change removes a silent-drop path; there is no new parsing of untrusted input, and the request-side has no field-count cap (matching Node/undici — servers enforce their own limits). The overflow Vec is sized from the caller-supplied header count, which is already bounded by whatever the JS side constructed.

Level of scrutiny

Moderate. This is core HTTP client code with an unsafe { detach_lifetime(...) } over the returned header slice, and the backing storage now includes a growable Vec whose buffer moves on resize. However, the SAFETY invariant is unchanged from the pre-existing static-array case: the erased 'static slice was already only valid until the next build_request() overwrote the array. The Vec resize is the same hazard in a different shape. I traced all four call sites (h1 initial send, h1 on_writable, h2 ClientSession::attach, h3 encode) and each serializes via write_request/encode::write_request immediately, on the single HTTP thread, before another build_request() can run. The updated SAFETY comment documents this correctly.

Other factors

  • Bounds are provably safe: the user-header loop writes at most header_names.len() entries (some continue early), then at most 6 defaults are appended — I read lines 2463–2531 to confirm the sixth slot is Content-Length XOR Transfer-Encoding, never both. So header_count <= needed always holds.
  • The scratch::request_headers_overflow() accessor follows the exact pattern of the four existing accessors in mod scratch and is covered by the same module-level INVARIANT comment (single HTTP thread, sole live borrow).
  • The tests follow the file's existing raw-socket capture pattern (same as the latin-1 wire test just above), use Promise.withResolvers with error wired to reject, close the server in finally, and assert exact set equality on the received header lines rather than just a count.
  • Minor: the SAFETY comment in H2Client.rs:129-135 still names only SHARED_REQUEST_HEADERS_BUF, not the overflow Vec — a doc-staleness nit, not a correctness issue, since the invariant holds identically for both buffers.

Comment thread src/http/H2Client.rs
cd1bc69 inadvertently reverted src/http/lib.rs to main while updating
the H2Client.rs SAFETY comment. This restores the lib.rs change from
195b14c so the request-header overflow Vec and the unbounded
build_request() loop are back in place.
Comment thread src/http/lib.rs Outdated
Comment thread src/http/lib.rs
Comment thread src/http/lib.rs
Comment thread src/http/H2Client.rs
robobun added 2 commits July 30, 2026 08:33
The test at test/js/bun/http/fetch-header-count-limit.test.ts asserted the
old behaviour (300 headers in, 250 on the wire). Update it to expect every
header to reach the origin and retitle the overflow-path test for what it
now exercises.

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/bun/http/fetch-header-count-limit.test.ts (1)

96-99: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Assert the custom special-header values, not only their names.

These checks also pass if the user values are dropped and default Host, User-Agent, and Accept headers are emitted. Return the parsed values from the server and assert the exact custom values.

As per coding guidelines, tests must prove the intended reason and assert the strongest invariant.

🤖 Prompt for 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.

In `@test/js/bun/http/fetch-header-count-limit.test.ts` around lines 96 - 99,
Update the assertions in the fetch header-limit test to validate the parsed
header values returned by the server, not just the presence of “host”,
“user-agent”, and “accept” names. Assert each header matches the exact custom
value configured by the test, preserving the existing header-name checks if
useful.

Source: Coding guidelines

🤖 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 `@test/js/bun/http/fetch-header-count-limit.test.ts`:
- Around line 76-87: Align the test description with the setup in “user-supplied
Host/User-Agent/Accept are sent alongside >250 other headers” by either renaming
it to state exactly 250 other headers or changing the loop to generate 251; keep
the existing boundary-path assertions unchanged.

---

Outside diff comments:
In `@test/js/bun/http/fetch-header-count-limit.test.ts`:
- Around line 96-99: Update the assertions in the fetch header-limit test to
validate the parsed header values returned by the server, not just the presence
of “host”, “user-agent”, and “accept” names. Assert each header matches the
exact custom value configured by the test, preserving the existing header-name
checks if useful.
🪄 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: decd27cc-a97e-42a0-baed-d13c41af3f65

📥 Commits

Reviewing files that changed from the base of the PR and between 195b14c and c8fd611.

📒 Files selected for processing (3)
  • src/http/H2Client.rs
  • src/http/lib.rs
  • test/js/bun/http/fetch-header-count-limit.test.ts

Comment thread test/js/bun/http/fetch-header-count-limit.test.ts
The previous assertions only checked the header names were present, which
also passes under the old 250-cap behaviour (the defaults are appended
instead). Return the parsed values from the raw server and assert the
user's custom values. Also bump the filler to 251 so the test title is
literally accurate.

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 test/js/bun/http/fetch-header-count-limit.test.ts:94-99 — The renamed title claims "user-supplied Host/User-Agent/Accept are sent", but the assertions still only check headerNames.toContain("host"|"user-agent"|"accept") — presence of the name, not the user-supplied value. Under the pre-PR behavior these names were also present (as Bun's defaults, since the override flags weren't set for dropped headers), so this test passes both with and without the fix. Either assert the received values (custom-host.example.com, custom-agent, text/html) or drop the test — the customCount === 300 case above and the new it.each([251, 300]) cases already cover the overflow path.

    Extended reasoning...

    What the bug is

    The PR renamed this test from "default headers preserved when user headers overflow the buffer" to "user-supplied Host/User-Agent/Accept are sent alongside >250 other headers", and rewrote the setup comment to say the special headers "exercise the overflow path". But the assertions were left byte-identical to main:

    expect(headerNames).toContain("host");
    expect(headerNames).toContain("user-agent");
    expect(headerNames).toContain("accept");

    These check only that a header named host/user-agent/accept reached the origin. They cannot distinguish the user-supplied Host: custom-host.example.com from Bun's default Host: 127.0.0.1:<port>. The raw server helper (makeRawHttpServer) only returns headerNames, not values, so the values custom-host.example.com / custom-agent / text/html set on lines 88–90 are never observed anywhere in the test.

    Why the assertions can't fail on the pre-PR behavior

    Tracing the removed will_append guard from the diff:

    • The 250 a-* headers fill user slots 0–249.
    • Host/User-Agent/Accept iterate with header_count >= 250, so will_append = false.
    • The override flags were wrapped in if will_append { override_host_header = true; } etc., so they stay false.
    • if !will_append { continue; } drops the user headers.
    • After the loop, because the override flags are false, the default Host/User-Agent/Accept are appended.

    Result on main: headerNames contains "host", "user-agent", "accept" (the defaults) → test passes. This is exactly what the original title ("default headers preserved when user headers overflow") documented, and why these assertions existed.

    After this PR: the user headers are written via the overflow Vec, override flags are set, defaults are suppressed → headerNames contains "host", "user-agent", "accept" (the user's) → test passes.

    Step-by-step proof

    1. Run this test with USE_SYSTEM_BUN=1 bun test test/js/bun/http/fetch-header-count-limit.test.ts -t "user-supplied Host" (released binary, no fix).
    2. 250 a-* headers + defaults arrive; user Host/UA/Accept are dropped; default Host/UA/Accept appended.
    3. headerNames = ["a-0000", …, "a-0249", "connection", "user-agent", "accept", "host", "accept-encoding"].
    4. All three .toContain() assertions pass.
    5. Run with bun bd test … — same three assertions pass for the same reason.

    Per REVIEW.md: "a test that passes both ways is worse than no test" and "assert the strongest invariant … actual bytes not lengths". CLAUDE.md's testing section is explicit: "Your test is NOT VALID if it passes with USE_SYSTEM_BUN=1."

    Impact

    Low — this is a test-quality issue, not a runtime bug. The overflow fix itself is guarded elsewhere in this PR: the updated customCount === 300 assertion in the same file (line 56) and the new it.each([251, 300]) cases in fetch_headers.test.js both fail on the released binary with Received: 250. So merging as-is doesn't leave the fix untested; it just leaves one test whose title promises something its body doesn't verify.

    How to fix

    Either:

    • Assert the values. Extend makeRawHttpServer to also return the raw header lines (it already has them in lines), then assert e.g. expect(headerLines).toContain("host: custom-host.example.com") / "user-agent: custom-agent" / "accept: text/html". This makes the test fail on the released binary (which would send host: 127.0.0.1:<port> etc.) and actually verify what the title says.
    • Or delete the test. The scenario it was originally written for — "override flags set but header dropped, leaving Host entirely absent" — is structurally impossible now that will_append is gone, and the overflow path is already covered by the two other tests.

@robobun

robobun commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Re the additional finding on fetch-header-count-limit.test.ts:94-99 (assert values, not names): addressed in 739f86c before the review landed. The raw server now returns parsed header values and the test asserts {host: "custom-host.example.com", "user-agent": "custom-agent", accept: "text/html"} via a single toEqual. Under the pre-PR behaviour those resolve to the default Host/UA/Accept, so the test fails on the released binary.

@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 touches the unsafe lifetime-erased scratch storage in the HTTP client hot path — worth a human confirming the new overflow-Vec invariant.

What was reviewed:

  • Sizing: header_count is bounded by header_names.len() + 6 on every path (2 continues, 5 conditional defaults, 1 mutually-exclusive CL/TE) — no OOB into either the 256-slot inline array or the needed-sized overflow slice.
  • Lifetime: verified all four build_request callers (h1 on_writable ×2, h2 ClientSession::attach, h3 encode) serialize the returned Request synchronously before any subsequent call could resize the overflow Vec, so the erased 'static borrow never dangles.
  • Pattern mirrors the response-side overflow from #34923; the RacyCell INVARIANT (single HTTP thread) covers the new accessor.
Extended reasoning...

Overview

Removes the silent 250-header cap in fetch() request building. src/http/lib.rs build_request() previously wrote into a fixed 256-slot SHARED_REQUEST_HEADERS_BUF and continued past MAX_USER_HEADERS = 250, dropping headers with no error. The fix keeps the 256-slot inline array for the common case and adds a per-HTTP-thread SHARED_REQUEST_HEADERS_OVERFLOW: RacyCell<Vec<Header>> sized to header_names.len() + 6 when needed. The will_append guard and MAX_USER_HEADERS constant are removed; override flags (Host/UA/Accept/etc.) are now set unconditionally since the header is always written. H2Client.rs updates its SAFETY comment to name the overflow storage. Two test files gain/tighten coverage.

Security risks

None identified. This is outbound request construction — the header count is controlled by the caller who has already materialized every header in self.header_entries/self.header_buf, so the overflow Vec is constant-factor scratch on already-committed memory. No new parsing of untrusted input.

Level of scrutiny

Medium-high. The change itself is small and mirrors #34923's response-side pattern, but it modifies unsafe { detach_lifetime(..) } semantics: the returned Request<'static> may now borrow a Vec buffer that moves on the next resize(), whereas the fixed array never moved. The SAFETY comment documents the new invariant ("Callers serialize the returned Request before the next build_request() call resizes"). I traced all four call sites — lib.rs:2983/3473 (h1), ClientSession.rs:448 (h2), h3_client/encode.rs:48 (h3) — and each serializes into a writer/QPACK buffer synchronously on the single HTTP thread before returning, so the invariant holds today. A maintainer familiar with the HTTP thread model should confirm this is the intended contract going forward.

Other factors

  • I verified the sizing arithmetic: the loop writes ≤ header_names.len() entries (Content-Length and non-streaming Transfer-Encoding continue), then appends ≤ 5 conditional-once defaults plus exactly one CL/TE branch — worst case header_names.len() + 6 = needed, so request_headers_buf[header_count] never indexes past the slice.
  • Test coverage is solid: it.each([251, 300]) in fetch_headers.test.js asserts exact wire contents against a raw TCP server; fetch-header-count-limit.test.ts now asserts the user-supplied Host/UA/Accept values (not just presence) reach the origin through the overflow path. PR evidence shows both fail on main with Received: 250.
  • My earlier inline comment (accidental revert in cd1bc69) was addressed by 290e308; all bot threads are resolved.
  • The overflow Vec is never shrunk, so a single large request leaves per-thread scratch allocated — same trade-off as #34923's response side, and proportional to memory the caller already committed.

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