Skip to content

node:http2: return undefined from sentInfoHeaders until a 1xx block is sent - #43395

Open
robobun wants to merge 4 commits into
mainfrom
robobun/b7ef0b22/http2-sent-info-headers-undefined
Open

robobun wants to merge 4 commits into
mainfrom
robobun/b7ef0b22/http2-sent-info-headers-undefined

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Http2Stream#sentInfoHeaders returns [] on a stream that sent no informational (1xx) headers. Node v26.3.0 returns undefined. This holds for client requests and server streams.
  • The cause is the getter at src/js/node/http2.ts:2355: return this[kInfoHeaders] || [];. The [] is left from the client-only stub in feat(http2) Client Support #6761. feat(node:http2) Implement HTTP2 server support #14286 added the real slot and kept the fallback.
  • additionalHeaders() records the block at http2.ts:3492, before the native call that validates and sends it. A block with an invalid header name throws ERR_INVALID_HTTP_TOKEN, sends nothing, and still shows in sentInfoHeaders.

Fix

  • The getter returns the slot as is, like node's getter in lib/internal/http2/core.js: return this[kInfoHeaders];.
  • additionalHeaders() records the block after the native call returns. respond() uses this order for sentHeaders. Node validates the header list before it records the block.
  • Verified: test/js/node/http2/node-http2.test.js. The new test runs one fixture in the test process and under Node.js, and compares both with one expected document. Stock bun fails it and the six "settings and properties" tests.
  • Self-reviewed: 4 concerns raised, 4 addressed.

Background

  • An informational header block is a HEADERS frame with a 1xx :status, for example 102 or 103. A server sends it with stream.additionalHeaders() before the final response headers.
  • sentInfoHeaders lists the 1xx blocks that the stream sent. kInfoHeaders is the symbol-keyed slot that stores the list.
  • The native request() call validates each header name and value, encodes the block, and writes the frame. It throws on the first invalid header.
Notes

Node source, v26.3.0:

Repro (bun file.mjs and node file.mjs):

import http2 from "node:http2";
const srv = http2.createServer();
srv.on("stream", st => {
  console.log("server before respond:", st.sentInfoHeaders);
  st.respond({ ":status": 200 });
  st.on("close", () => console.log("server at close:", st.sentInfoHeaders));
  st.end("ok");
});
srv.listen(0, () => {
  const c = http2.connect("http://127.0.0.1:" + srv.address().port);
  const r = c.request({ ":path": "/" });
  console.log("client:", r.sentInfoHeaders);
  r.resume();
  r.on("close", () => {
    console.log("client at close:", r.sentInfoHeaders);
    c.close();
    srv.close();
  });
  r.end();
});

Node v26.3.0 prints undefined four times. Bun 1.4.3 prints [] four times.

The block that throws, on a server stream:

try {
  stream.additionalHeaders({ ":status": 103, "inv alid": "x" });
} catch (err) {
  console.log(err.code, JSON.stringify(stream.sentInfoHeaders));
}

Node v26.3.0 prints ERR_INVALID_HTTP_TOKEN undefined. Bun 1.4.3 prints ERR_INVALID_HTTP_TOKEN [{":status":103,"inv alid":"x"}]. With this change Bun prints the same line as node.

Test:

  • test/js/node/http2/http2-sent-info-headers.fixture.js collects sentInfoHeaders on both sides of three requests: no 1xx block, a block that throws, and a 102 block followed by a block that throws. It writes undefined as the string "undefined", because JSON drops undefined.
  • Each request uses its own session. On main, a block that throws leaves the HPACK encoder of the session out of step with the peer (node:http2: encode each header block in one call, all fields or none #41520 owns that), so a shared session can fail on a later request.
  • Bun runs the fixture in the test process. A debug build takes seconds to start, and a spawned run came close to the 5 s test timeout on a loaded machine. Node.js runs the same file as a script.
  • The old assertion in "settings and properties should work" (req.sentInfoHeaders.length is 0) throws under node. It now expects undefined.
  • @types/node declares the property as OutgoingHttpHeaders[] | undefined.

Also ran with the debug build, all pass: the vendored node tests test-http2-sent-headers.js, test-http2-info-headers.js, test-http2-compat-write-early-hints*.js, test-http2-compat-expect-continue*.js, test-http2-status-code*.js. The whole of node-http2.test.js passed (388 pass) with the getter change. With both changes the new test and the changed assertion pass. On a heavily loaded machine, some tests in that file that spawn a debug-build subprocess hit the 5 s timeout, a different set on each run. None of them reads sentInfoHeaders or calls additionalHeaders().

Self-review concerns, all addressed:

  1. A block that throws stayed in sentInfoHeaders. The second commit moves the record after the native call.
  2. The expected values were typed by hand and never checked by node. The same fixture now runs under Node.js.
  3. undefined was not explicit in the compared data, and the throw cases had no rows. The fixture now has both.
  4. The first draft of this text said the populated path already equals node. It does not in every detail, see below.

Not changed here:


[human-review] gate passed · iteration 0 · 3 files touched

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

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [849.37ms]
(pass) node none > Client Basics > should be able to send a POST request [560.10ms]
(pass) node none > Client Basics > constants [17.42ms]
(pass) node none > Client Basics > getDefaultSettings [7.07ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [17.30ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.30ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.09ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [4.84ms]
(pass) node none > Client Basics > should be able to send data using end [582.21ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [571.39ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving d
... (truncated)

release without fix: 7 failed, 6 skipped
bun test v1.4.3-canary.1 (b52d51348)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [1.22ms]
(pass) node none > Client Basics > getDefaultSettings [0.22ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.42ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.24ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [0.06ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [0.11ms]
(pass) node none > Client Basics > is possible to abort request [2.76ms]
(pass) node none > Client Basics > aborted event should work with abortController [1.28ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [1.10ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [1.19ms]
887 |               ":method": "GET",
888 |               ":path": "/",
889 |               ":scheme": "https",
890 |             });
891 |             expect(req.sentTrailers).toBeUndefined();
892 
... (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/pr_gate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.3 (b52d51348)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [1039.17ms]
(pass) node none > Client Basics > should be able to send a POST request [712.48ms]
(pass) node none > Client Basics > constants [21.76ms]
(pass) node none > Client Basics > getDefaultSettings [9.48ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [23.28ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [7.28ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [4.27ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [7.22ms]
(pass) node none > Client Basics > should be able to send data using end [749.16ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [738.08ms]
(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 817ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/125] gen JS modules (bundle-modules)
Preprocess modules (8718ms)
Bundle modules (61ms)
Postprocesss modules (159ms)
Bundle Functions (618ms)
Generate Code (47ms)

[9.62s] Bundled "src/js" for production
  2607 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[1/8] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  �[1m�[94m--> �[0msrc/install/windows-shim/Cargo.toml:41:8
   �[1m�[94m|�[0m
�[1m�[94m41�[0m �[91m- �[0mname = �[91m"bun_shim_impl"�[0m
�[1m�[94m41�[0m �[92m+ �[0mname = �[92m"bun-shim-impl"�[0m
   �[1m�[
... (truncated)
diff hotspot
src/js/node/http2.ts                               |  6 +-
 .../node/http2/http2-sent-info-headers.fixture.js  | 76 ++++++++++++++++++++++
 test/js/node/http2/node-http2.test.js              | 54 ++++++++++++++-
 3 files changed, 132 insertions(+), 4 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                   reads  edits  tests
src/js/node/http2.ts                                       7      6     33
test/js/node/http2/http2-sent-info-headers.fixture.js      0      3     31
test/js/node/http2/node-http2.test.js                      9      4     30

…ders() runs

Node's Http2Stream#sentInfoHeaders getter returns the slot as is. Only
additionalHeaders() creates the array. Bun's getter fell back to an empty
array, so a stream that sent no 1xx block reported [] where node reports
undefined.
additionalHeaders() recorded the block before the native call that
validates and sends it. A block with an invalid header name threw, sent
nothing, and still showed in sentInfoHeaders. Node validates before it
records the block.

The test now runs one scenario in this process and under Node.js, and
compares both against the same expected document.
@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:31 PM PT - Sep 18th, 2026

❌ @robobun, your commit f853af6 has 2 failures in Build #118162 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 43395

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

bun-43395 --bun

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on bun 1.4.3 against node v26.3.0:

  • A client request and a server stream that never call additionalHeaders() print sentInfoHeaders as [] under bun and as undefined under node.
  • On a server stream, additionalHeaders({ ":status": 103, "inv alid": "x" }) throws ERR_INVALID_HTTP_TOKEN. After the throw, bun reports [{":status":103,"inv alid":"x"}] and node reports undefined.

The repro scripts and both outputs are in the Notes block of the PR description.

Fail-before: USE_SYSTEM_BUN=1 bun test test/js/node/http2/node-http2.test.js -t "sentInfoHeaders lists|settings and properties should work" gives 7 fail. With this branch, bun bd test on the same filter gives 7 pass.

CI: no failure comes from this diff. Two unrelated jobs are red in build 118162.

  • Build 118162 finished with 179 of 181 jobs passed.
  • :debian: 13 x64-asan - test-bun: test/js/bun/spawn/spawn.test.ts fails in "stdout reader of an unref'd child and process lifetime > an idle reader stopped at the highwater". Build 118157 has the same failure on the same lane. The CI helper marks it as also seen on main.
  • :darwin: any aarch64 - test-bun: the test runner stopped at test 1652 of 3101 with Error: ENOENT: no such file or directory, open '.../test/js/node/test/parallel/test-fs-watch-recursive-symlink.js' at runOneTest (scripts/runner.node.mjs:847:29). A checked-in test file was missing from the checkout during the run. The runner did not assign the failure to a test. The tests after that point did not run on that lane.
  • Both failures are reported for triage. This PR changes src/js/node/http2.ts and two files in test/js/node/http2/. It does not touch Bun.spawn, the pipe reader, the node:fs watch tests, or the runner.
  • No lane reports test/js/node/http2/node-http2.test.js as failed in either build. The log of the darwin aarch64 job does not show that file, so I cannot confirm that it ran on that lane in this build. The change is JS only and has no code that depends on the platform.
  • The other entries in both builds passed on a retry.

The PR is ready for a maintainer to review and merge.

Found on the way, outside this PR. Separate work tracks each one:

  • additionalHeaders() on a HEAD request throws ERR_HTTP2_PAYLOAD_FORBIDDEN in bun and sends the block in node.
  • additionalHeaders() adds :status: 200 to a block that has no :status. Node sends the block as is.

@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: be2f7c72-2607-482c-8679-bb8849af5405

📥 Commits

Reviewing files that changed from the base of the PR and between 8e1e870 and f853af6.

📒 Files selected for processing (1)
  • src/js/node/http2.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.


Walkthrough

Changes

The HTTP/2 implementation leaves sentInfoHeaders undefined until headers are successfully submitted. A fixture and cross-runtime test cover plain, invalid, and mixed informational-header flows.

HTTP/2 informational header tracking

Layer / File(s) Summary
Stream tracking and submission order
src/js/node/http2.ts
sentInfoHeaders returns undefined before any informational headers are sent. Headers are recorded only after native submission succeeds.
Cross-runtime fixture and tests
test/js/node/http2/http2-sent-info-headers.fixture.js, test/js/node/http2/node-http2.test.js
The fixture records client and server state across stream events and header attempts. Tests compare Bun and Node results for successful and failed submissions.

Suggested reviewers: cirospaciari

Priority: ⬇️ Low

🚥 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 and concisely describes the primary compatibility change to sentInfoHeaders.
Description check ✅ Passed The description explains the problem, fix, scope, verification steps, test results, and known out-of-scope differences. It provides the required information even though it does not use the template he…

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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked the maxSendHeaderBlockLength path in the reordered additionalHeaders(): the native request() returns normally there (it dispatches onFrameError/onStreamError instead of throwing), so the block is still recorded in sentInfoHeaders — which matches Node, whose additionalHeaders() also records before the info() send that can fail on size.

Extended reasoning...

The diff is small: the getter drops its || [] fallback and the native validate-and-send call moves ahead of the kInfoHeaders push in ServerHttp2Stream.additionalHeaders(). I traced the one path where the native call fails without throwing (h2_frame_parser.rs request() when encoded_size > max_send_header_block_length): it closes the stream, emits frame/stream errors, and returns a stream id, so the JS side still records the block. Node's additionalHeaders() records the block before calling this[kHandle].info(headers), so the observable sentInfoHeaders is the same there; that candidate is not a divergence introduced by this PR. The remaining inline finding (a block without :status being treated as informational) is pre-existing and acknowledged as out of scope in the description.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/js/node/http2.ts — Servers that call additionalHeaders({}) or a block without :status send a final 200 HEADERS frame instead of an informational one, then respond() sends a second final HEADERS, and the block is recorded in sentInfoHeaders as if it were a 1xx. src/js/node/http2.ts:3470 injects :status: 200 before validation, and the 1xx range check at 3476 is skipped because hasStatus is false. Fix: do not synthesize a status in additionalHeaders(); a block without :status must either be sent as-is like Node or be rejected, and it must never be recorded as an informational block.

    Extended reasoning...

    Pre-existing at the base commit, and the PR names it as out of scope, but this PR is the one that defines what sentInfoHeaders means and adds the test at node-http2.test.js:1840 asserting it 'lists only the 1xx blocks that were sent'. A block with no :status is the missing row in that test. Trace: additionalHeaders() copies the object at http2.ts:3448, checks headers[HTTP2_HEADER_STATUS] === undefined at 3469, sets it to 200 at 3470 and hasStatus = false, so the statusCode < 100 || statusCode >= 200 check at 3476 never runs. request() at 3495 encodes and writes a HEADERS frame with :status: 200, which HTTP/2 clients treat as the final response headers. The block is then pushed into kInfoHeaders at 3496-3500. A later respond() writes another HEADERS with :status 200; a strict client (nghttp2-based, browsers) treats a second non-1xx HEADERS as a protocol error and resets the stream. Population: any server that passes hint headers to additionalHeaders() without an explicit :status, which Node accepts. Rate: every such request. Node does not add a status here (PR description confirms).…

    Verification: pre-existing — the injection and the wrong frame both exist byte-for-byte at the base commit and the PR description lists "Bun adds :status: 200 to a block that has no :status. Node does not." under "Not changed here"; the diff touches the same function (moves the native call above the kInfoHeaders record) but does not alter this path. Trigger: a server calls… | pre-existing…

@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

The finding about a block with no :status is correct, and it is older than this PR. I checked additionalHeaders({ link: "</style.css>; rel=preload" }) followed by respond() against node v26.3.0:

  • node sends the block with no :status. The client rejects the frame and the request fails with NGHTTP2_PROTOCOL_ERROR. sentInfoHeaders is [{"link":"..."}].
  • bun adds :status: 200. The client gets a 'response' event with status 200. Then respond() sends a second final HEADERS frame and the request fails with the same NGHTTP2_PROTOCOL_ERROR. sentInfoHeaders is [{"link":"...",":status":200}].

The request fails in both runtimes, so this PR leaves that path as it is. Separate work now tracks the status default. That work can add a row for a block with no :status to http2-sent-info-headers.fixture.js.

The maxSendHeaderBlockLength path needs no change here. Node also records the block before the send that can fail on size.

Comment thread src/js/node/http2.ts Resolved

@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 — the follow-up commit trimmed the additionalHeaders() comment, and the two-line runtime change plus the Node-vs-Bun fixture read correctly.

What was reviewed:

  • sentInfoHeaders getter returning the raw slot: kInfoHeaders has no other readers in src/, and the only other test assertion on the old [] (line 892 of node-http2.test.js) was updated; the vendored test-http2-sent-headers.js indexes [0] only after a successful additionalHeaders(), so it is unaffected.
  • The request()-before-record reorder: nothing between the native call and the push observes kInfoHeaders, the same headers object is stored either way, and the pre-existing ?. on bunHTTP2Native is unchanged behavior.
  • Fixture hermeticity: one session per request, error/close wired to reject/resolve, client.close() and server.close() in finally, listens on port 0 at 127.0.0.1; onServerStreamClose is reassigned before each sequential run so it cannot cross-talk.
Extended reasoning...

Overview

The PR touches src/js/node/http2.ts (two edits: the sentInfoHeaders getter now returns this[kInfoHeaders] without the || [] fallback, and ServerHttp2Stream.additionalHeaders() now calls the native request() before pushing the block onto kInfoHeaders), adds test/js/node/http2/http2-sent-info-headers.fixture.js (a CJS module that drives /plain, /invalid, and /info-then-invalid and snapshots sentInfoHeaders at each lifecycle point on both sides), and extends test/js/node/http2/node-http2.test.js with a test that runs the fixture in-process and, when nodeExe() is available, under real Node, comparing both against one expected document. One existing assertion (req.sentInfoHeaders.length is 0) was updated to toBeUndefined().

Since the prior review on this PR, only two commits landed: an empty CI retrigger and a commit that shortens the comment above the native call at the exact line where an inline comment had been left. No runtime logic changed between the previously reviewed version and HEAD.

Security risks

None identified. The change does not alter header validation, wire encoding, or the HPACK path; it only changes when a JS-side bookkeeping array is populated and what the getter returns when nothing has been recorded. The fixture and test contact only a local loopback server on port 0.

Level of scrutiny

Low-to-moderate. The runtime diff is two lines in a builtin JS module, both matching Node's lib/internal/http2/core.js semantics (validate, record, send — Bun's native request() validates and throws before sending, so calling it first yields the same observable state as Node). I grepped src/ for kInfoHeaders/sentInfoHeaders: the getter and additionalHeaders() are the only readers/writers, so returning undefined cannot break another internal caller. I also grepped test/ for the old [] behavior: the one in-repo assertion was updated in this PR, and the vendored Node test test-http2-sent-headers.js only indexes sentInfoHeaders[0] after a successful additionalHeaders() call, which still populates the array.

Other factors

The test is capable of failing on the pre-fix build for both changes: /plain would yield [] instead of "undefined" (getter), and /invalid would yield the rejected block instead of "undefined" (reorder). The fixture is hermetic: one session per request, error and close wired to reject/resolve, client.close() and server.close() in finally, sequential runs so the shared onServerStreamClose cannot cross-talk, and the Node subprocess drains stdout/stderr/exited concurrently with await using. The .fixture.js suffix is not picked up as a test file. No CODEOWNERS entry covers the changed paths, no human reviews are outstanding, and the bug hunt ran to a dry streak with no findings.

This branch has not been deployed

No deployments
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.

1 participant