Repository navigation
node:http2: send array-valued pushStream() headers as one field per element - #37579
Conversation
|
Status: merged as e592637. Reproduced on bun 1.4.0 and main with the script in the PR body: |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughChangesHTTP/2 Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
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 `@test/js/node/http2/node-http2.test.js`:
- Around line 3593-3625: Update the helper around the server listen/client
request flow to wire server error events to the existing rejection path,
ensuring listen or runtime failures reject the promise instead of leaving it
pending. Move client cleanup into a finally block so the client is destroyed or
closed even when await promise rejects, while preserving normal block collection
and server cleanup.
🪄 Autofix
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: 497f2794-0e35-4bcc-b310-a7734e8f34d8
📒 Files selected for processing (3)
src/js/node/http2.tssrc/runtime/api/bun/h2_frame_parser.rstest/js/node/http2/node-http2.test.js
|
Updated 1:33 PM PT - Aug 11th, 2026
✅ @robobun, your commit 3608809665523b675a1cae6363af8c27cd3b14f8 passed in 🧪 To try this PR locally: bunx bun-pr 37579That installs a local version of the PR into your bun-37579 --bun |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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/js/node/http2.ts`:
- Around line 3428-3429: Add tests for pushStream() covering headers with an
empty array and a singleton array, asserting both are accepted. Also verify
multi-value arrays and repeated case-insensitive header names remain rejected
through assertSingleValueHeaders.
In `@test/js/node/http2/node-http2.test.js`:
- Around line 3595-3597: Update the server startup promise around the listening
callback so the temporary "error" listener is removed once the server emits
"listening"; then register subsequent server errors with the promise's active
reject path. Ensure post-start errors cannot invoke the already-settled startup
callback or leave the operation promise pending.
🪄 Autofix
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: df91ab1c-26d4-48c8-9dd7-396381875e4b
📒 Files selected for processing (3)
src/js/node/http2.tssrc/runtime/api/bun/h2_frame_parser.rstest/js/node/http2/node-http2.test.js
💤 Files with no reviewable changes (1)
- src/runtime/api/bun/h2_frame_parser.rs
…ejection; cover an empty single-value array
There was a problem hiding this comment.
I re-reviewed after 007d643 / 8a97f7d / 3608809 — the client-cleanup nit and the CodeRabbit points are addressed, and I didn't find anything new. Given this is ~90 lines in the native HPACK encoder for PUSH_PROMISE, a human look would still be worthwhile.
What was reviewed:
push_promise()array/single-value branch matches thesend_trailers()/request()pattern (samesingle_value_index_checkedbitset, same per-elementis_empty_or_undefined_or_nullreject,never_indexhoisted once per name).assertSingleValueHeadersinpushStream()is placed before the promised id is reserved and gated onkStrictSingleValueFields, same asrespond()/request().pushedHeaderBlockshelper now closes the client infinallyand wires servererror/sessionErrorto the single rejection path for the whole lifetime.
Extended reasoning...
Overview
This PR brings push_promise() in h2_frame_parser.rs to parity with its siblings request() and send_trailers(): array-valued headers now encode as one HPACK field per element, and single-value-header enforcement uses the same strictSingleValueFields-gated bitset. On the JS side, pushStream() gains the same assertSingleValueHeaders() pre-check that respond(), additionalHeaders(), and request() already run, placed before a promised stream id is reserved so the throw is synchronous and reserves nothing. Five new tests in node-http2.test.js assert on the flat wire-decoded field list.
Security risks
None identified. The change tightens validation (rejects CR/LF/NUL per element, rejects duplicated single-value headers) rather than relaxing it. No new user-controlled data reaches an allocation size or index computation; the array iteration and per-element to_js_string coercion follow the same JSC exception-propagation shape (? on every fallible call) as the sibling encoders.
Level of scrutiny
Medium-high. This is native HTTP/2 wire-protocol encoding — an error here corrupts the shared HPACK dynamic table for the session. The change is mechanical (I diffed it against send_trailers() at lines 7907-8050 and the shape is the same), but ~90 lines of new native logic in a Node-compat protocol path is more than I'd approve without a human look, particularly given the PR body itself notes the pre-existing mid-encode HPACK-desync hazard (#33470) that this change is shaped to compose with.
Other factors
Since my previous inline nit, three commits landed addressing all feedback: the test helper now closes the client in finally, wires server error/sessionError to a single rejection promise for the whole lifetime, and races listen() against it; the flagged comments were trimmed; and an empty-array-on-single-value-field case was added. The bug-hunting pass on the latest revision found nothing. Tests cover the variant matrix well (array/empty/singleton, sensitive per-element, three single-value violation shapes, strictSingleValueFields: false, LF/null element via callback). CI build #92415 is in progress.
There was a problem hiding this comment.
LGTM — brings push_promise() to parity with the sibling encoders.
What was reviewed:
- Compared the new array/single-value branch in
push_promise()againstsend_trailers()(h2_frame_parser.rs:8007-8094) andrequest()— sameis_array()/array_iterator/bitset pattern, same error codes and messages. - The JS-side
assertSingleValueHeadersguard matches the three existing call sites (respond,additionalHeaders,request) with the samekStrictSingleValueFieldsgate. - Test helper cleanup: my earlier note about
client.close()on the failure path was addressed in 007d643/3608809; all review threads are resolved.
Extended reasoning...
Overview
This PR fixes pushStream() in node:http2 so that array-valued headers are sent as one HPACK field per element, matching Node.js. Three files touched: a one-line guard in src/js/node/http2.ts, a ~90-line restructure of the header-encoding loop in push_promise() in src/runtime/api/bun/h2_frame_parser.rs, and five new tests in test/js/node/http2/node-http2.test.js.
Security risks
None identified. This is outbound HTTP/2 header encoding on the server side; the change tightens validation (single-value enforcement, per-element CR/LF/NUL rejection) rather than relaxing it. No auth, crypto, or permissions code touched.
Level of scrutiny
Moderate — protocol encoding is correctness-sensitive, but the change is mechanical parity with two sibling encoders in the same file. I read the new push_promise() block side-by-side against send_trailers() at lines 8007-8094: identical structure (js_type().is_array() → array_iterator → single_value_index_checked bitset → per-element is_empty_or_undefined_or_null rejection → encode). The one intentional divergence is computing never_index once per header name and capturing it in the closure rather than per-element, which is semantically identical (the lookup depends only on the name) and is called out in the PR description. The JS change is a copy of the guard that respond()/additionalHeaders()/request() already run at lines 3664/3775/6235.
Other factors
- Test coverage is thorough: wire-level assertion on the flat
rawHeaderslist, empty-array on both ordinary and single-value fields, single-element array on a pseudo-header, sensitive-header never-index for every element, synchronous throw with no stream id reserved (asserts the next push gets id 2),strictSingleValueFields: falseopt-out, and per-element invalid-value callback errors. The PR body confirms all five fail withUSE_SYSTEM_BUN=1and pass on the branch. - All prior review feedback (CodeRabbit and my own inline nit about client cleanup in the test helper's failure path) has been addressed across commits 007d643, 8a97f7d, and 3608809; every thread is marked resolved.
- The pre-existing HPACK-desync-on-mid-encode-throw is explicitly noted as out of scope (tracked by #33470) and this change doesn't alter that behavior — the loop shape is preserved so that PR rebases mechanically.
- The
single_value_headersbitset is declared outside the two-pass pseudo-header loop, so a duplicate across passes would still be caught; pseudo and non-pseudo names are disjoint anyway, and this matches howrequest()structures the same state.
Repro
{"x-multi":"a, b","set-cookie":["c1=1","c2=2"]}{"x-multi":"a,b","set-cookie":["c1=1,c2=2"]}{"x-multi":"a, b","set-cookie":["c1=1","c2=2"]}(A receiver joins repeated fields with
", ", so the missing space inx-multishows a single field was sent;set-cookieshows it directly: two cookies collapsed into one invalid cookie.)Cause
Every outbound header block in bun goes through a native encoder in
h2_frame_parser.rs.request()(used bysession.request(),respond(),additionalHeaders()) andsend_trailers()have an array branch that encodes one field per element.push_promise(), which backspushStream(), did not: it calledto_js_string()on the value, so an array went throughArray.prototype.toStringand was sent as a single field joined with",". Node'sbuildNgHeaderStringemits one field per element for every block,pushStream()included.The same missing branch meant
pushStream()had no single-value enforcement at all:{"content-type": ["a", "b"]}was sent ascontent-type: a,b,{":path": ["/a", "/b"]}as:path: /a,/b, and{"content-type": "a", "Content-Type": "b"}as two fields. Node throwsERR_HTTP2_HEADER_SINGLE_VALUEsynchronously frompushStream()for all three (verified against v26.3.0).Fix
src/runtime/api/bun/h2_frame_parser.rs:push_promise()gets the same value handling asrequest()andsend_trailers(): an array encodes one field per element (each element validated like a single value, so an element with CR/LF/NUL or a null element is rejected withERR_HTTP2_INVALID_HEADER_VALUEexactly as it is forrespond()), and single-value fields are tracked with the samestrictSingleValueFields-gated bitset the other encoders use. The never-index lookup is done once per header name and applied to every element.src/js/node/http2.ts:pushStream()runsassertSingleValueHeaders()(the checkrespond()/additionalHeaders()/request()already run) before a promised stream id is reserved, so the violation throws synchronously frompushStream()like node, reserves no stream, and never reaches the encoder.Behavior checked against node v26.3.0 with a per-case probe:
pushStream()headers"x-multi": ["a", "b"]a,b"set-cookie": ["c1=1", "c2=2"]c1=1,c2=2"x-none": []x-none:(empty value)"x-num": [1, 2]1,21,21,2"content-type": ["a", "b"]ERR_HTTP2_HEADER_SINGLE_VALUEa,b":path": ["/a", "/b"]/a,/b"content-type": "a", "Content-Type": "b"strictSingleValueFields: false"x": ["ok", null]null(stringified)ok,ERR_HTTP2_INVALID_HEADER_VALUEThe last row is the one deliberate difference from node: rejecting a null element is what bun's
request()/respond()already do for array elements, sopushStream()now matches the rest of bun rather than node'sString(null).Not changed here: a block that fails validation after some of its fields were already fed to the HPACK encoder still desyncs the encoder table from the peer. That is pre-existing (the same
{"x-a": "fine", "x-bad": "a\nb"}push kills the session with COMPRESSION_ERROR before and after this change, and it applies to every encoder) and is what #33470 fixes by collecting the block before encoding; this change keepspush_promise()'s loop shape so that PR rebases mechanically either way. #37568 (coerce values in JS before taking the id) passes arrays through to the native encoder and is complementary.Verification
Five tests added to
test/js/node/http2/node-http2.test.js, asserting on the flat header list the client decodes from the wire (one name/value pair per field):[x]sends one field (also for a pseudo-header)[http2.sensitiveHeaders]header arrives never-indexedERR_HTTP2_HEADER_SINGLE_VALUEsynchronously, the callback is never invoked, and the next push still gets stream id 2strictSingleValueFields: falsesends each element of a single-value headerERR_HTTP2_INVALID_HEADER_VALUEand nothing is pushedAll five fail on the current release (
USE_SYSTEM_BUN=1) and pass with this change. The rest ofnode-http2.test.js(352 tests),h2-conformance.test.ts,h2-push-refusal-staged.test.ts, and the ported node push tests (test-http2-server-push-stream*.js,test-http2-compat-serverresponse-createpushresponse.js,test-http2-respond-file-push.js,test-http2-server-push-disabled.js,test-http2-single-headers-validation-disabled.js) pass with the debug build.