Skip to content

http: bind HPACK decode results to the decoder's lifetime - #31488

Open
Jarred-Sumner wants to merge 1 commit into
mainfrom
claude/hpack-decode-lifetime
Open

http: bind HPACK decode results to the decoder's lifetime#31488
Jarred-Sumner wants to merge 1 commit into
mainfrom
claude/hpack-decode-lifetime

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

What

lshpack::HPACK::decode used to return DecodeResult with &'static [u8] name/value slices that actually pointed into a per-thread staging buffer in c-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 the thread_local shared_header_buffer and put the 64 KB staging buffer (char buffer[LSHPACK_MAX_HEADER_SIZE]) inside lshpack_wrapper, used by both lshpack_wrapper_encode and lshpack_wrapper_decode (lsxpack_header_prepare_decode now points at the wrapper-owned buffer).
  • src/http/lshpack.rs: DecodeResult<'a> with decode<'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 (the src argument is intentionally not tied to 'a). No new unsafe blocks; the existing from_raw_parts SAFETY comments are updated to describe the wrapper-owned buffer.
  • src/runtime/api/bun/h2_frame_parser.rs: H2FrameParser::decode can no longer let the borrowed result escape the with_mut closure, so it now copies the decoded name/value into a caller-provided scratch Vec<u8> (cleared at the start of every decode call) and returns an owned HeaderValue { name_len, never_index, well_know, next }; read_headers splits the scratch at name_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_wrapper handle, i.e. per HTTP/2 session (H2FrameParser for node:http2, ClientSession for 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 the thread_local buffer and only adding the 'a lifetime, 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_bin and bun 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.
  • A header round-trip fixture (request/response/sensitive headers, repeated and >256-byte values over node:http2) produces byte-identical output before and after.

@robobun

robobun commented May 27, 2026

Copy link
Copy Markdown
Collaborator
Updated 9:05 PM PT - May 28th, 2026

@Jarred-Sumner, your commit 6c6e641 has some failures in Build #58804 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 31488

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

bun-31488 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. ERR_HTTP2_PROTOCOL_ERROR 200 in browser since bun 1.2.6 #19208 - ERR_HTTP2_PROTOCOL_ERROR when serving assets over HTTP/2; concurrent requests on the same connection could hit the stale thread-local HPACK decode buffer

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

Fixes #19208

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@Jarred-Sumner, we couldn't start this review because you've reached your PR review rate limit.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ff990ea9-4de3-466d-b599-fd6d50b6485b

📥 Commits

Reviewing files that changed from the base of the PR and between 754c11a and 6c6e641.

📒 Files selected for processing (3)
  • src/http/lshpack.rs
  • src/jsc/bindings/c-bindings.cpp
  • src/runtime/api/bun/h2_frame_parser.rs

Walkthrough

This PR refactors HPACK header decoding from using 'static thread-local shared buffers to per-instance and per-call scratch buffers, propagating lifetime constraints through the HPACK decode contract, C bindings wrapper layout, and H2 frame parser header processing pipeline to ensure buffer validity is tied to decoder ownership rather than global state.

Changes

HPACK Buffer Ownership and Lifetime Refactor

Layer / File(s) Summary
HPACK decode lifetime contract
src/http/lshpack.rs
DecodeResult<'a> now carries an explicit lifetime and borrows name/value with &'a [u8]. HPACK::decode<'a>(&'a mut self, ...) ties returned slices to the caller's mutable borrow. Documentation updated to reflect wrapper-owned buffer validity until the next decode/encode call.
C bindings per-instance buffer
src/jsc/bindings/c-bindings.cpp
lshpack_wrapper struct now includes an embedded buffer[LSHPACK_MAX_HEADER_SIZE] field. Encode and decode paths updated to store/reference header data in self->buffer instead of thread-local shared buffer, changing the wrapper's C ABI layout.
H2 frame parser scratch buffer integration
src/runtime/api/bun/h2_frame_parser.rs (HeaderValue struct and decode signature)
New HeaderValue struct stores decode metadata (split point, flags) without owning slices. H2FrameParser::decode(&self, src_buffer: &[u8], scratch: &mut Vec<u8>) now accepts a scratch buffer, clearing and appending decoded bytes. decode_header_block allocates a single reusable scratch buffer for the entire header block loop and passes it into decode on each iteration.
H2 frame parser header processing
src/runtime/api/bun/h2_frame_parser.rs (header loop and validation)
Per-header processing derives (name, value) slices from scratch using HeaderValue.name_len. Logging, malformed-header validation, pseudo-header checks, header list size accounting, and JS value string creation all updated to use scratch-derived slices instead of header.name/header.value.

Possibly Related PRs

  • oven-sh/bun#31323: Modifies src/runtime/api/bun/h2_frame_parser.rs HPACK header-block decoding flow to change how the full header block is decoded and how decoded header bytes are handled.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: binding HPACK decode results to the decoder's lifetime instead of static lifetimes.
Description check ✅ Passed The description comprehensively covers all required sections with detailed explanations of what changed, how it was verified, memory tradeoffs, and testing results.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

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

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 Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/hpack-decode-lifetime branch from 754c11a to 6c6e641 Compare May 28, 2026 22:51
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

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.

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

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 Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Keep the threadlocal buffer.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Heads up: #31842 (part of the todo-audit-fixes campaign) carries the same Rust-side DecodeResult lifetime fix plus a copy-out HeaderValue at the parser boundary and a regression test, and is proceeding first. Once it lands on main, this PR should be rebased down to just the c-bindings.cpp per-instance-buffer change, which remains valuable on its own.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants