Skip to content

node:http2: take inbound header names from the per-VM HTTPHeaderIdentifiers cache - #39469

Merged
Jarred-Sumner merged 5 commits into
mainfrom
claude/farm/2f1a797c/h2-header-identifiers
Aug 17, 2026
Merged

Jarred-Sumner merged 5 commits into
mainfrom
claude/farm/2f1a797c/h2-header-identifiers

Conversation

@robobun

@robobun robobun commented Aug 17, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #39448, which deleted the dead Http2CommonStrings cache. This wires the live inbound path up to the cache that is actually in use.

Problem

  • Bun__h2__materializeHeaders (src/jsc/bindings/H2HeadersMaterializer.cpp) runs once per inbound HEADERS / PUSH_PROMISE / trailer block on both the client and the server. For every field it allocated a new JSString cell for the name (jsString(vm, String(httpHeaderNameStringImpl(...)))), then read it back and called Identifier::fromString on it for the headers-object put, which is an atom-table lookup per field.
  • Pseudo-headers (:method, :path, :scheme, :authority, :status) are not in HTTPHeaderNames.in, so they took the unknown-name path: a new StringImpl copy plus a new JSString plus an atomization, on every request and every response.
  • WebCore::HTTPHeaderIdentifiers (src/jsc/bindings/webcore/HTTPHeaderIdentifiers.{h,cpp}, reached through clientData(vm)->httpHeaderIdentifiers()) already caches one JSString and one Identifier per known header name for the lifetime of the VM, and Bun__NodeHTTP__buildRawHeadersArray in NodeHTTP.cpp already uses it. The HTTP/2 path did not, and the cache had no pseudo-header entries. Its identifierFor() was removed as dead code in Remove dead code from react_compiler, the WebCore/IDL bindings, install_jsc, node-fallbacks, and misc crates #39420 earlier today.

Fix

  • HTTPHeaderIdentifiers: add HTTP2_PSEUDO_HEADERS_EACH_NAME, a second X-macro list with the five pseudo-headers, and feed it through the same declaration / lazy-init / accessor / visitor macros as the existing entries, so each pseudo-header gets the same LazyProperty<JSString> + cached Identifier pair. HTTP2PseudoHeaderName is generated from that list, so the enum and the lookup tables cannot drift apart; findHTTP2PseudoHeaderName() maps a wire name to it (one byte compare for names that do not start with :).
  • Restore identifierFor(VM&, HTTPHeaderName) next to stringFor() and add the HTTP2PseudoHeaderName overloads. The regular-name tables are indexed by HTTPHeaderName, so a static_assert now checks that HTTP_HEADERS_EACH_NAME has one entry per enum value and in enum order (previously nothing checked either; a misplaced entry would have silently shifted every later slot).
  • H2HeadersMaterializer.cpp: names found in either list take the cached JSString and Identifier, so they allocate nothing and are never atomized per request. Any other name is atomized straight from the wire bytes (the same atom-table lookup as before, minus the intermediate StringImpl copy) and its rawHeaders JSString wraps that atom, so the unknown path goes from two allocations to one. The :status, cookie and set-cookie special cases compare the enums instead of re-comparing the name bytes. Behavior of the produced rawHeaders array, headers object and sensitive list is unchanged.
  • BunHttp2CommonStrings is not brought back.
  • Verified:
    • test/js/node/http2/node-http2.test.js, http2 hands out the cached name strings for pseudo-headers and known header names: after one round trip, jscDescribe() from bun:jsc reports every name string in rawHeaders, on both the server and the client side, as atomic, which is only true of strings that wrap the atoms the headers object is keyed by (the cache entries, or the per-block atom for unknown names). On main all ten names come out non-atomic (a JSString over WebCore's static name string, or a copy of the wire bytes), so the test fails there and passes with this change.
    • Same file, http2 materializes pseudo, known, unknown and all-digit header names the same way on every request: pins the produced rawHeaders / headers object / sensitive list for all four name paths on both sides, including the cookie / set-cookie / :status special cases, and checks that a second round trip on the same session (served from the populated cache) materializes identically. Behavior-preservation test, passes before and after.
    • bun bd test test/js/node/http2/ (all 9 files, including h2-conformance.test.ts): 477 pass, 0 fail, on the debug + ASAN build.
    • All 283 upstream test/js/node/test/{parallel,sequential}/*http2* files pass with the debug build.
    • The new test also passes with BUN_JSC_validateExceptionChecks=1.
    • The three touched C++ files are clang-format clean.

Benchmark

Local h2c loop in one process: a node:http2 server and client on 127.0.0.1, 64 streams in flight on one session. Each round trip carries 10 regular fields on the request and 10 on the response (70% names from HTTPHeaderNames.in, 30% x-*), so with the pseudo-headers it materializes 25 fields per round trip (14 on the server, 11 on the client). bun run build:release (non-LTO) of main at 9f67488 with and without this diff, same machine (16 vCPU container on a shared, busy host), 7 interleaved trials each, 60k timed round trips per trial after a 5k warm-up.

headers per message metric before after delta
10 (25 fields/round trip) header fields/sec, median 576,473 589,189 +2.2%
10 header fields/sec, best 582,706 600,457 +3.0%
10 round trips/sec, median 23,059 23,568 +2.2%
50 (105 fields/round trip) header fields/sec, median 1,006,661 1,043,415 +3.7%
50 header fields/sec, best 1,032,850 1,062,902 +2.9%
50 round trips/sec, median 9,587 9,937 +3.7%

The loop measures the whole request path (stream objects, events, framing, HPACK, loopback sockets), of which name materialization is a small slice, so a few percent end to end is the expected size of this change. Per round trip with the 10-header mix (5 pseudo-headers, 15 known names, 5 x-* names) it removes 20 JSString allocations, 10 StringImpl copies and 20 atom-table lookups; the 5 x-* names keep their lookup and go from two allocations to one.

Benchmark script
// usage: <bun> bench.mjs [regularHeadersPerMessage=10] [roundTrips=60000] [concurrency=64]
import http2 from "node:http2";
const regular = Number(process.argv[2] ?? 10), total = Number(process.argv[3] ?? 60000), concurrency = Number(process.argv[4] ?? 64);
const knownNames = ["content-type","cache-control","etag","vary","last-modified","accept","accept-encoding","user-agent",
  "authorization","accept-language","referer","origin","if-none-match","link","via"];
function buildHeaders(count, prefix) {
  const out = {}; let known = 0;
  for (let i = 0; i < count; i++) {
    if (i % 10 < 7 && known < knownNames.length) out[knownNames[known++]] = `${prefix}-value-${i}`;
    else out[`x-${prefix}-${i}`] = `${prefix}-value-${i}`;
  }
  return out;
}
const requestHeaders = { ":method": "POST", ":path": "/bench", ...buildHeaders(regular, "req") };
const responseHeaders = { ":status": 200, date: "Thu, 01 Jan 2026 00:00:00 GMT", ...buildHeaders(regular - 1, "res") };
const requestFields = 4 + regular, responseFields = 1 + regular;
const server = http2.createServer();
let serverFields = 0;
server.on("stream", stream => { serverFields += requestFields; stream.respond(responseHeaders, { endStream: true }); });
await new Promise(r => server.listen(0, "127.0.0.1", r));
const client = http2.connect(`http://127.0.0.1:${server.address().port}`);
await new Promise(r => client.once("connect", r));
function run(n) {
  return new Promise((resolve, reject) => {
    let started = 0, finished = 0, clientFields = 0;
    function one() {
      started++;
      const req = client.request(requestHeaders, { endStream: true });
      req.on("error", reject);
      req.on("response", h => { if (h[":status"] !== 200) reject(new Error("bad status")); clientFields += responseFields; });
      req.on("close", () => { if (++finished === n) resolve(clientFields); else if (started < n) one(); });
    }
    for (let i = 0; i < Math.min(concurrency, n); i++) one();
  });
}
await run(Math.min(total, 5000)); // warm-up
serverFields = 0;
const t0 = performance.now();
const clientFields = await run(total);
const s = (performance.now() - t0) / 1000;
console.log(JSON.stringify({ regular, roundTripsPerSec: Math.round(total / s), headerFieldsPerSec: Math.round((clientFields + serverFields) / s) }));
client.close(); server.close();

Background

  • HTTPHeaderNames.in is WebCore's list of ~94 well-known HTTP header names. findHTTPHeaderName() maps a name to the HTTPHeaderName enum with a perfect hash. HTTP/2 pseudo-headers are not HTTP header fields, which is why they are not in that list.
  • WTF::AtomStringImpl is the per-thread interning table for strings used as property keys. A JSC::Identifier is a handle to an atom; Identifier::fromString hashes the input and looks it up (allocating a new atom on a miss). JSString is the GC cell that wraps a StringImpl and is what JavaScript sees; several JSStrings can share one StringImpl, including an atom.
  • JSC::LazyProperty<Owner, T> is JSC's lazily-initialized GC slot: it stores an initializer until first use, and visit() marks the cell once created. HTTPHeaderIdentifiers lives in the per-VM JSVMClientData and is marked from Zig::GlobalObject::visitChildren, so the cached strings stay alive for the VM's lifetime and adding entries to the X-macro list extends both the initialization and the marking automatically.
  • Sharing one JSString cell across many rawHeaders arrays is not observable from JavaScript: strings are compared by value, and the cells are immutable.

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

fails on main (without fix)
ASAN without fix: 1 failed, 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.0 (8326d1bd3)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [842.82ms]
(pass) node none > Client Basics > should be able to send a POST request [564.28ms]
(pass) node none > Client Basics > constants [18.74ms]
(pass) node none > Client Basics > getDefaultSettings [7.14ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [22.56ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.48ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.35ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [5.25ms]
(pass) node none > Client Basics > should be able to send data using end [594.14ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [582.90ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving 
... (truncated)

release without fix: 6 skipped
bun test v1.4.0-canary.1 (b4a5e9abb)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [0.91ms]
(pass) node none > Client Basics > getDefaultSettings [0.15ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.39ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.11ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [0.04ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [0.07ms]
(pass) node none > Client Basics > is possible to abort request [1.72ms]
(pass) node none > Client Basics > aborted event should work with abortController [0.72ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [0.65ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [1.09ms]
(pass) node none > Client Basics > headers cannot be bigger than 65536 bytes [34.66ms]
(skip) node none > Client Basics > should not leak memory
(pass) node none > Client Basics > should fail to con
... (truncated)
passes on PR (with fix)
ASAN with fix: 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.0 (8326d1bd3)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [860.23ms]
(pass) node none > Client Basics > should be able to send a POST request [575.20ms]
(pass) node none > Client Basics > constants [18.83ms]
(pass) node none > Client Basics > getDefaultSettings [7.39ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [23.12ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.63ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.68ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [5.08ms]
(pass) node none > Client Basics > should be able to send data using end [607.15ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [599.84ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving 
... (truncated)

release with fix: 6 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1049ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/126] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 241 extern-C blocks audited
[2/126] gen cpp.rs (cppbind)
[3/126] gen JSSink.{cpp,h,lut.h,rs}
generated_jssink.rs: 8 sinks, 96 exported symbols
Generating /workspace/bun/build/release/codegen/JSSink.lut.h from /workspace/bun/build/release/codegen/JSSink.lut.txt
[4/126] gen JS modules (bundle-modules)
Preprocess modules (8180ms)
Bundle modules (39ms)
Postprocesss modules (36ms)
Bundle Functions (629ms)
Generate Code (22ms)

[8.92s] Bundled "src/js" for production
  2630 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[4/125] 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_highway v0.0.0 (/workspace/bun/src/highway)
�[1m�[92m   Compilin
... (truncated)
diff hotspot
src/jsc/bindings/H2HeadersMaterializer.cpp         |  36 +++--
 src/jsc/bindings/webcore/HTTPHeaderIdentifiers.cpp |  91 ++++++++++--
 src/jsc/bindings/webcore/HTTPHeaderIdentifiers.h   |  24 +++
 test/js/node/http2/node-http2.test.js              | 164 +++++++++++++++++++++
 4 files changed, 291 insertions(+), 24 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                                reads  edits  tests
src/jsc/bindings/H2HeadersMaterializer.cpp              4     11      0
src/jsc/bindings/webcore/HTTPHeaderIdentifiers.cpp      5     13      0
src/jsc/bindings/webcore/HTTPHeaderIdentifiers.h        2      3      0
test/js/node/http2/node-http2.test.js                   4      8      0

root cause · written by the author bot

The inbound node:http2 header path in H2HeadersMaterializer.cpp built a fresh JSString and then a separate Identifier for every header name on every HEADERS frame, and the pseudo-headers were absent from HTTPHeaderNames.in, so each request or response also paid for a new StringImpl plus atomization for :method, :path, :scheme, :authority and :status. The fix routes known names through the existing per-VM HTTPHeaderIdentifiers cache, as NodeHTTP.cpp already does, and adds the five pseudo-headers to that cache as lazily initialized strings with cached identifiers, so steady-state materializat…

…ifiers cache

Bun__h2__materializeHeaders allocated a JSString for every field name of
every inbound HEADERS block and then atomized the name a second time for
the headers-object put. Pseudo-headers additionally copied the name into a
fresh StringImpl, since HTTPHeaderNames.in does not list them.

Add the five RFC 9113 pseudo-headers to WebCore::HTTPHeaderIdentifiers as
a second X-macro list with the same LazyProperty<JSString> + Identifier
shape as the existing entries, restore identifierFor() next to stringFor()
and add the pseudo-header overloads, and make the materializer use the
cache for every name it recognizes. Unknown names are atomized once and
the rawHeaders entry wraps that atom.
@coderabbitai

coderabbitai Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 497e1dfa-a326-4e84-a9a3-7b575af1b1cc

📥 Commits

Reviewing files that changed from the base of the PR and between 7aad387 and bde1581.

📒 Files selected for processing (4)
  • src/jsc/bindings/H2HeadersMaterializer.cpp
  • src/jsc/bindings/webcore/HTTPHeaderIdentifiers.cpp
  • src/jsc/bindings/webcore/HTTPHeaderIdentifiers.h
  • test/js/node/http2/node-http2.test.js

Included review availability: Your plan includes up to 5 reviews per rolling hour; 0 remain after this review.


Walkthrough

Changes

HTTP/2 header materialization now resolves pseudo-headers and standard headers through shared cached identifiers. Unknown names are atomized once. Tests cover value handling, ordering, numeric status conversion, and cached raw header names.

HTTP/2 header materialization

Layer / File(s) Summary
Header identifier registry
src/jsc/bindings/webcore/HTTPHeaderIdentifiers.*
Adds HTTP/2 pseudo-header definitions, lookup functions, cached identifiers and strings, indexed accessors, and lazy-property visitation.
Header materializer integration
src/jsc/bindings/H2HeadersMaterializer.cpp
Resolves known and unknown header names through the shared registry. It uses resolved classifications for :status, cookie, and set-cookie handling.
HTTP/2 materialization validation
test/js/node/http2/node-http2.test.js
Tests repeated request and response materialization, duplicate values, ordering, numeric names, and cached raw header-name strings.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 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 identifies the main change: using the per-VM HTTPHeaderIdentifiers cache for inbound node:http2 header names.
Description check ✅ Passed The description explains the problem, implementation, verification, tests, and benchmarks, although it does not use the template headings exactly.

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

@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 reviewed this PR and didn't find any bugs. Because it changes GC-visited per-VM state and the hot native path for every inbound HTTP/2 header block, a human look would still be worthwhile.

What was reviewed:

  • Pseudo-header X-macro names (Authority/Method/Path/Scheme/Status) don't collide with any HTTP_HEADERS_EACH_NAME entry, so the generated members and accessors are unique.
  • New LazyProperty slots are wired into HTTPHeaderIdentifiers::visit(), which is reached from ZigGlobalObject::visitChildren (ZigGlobalObject.cpp:3410), so the cached pseudo-header JSStrings are marked.
  • Removed getString/RETURN_IF_EXCEPTION pair is safe: stringFor/identifierFor don't enter user JS, and the PR verified with BUN_JSC_validateExceptionChecks=1.
  • :protocol (RFC 8441) is not in the pseudo-header list; it falls through to the atomize-from-wire branch, which is behaviorally identical to before.
Extended reasoning...

Overview

This PR wires the HTTP/2 inbound header materializer (Bun__h2__materializeHeaders) up to the existing per-VM HTTPHeaderIdentifiers cache so that known header names and the five HTTP/2 pseudo-headers reuse a single cached JSString + Identifier instead of allocating fresh ones per request. It touches three C++ files: extends HTTPHeaderIdentifiers.{h,cpp} with a second X-macro list for pseudo-headers (plus enum, findHTTP2PseudoHeaderName, restored identifierFor overloads, and static_asserts against numHTTPHeaderNames), and rewrites the name-resolution block in H2HeadersMaterializer.cpp to consult the cache and compare enums for the :status/cookie/set-cookie special cases. A ~100-line behavior-preservation test is added to node-http2.test.js.

Security risks

None identified. Input is already-validated lowercase ASCII wire bytes; the change swaps how name strings are obtained without touching validation, bounds, or the packed/meta buffer walk. No new untrusted-data parsing.

Level of scrutiny

High. This is native C++ on a per-request hot path with GC-lifetime implications: new LazyProperty<JSGlobalObject, JSString> slots on a per-VM object that must be visited, and cached JSString cells shared across many arrays. Per the repo's review guidance, native memory safety and GC rooting are the most-blocked category. The change follows the established X-macro pattern exactly (same declaration/init/accessor/visitor macros as the existing 94 entries), which limits risk, but it is not a mechanical change.

Other factors

I verified: (1) no member/accessor name collisions between the two X-macro lists; (2) the new LazyProperty slots are marked via HTTPHeaderIdentifiers::visit, which ZigGlobalObject::visitChildrenImpl already calls; (3) the removed nameStr->getString + RETURN_IF_EXCEPTION is safe because the replacement path (stringFor/identifierFor/Identifier::fromString on Latin1 span) cannot throw a JS exception; (4) the unknown-name path (jsString(vm, ident.string())) wraps the atom's refcounted StringImpl, not a borrowed view. The PR reports passing the full test/js/node/http2/ suite plus 283 upstream Node http2 tests on debug+ASAN and under BUN_JSC_validateExceptionChecks=1. The new test is explicitly behavior-preserving (passes before and after), which is appropriate for a perf-only refactor but means it does not gate the optimization itself. Given the hot-path native scope, deferring to a human reviewer rather than auto-approving.

@robobun

robobun commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

On the two points a human reviewer is likely to hit as well:

  • :protocol is left out on purpose. It is the RFC 8441 extended CONNECT pseudo-header, so it only shows up on WebSocket-over-h2 style requests, and the engine only lets it through when enableConnectProtocol is set. It takes the same unknown-name path as before (atomize from the wire bytes, one JSString), which is one allocation fewer than main. Adding it later is one line in HTTP2_PSEUDO_HEADERS_EACH_NAME if it turns out to matter.
  • The red "Source lints" check is jsresult-swallow.test.ts reporting that the inventory count for src/runtime/api/bun/h2_frame_parser.rs is stale after Remove dead code from the node:http2 legacy inbound path, the watcher loader column, unused native bindings, builtins.d.ts and misc crates #39448 (5 listed, 4 in the tree). It fails the same way on main with this branch's diff removed; this PR does not touch any Rust or the inventory. It is being fixed separately.

@robobun

robobun commented Aug 17, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:30 PM PT - Aug 17th, 2026

✅ @robobun, your commit 3b3e6f4990f3d5dab468d3322e016c30b6f01e0a passed in Build #100226! 🎉


🧪   To try this PR locally:

bunx bun-pr 39469

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

bun-39469 --bun

@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 reviewed this again after 3b3e6f4 and didn't find any bugs — the earlier note about the HTTPHeaderNames.in order comment is now enforced by the entriesFollowHTTPHeaderNameOrder() static_assert, which is a better fix than rewording. Since this is ~100 lines of native C++ on the per-request h2 hot path that adds GC-visited LazyProperty state, a human sign-off from someone familiar with the h2 engine would still be worthwhile.

What was reviewed:

  • Pseudo-header X-macro identifiers (Authority/Method/Path/Scheme/Status) do not collide with any HTTP_HEADERS_EACH_NAME entry, so the generated members/accessors are distinct.
  • New m_*String LazyProperties are added to visit() and reached from ZigGlobalObject::visitChildren, so the cached JSStrings are marked.
  • isStatus/isCookie/isSetCookie enum comparisons are equivalent to the removed byte comparisons given wire names are validated lowercase; :protocol still routes through the unknown-name branch and produces the same result as before.
  • Unknown-name branch: nameView.span8() is safe (Latin1 view over validated-ASCII wire bytes) and the atom-then-jsString order preserves the same identifier used for putDirect.
Extended reasoning...

Overview

This PR wires Bun__h2__materializeHeaders (the single native pass that turns a decoded HTTP/2 header block into rawHeaders / headers-object / sensitive-list) to the existing per-VM HTTPHeaderIdentifiers cache, and extends that cache with the five RFC 9113 pseudo-headers via a second X-macro list. It restores identifierFor(HTTPHeaderName) (removed as dead in #39420), adds HTTP2PseudoHeaderName overloads, and replaces the :status/cookie/set-cookie string comparisons with enum comparisons. My prior review flagged a misleading comment about the macro list following HTTPHeaderNames.in order; the author replaced it with a compile-time static_assert that entry i equals enum value i, which enforces the invariant rather than documenting it.

Security risks

None identified. Header names reaching this code are already validated lowercase ASCII by the Rust engine before the packed buffer is handed over (per the existing comment at the nameView construction site), so the new findHTTP2PseudoHeaderName and enum comparisons see the same input class as the removed byte comparisons. No user-controllable data indexes the new getter tables — the indices are compiler-assigned enum values from findHTTPHeaderName/findHTTP2PseudoHeaderName, and the static_assert now guarantees the regular-name table can't be misaligned. The unknown-name branch's span8() is called on a Latin1 StringView built from those validated bytes.

Level of scrutiny

High. This runs once per inbound HEADERS/PUSH_PROMISE/trailer block on both client and server, so it's a per-request hot path in a Node-compat surface. It adds GC-visited state (five new LazyProperty<JSGlobalObject, JSString> slots) to a per-VM object marked from ZigGlobalObject::visitChildren, and shares one JSString cell across many rawHeaders arrays. I verified the visitor is extended in the same commit, that the pseudo-header identifier suffixes don't collide with any regular-header entry, and that the pattern matches the existing NodeHTTP.cpp consumer of the same cache — but this is exactly the category (native JSC bindings, GC lifetime, hot path) the repo review guidance flags as most-blocked, so a maintainer look is appropriate rather than bot-only approval.

Other factors

Test coverage is strong for a perf change: one test uses jscDescribe() to assert every rawHeaders name string is atomic (fails on main, passes here — a real fails-for-the-right-reason check), and a second behavior-preservation test pins rawHeaders/headers/sensitive across pseudo/known/unknown/all-digit names including the :status numeric coercion, cookie ; join, and set-cookie array, on two round trips of the same session. Both use port: 0, local loopback, and try/finally cleanup. The PR description reports the full test/js/node/http2/ suite (477 tests) and all 283 upstream Node http2 tests pass on debug+ASAN, plus BUN_JSC_validateExceptionChecks=1. The comment-cop bot's paragraph-comment complaints were all addressed in cd77329, and the failing jsresult-swallow lint is confirmed unrelated (fails the same way on main).

@Jarred-Sumner
Jarred-Sumner merged commit 5c8725f into main Aug 17, 2026
6 of 7 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/farm/2f1a797c/h2-header-identifiers branch August 17, 2026 23:35
Jarred-Sumner pushed a commit that referenced this pull request Aug 18, 2026
Four X-macro string caches (HTTPHeaderIdentifiers, BunCommonStrings,
BunMarkdownTagStrings, the JSStreamsRuntime handler table) each stamp
one JSC::LazyProperty init lambda per name plus an unrolled accessor and
visitor per name. In the linux-x64 binary that is 251 lambda instances
of about 500 to 720 bytes each (130 KB), 44 KB of HTTPHeaderIdentifiers
accessors and 51 KB of unrolled visit functions, all of them the same
code with a different index or literal.

Each table now has one shared init lambda that recovers its slot from
the LazyProperty address, an array of literals, and a loop for visit.
The per-name accessors stay as one-line inline forwards so callers do
not change. HTTPHeaderIdentifiers keeps everything #39469 added (the
pseudo-header list, identifierFor for both enums, the enum-order
static_asserts); the pseudo-headers live in the same table after the
regular names, with a static_assert pinning the offset.

Checked with node-http, streams and headers tests on the debug build;
the touched TUs also type-check against the release compile flags. CI's
size gate gives the real number, the local Rust-only measure cannot see
C++.
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