http: bind HPACK decode results to the decoder's lifetime - #31488
http: bind HPACK decode results to the decoder's lifetime#31488Jarred-Sumner wants to merge 1 commit into
Conversation
|
Updated 9:05 PM PT - May 28th, 2026
❌ @Jarred-Sumner, your commit 6c6e641 has some failures in 🧪 To try this PR locally: bunx bun-pr 31488That installs a local version of the PR into your bun-31488 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
Warning Review limit reached
More reviews will be available in 4 minutes and 15 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
WalkthroughThis PR refactors HPACK header decoding from using ChangesHPACK Buffer Ownership and Lifetime Refactor
Possibly Related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
I didn't find any bugs, but this touches FFI lifetime semantics, unsafe Rust on the network-input path, and carries a non-trivial 64KB-per-session memory tradeoff that's worth a human sign-off.
Extended reasoning...
Overview
This PR moves the 64KB HPACK staging buffer from a thread-local in c-bindings.cpp into the per-handle lshpack_wrapper struct, adds a lifetime parameter to DecodeResult<'a> so decoded name/value slices are bound to &'a mut self, and reworks H2FrameParser::decode to copy results into a caller-provided scratch Vec<u8> since the borrow can no longer escape the with_mut closure. Three files: src/jsc/bindings/c-bindings.cpp, src/http/lshpack.rs, and src/runtime/api/bun/h2_frame_parser.rs.
Security risks
HPACK decoding sits directly on the HTTP/2 network-input path and the change touches unsafe { from_raw_parts(...) } over FFI-returned pointers. The change is strictly a soundness improvement (replacing bogus &'static borrows with handle-bound ones), and I don't see any new exposure — the scratch-vec copy in read_headers is straightforward and split_at(name_len) is bounded by construction. No injection/auth/data-exposure surface is introduced.
Level of scrutiny
High. This is core protocol handling for every HTTP/2 request, crosses the Rust/C++ FFI boundary, modifies a struct layout that's mirrored opaquely on the Rust side, and updates SAFETY justifications on raw-pointer slice construction. The PR description also explicitly flags a design tradeoff — 64KB per HTTP/2 session instead of per thread — and an alternative that was considered and rejected. That's exactly the kind of memory-footprint decision (potentially significant under many concurrent connections) that a maintainer should consciously accept rather than have a bot rubber-stamp.
Other factors
No bugs found by the bug-hunting pass. The logic looks correct to me: encode/decode now use self->buffer, the lifetime annotation correctly ties the result to the mutable borrow of the handle, and the read_headers loop reuses one scratch vec across iterations. Test coverage is reasonable (existing http2 / fetch-h2 suites pass per the description). Still, given the FFI/unsafe surface and the explicit memory tradeoff, deferring to a human reviewer.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Let's keep the threadlocal buffer? Lets save that memory.
Give each lshpack wrapper its own header staging buffer instead of sharing a per-thread one, and return decoded name/value slices borrowed from the decoder handle rather than as 'static slices. The node:http2 frame parser copies each decoded header into a per-call scratch buffer before the next decode.
754c11a to
6c6e641
Compare
|
Confirmed: the PR was conflicting with main after #31495 landed. Rebased onto origin/main (now 6c6e641) and resolved the decode_header_block conflicts so both main's new header validation (connection-specific header rejection, pseudo-header ordering, single-value duplicate detection), owned-frame flush, and pinned-buffer parsing and this PR's owned HeaderValue + per-call scratch + DecodeResult<'a> restructure are preserved. Built and ran node-http2.test.js, node-http2-invalid-padding.test.ts, fetch-http2-client.test.ts, and fetch-http2-adversarial.test.ts with no new failures (only the known pre-existing maxSessionMemory debug-build timeout), then force-pushed with --force-with-lease; the PR now shows as mergeable. |
There was a problem hiding this comment.
I didn't find any bugs and the lifetime fix looks correct, but the +64 KB-per-HTTP/2-session memory tradeoff and the FFI struct layout change are design decisions worth a human sign-off.
Extended reasoning...
Overview
This PR fixes a soundness issue in the HPACK decode path: DecodeResult previously claimed 'static lifetimes for name/value slices that actually pointed into a per-thread staging buffer, so they could go stale after any decode/encode call on the same thread. The fix touches three layers: (1) c-bindings.cpp moves the 64 KB staging buffer from a lazy thread-local into the lshpack_wrapper struct itself; (2) lshpack.rs makes DecodeResult<'a> borrow from &'a mut self; (3) h2_frame_parser.rs adapts by copying the decoded name/value into a caller-provided scratch Vec<u8> (since the borrowed result can no longer escape the with_mut closure) and mechanically rewrites the header-processing loop to split the scratch at name_len.
Security risks
None new that I can identify. The change strictly tightens lifetime guarantees on untrusted-input parsing (HTTP/2 HEADERS frames) and removes a latent use-after-clobber. The header validation logic (malformed field names/values, pseudo-header ordering, forbidden connection-specific headers, header list size accounting) is preserved one-for-one — only the source of the byte slices changed from header.name/header.value to scratch-derived name/value.
Level of scrutiny
This warrants human review despite being a correctness improvement. It sits on the production-critical HTTP/2 path for both node:http2 and fetch, crosses the Rust/C++ FFI boundary, and changes the C ABI layout of lshpack_wrapper (now ~64 KB larger). Most importantly, the memory tradeoff — moving from one 64 KB buffer per thread to one per HTTP/2 session — is a deliberate design choice the description explains well, but it deserves explicit human acknowledgment given workloads with many concurrent h2 sessions.
Other factors
The bug-hunting system found nothing. The Rust changes are mechanically sound (the borrow checker now enforces what was previously a comment), and the h2_frame_parser.rs rewrite is a faithful 1:1 substitution of slice sources. The description is thorough about testing (http2 + fetch h2 test suites match base behavior, header round-trip fixture is byte-identical) and explicitly considers and rejects the alternative of keeping the thread-local buffer. No CODEOWNERS apply to these paths.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Keep the threadlocal buffer.
|
Heads up: #31842 (part of the todo-audit-fixes campaign) carries the same Rust-side |
What
lshpack::HPACK::decodeused to returnDecodeResultwith&'static [u8]name/value slices that actually pointed into a per-thread staging buffer inc-bindings.cpp, so the slices silently went stale after the next decode/encode call on any HPACK handle on that thread. This binds them to the decoder instead:src/jsc/bindings/c-bindings.cpp: drop thethread_localshared_header_bufferand put the 64 KB staging buffer (char buffer[LSHPACK_MAX_HEADER_SIZE]) insidelshpack_wrapper, used by bothlshpack_wrapper_encodeandlshpack_wrapper_decode(lsxpack_header_prepare_decodenow points at the wrapper-owned buffer).src/http/lshpack.rs:DecodeResult<'a>withdecode<'a>(&'a mut self, src: &[u8]) -> Result<DecodeResult<'a>, _>, so the returned name/value borrows are tied to the handle that owns the buffer they point into (thesrcargument is intentionally not tied to'a). No newunsafeblocks; the existingfrom_raw_partsSAFETY comments are updated to describe the wrapper-owned buffer.src/runtime/api/bun/h2_frame_parser.rs:H2FrameParser::decodecan no longer let the borrowed result escape thewith_mutclosure, so it now copies the decoded name/value into a caller-provided scratchVec<u8>(cleared at the start of every decode call) and returns an ownedHeaderValue { name_len, never_index, well_know, next };read_headerssplits the scratch atname_len.src/http/h2_client/dispatch.rs(fetch HTTP/2 client) compiles unchanged — its decode loops already consume each result before the next call, so there are no added copies on that path.Memory tradeoff
The 64 KB staging buffer moves from one lazily-allocated buffer per thread that ever touched HPACK to one per
lshpack_wrapperhandle, i.e. per HTTP/2 session (H2FrameParserfornode:http2,ClientSessionfor fetch's h2 client). Sessions also carry the lshpack encoder/decoder dynamic tables, so this adds ~64 KB to each session's existing footprint; in exchange the buffer's lifetime matches the borrows handed out from it, and the previous Windows TLS-bloat workaround (lazy heap allocation to keep 64 KB out of every thread's TLS block) is no longer needed. The alternative considered was keeping thethread_localbuffer and only adding the'alifetime, which keeps memory per-thread but would still let two handles on the same thread clobber each other's staged bytes while a result is borrowed — hence the per-handle buffer.Testing
cargo check -p bun_binandbun run rust:check-all(10/10 targets) pass; note the cross-target check only covers the Rust side, the C++ change is covered by the local debug build and CI.bun bd test test/js/node/http2/node-http2.test.js test/js/node/http2/node-http2-invalid-padding.test.ts test/js/web/fetch/fetch-http2-client.test.ts test/js/web/fetch/fetch-http2-adversarial.test.ts: same results as the base commit. The only failure,http2 server with minimal maxSessionMemory handles multiple requests(15 s timeout doing 10k sequential round trips), times out identically on a debug build of the base without this change.node:http2) produces byte-identical output before and after.