Skip to content

node:http2: prepare the final response headers like node - #43526

Open
robobun wants to merge 3 commits into
robobun/38339079/http2-status-validationfrom
robobun/4bedd5c5/http2-response-status-like-node
Open

robobun wants to merge 3 commits into
robobun/38339079/http2-status-validationfrom
robobun/4bedd5c5/http2-response-status-like-node

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #43453 (the base branch). Merge #43453 first.

Problem

  • respond(), respondWithFile() and respondWithFD() throw ERR_HTTP2_STATUS_INVALID for a :status that coerces to 0 (0, "", null, NaN, "abc"). Node v26.3.0 sends 200.
  • In the file methods a never-index list that is not an array becomes a stream 'error' after fstat (client: RST_STREAM INTERNAL_ERROR). Node throws ERR_INVALID_ARG_VALUE from the call. Some checks run in another order than in node, so another error wins on doubly invalid input.
  • Cause: each method in src/js/node/http2.ts has its own copy of the :status default and range check. None is node's prepareResponseHeadersObject().

Fix

  • One port of node's prepareResponseHeadersObject() and validatePreparedResponseHeaders() replaces the copies: :status | 0 || 200, the date default, the 200-599 range check, the never-index list check.
  • The file methods reject a closed stream, then check options, fd and the headers, in node's order. respond() runs the port before it walks the header list.
  • statCheck now sees the integer :status and the date, like node.
  • Verified: two new tests in test/js/node/http2/node-http2.test.js. One runs a fixture (56 calls) under Bun and under node and compares every line. It fails on the base branch. Also all of test/js/node/http2/ and the 256 vendored test-http2-* tests.

Background

  • :status | 0 turns "404" into 404 and a non-numeric value into 0. Node replaces 0 with 200.
  • The never-index list is headers[http2.sensitiveHeaders]: header names that HPACK must not add to its table.
  • respondWithFile() calls fstat before it sends headers. A later error reaches the user only through options.onError or the stream 'error' event. statCheck is a user callback that runs after fstat.
Notes

Node source: prepareResponseHeadersObject, validatePreparedResponseHeaders, respondWithFD, respondWithFile. The | 0 || 200 default and the < 200 check are the same in node v20, v22 and v24 (processHeaders there).

No user report exists for this. It comes from a comparison with node. #43453 left the 0 to 200 default out on purpose ("pre-existing"). This PR takes it, because .claude/docs/landing-prs.md says never validate stricter than node. The visible effect: a call that threw ERR_HTTP2_STATUS_INVALID for :status 0 now sends a 200, as node does.

The fixture (test/js/node/http2/http2-response-status.fixture.js) makes 56 calls, each on its own stream, and reports one line per call: what the call threw and what the client received. The test runs it in-process under Bun against an inline snapshot, then as a script under node, and compares the lines. Node v26.3.0 and this branch print the same 56 lines. Lines that differ on the base branch (- node and this branch, + base branch):

- respond({ ":status": 0 }): returns | client: response 200, body "respond body"
+ respond({ ":status": 0 }): throws RangeError ERR_HTTP2_STATUS_INVALID: Invalid status code: 0 | client: response 200, body "fallback"
  (same for "", null, NaN, "abc", and for respondWithFile() and respondWithFD())
- respond({ ":status": 99, [sensitiveHeaders]: "x" }): throws RangeError ERR_HTTP2_STATUS_INVALID: Invalid status code: 99
+ respond({ ":status": 99, [sensitiveHeaders]: "x" }): throws TypeError ERR_INVALID_ARG_VALUE: The property 'headers[http2.neverIndex]' is invalid. Received 'x'
- respond({ ":status": 99, "content-type": ["a", "b"] }): throws RangeError ERR_HTTP2_STATUS_INVALID: Invalid status code: 99
+ respond({ ":status": 99, "content-type": ["a", "b"] }): throws TypeError ERR_HTTP2_HEADER_SINGLE_VALUE: Header field "content-type" must only have a single value
- respondWithFile({ [sensitiveHeaders]: "x" }): throws TypeError ERR_INVALID_ARG_VALUE: The property 'headers[http2.neverIndex]' is invalid. Received 'x' | client: response 200, body "fallback"
+ respondWithFile({ [sensitiveHeaders]: "x" }): returns, stream error ERR_INVALID_ARG_VALUE | client: error ERR_HTTP2_STREAM_ERROR, body ""
- respondWithFile("headers", { length: "1" }): throws TypeError ERR_INVALID_ARG_VALUE: The property 'options.length' is invalid. Received '1'
+ respondWithFile("headers", { length: "1" }): throws TypeError ERR_INVALID_ARG_TYPE: The "headers" argument must be of type object. Received type string ('headers')
- respondWithFD("fd", { ":status": 99 }, { offset: "1" }): throws TypeError ERR_INVALID_ARG_VALUE: The property 'options.offset' is invalid. Received '1'
+ respondWithFD("fd", { ":status": 99 }, { offset: "1" }): throws TypeError ERR_INVALID_ARG_TYPE: The "fd" argument must be of type number or an instance of FileHandle. Received type string ('fd')
- respondWithFile({ ":status": "0" }, { statCheck }): returns, statCheck saw :status 200 and a string date
+ respondWithFile({ ":status": "0" }, { statCheck }): throws RangeError ERR_HTTP2_STATUS_INVALID: Invalid status code: 0
- close(), then respondWithFD({ ":status": 99 }, { offset: "1" }): throws Error ERR_HTTP2_INVALID_STREAM: The stream has been destroyed
+ close(), then respondWithFD({ ":status": 99 }, { offset: "1" }): throws TypeError ERR_INVALID_ARG_VALUE: The property 'options.offset' is invalid. Received '1'

The date default moves with the port. Node adds date before it walks the header list. Two results follow, and both match node. respond({ date: "x\r\n" }) sends no date: the value that cannot be sent is dropped and no default takes its place (before: a default date). respond({ Date: "x" }) throws ERR_HTTP2_HEADER_SINGLE_VALUE from the JS check for single-value headers. Before, the same error came from the native header walk, after earlier fields were already in the HPACK table (#41520 covers that class of problem).

Not changed here

  • additionalHeaders(): node:http2: validate the server stream :status like node #43453 and node:http2: let additionalHeaders() send a 1xx block on a HEAD request #43449 cover it.
  • The raw-array form of respond() with a falsy :status (respond([":status", 0])). Node prepends a default :status, finds two, and throws ERR_HTTP2_HEADER_SINGLE_VALUE. Bun throws ERR_HTTP2_STATUS_INVALID. An existing comment documents that choice. This PR adds what node does to that comment. The raw-array form gets the same range and never-index checks through validatePreparedResponseHeaders().
  • The type asserts on headers and options. Node throws for null headers and for a non-object options. Bun accepts both. For a non-object headers the message differs (must be of type object, node: must be an instance of Array or Object).
  • A statCheck that changes the headers. doSendFileFD() calls respond() after statCheck, and respond() prepares the headers again. A date that statCheck deleted comes back (node sends none). A :status that statCheck set to a value that coerces to 0 is still an error (ERR_HTTP2_STATUS_INVALID through onError or the stream 'error'), as on main. The 200 default is for the caller's headers only. Node sends such a value as is, and the client fails the stream. The second new test covers this.
  • respond() on a stream that is closed but not destroyed. Node throws ERR_HTTP2_INVALID_STREAM, Bun does not. Internal callers reach respond() (the implicit response in _write(), the compat layer), so that guard needs its own change.
  • The fstat failure branch of doSendFileFD(), which calls respond() before it destroys the stream: node:http2: a failed respondWithFD()/respondWithFile() calls respond() before it destroys the stream #43448, node:http2: do not call respond() when a file response fails before the headers are sent #43463.

Review follow-up: the statCheck case above regressed in the first push of this PR (a silent 200) and the second push restores it. The second push also adds the closed half of node's destroyed || closed guard to the two file methods.

Self-review: 5 concerns raised, 4 addressed (check order on doubly invalid input, the never-index check missing from the file methods, the date default missing from the port, a node-run fixture in place of a hand-typed table). The fifth, the :status copy in additionalHeaders(), is covered by #43453 and #43449.

Suites run with the debug build: test/js/node/http2/*.test.* (all files), 256 vendored test/js/node/test/parallel/test-http2-*.js, grpc-js test-server, test-server-errors, test-server-interceptors.


[human-review] gate passed · iteration 0 · 3 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/pr_gate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.3 (367d939d9)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [1093.52ms]
(pass) node none > Client Basics > should be able to send a POST request [782.75ms]
(pass) node none > Client Basics > constants [17.53ms]
(pass) node none > Client Basics > getDefaultSettings [7.17ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [17.69ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.65ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.38ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [5.26ms]
(pass) node none > Client Basics > should be able to send data using end [800.46ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [786.08ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving 
... (truncated)

release without fix: 6 skipped
bun test v1.4.3-canary.1 (47e5dbd95)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [1.09ms]
(pass) node none > Client Basics > getDefaultSettings [0.20ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.33ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.15ms]
(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 [3.63ms]
(pass) node none > Client Basics > aborted event should work with abortController [1.03ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [1.22ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [0.97ms]
(pass) node none > Client Basics > should fail to connect over HTTP/1.1 [64.42ms]
(skip) node none > Client Basics > should not leak memory
(pass) node none > Client Basics > headers cannot be bigge
... (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 (367d939d9)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [1112.30ms]
(pass) node none > Client Basics > should be able to send a POST request [766.82ms]
(pass) node none > Client Basics > constants [18.13ms]
(pass) node none > Client Basics > getDefaultSettings [7.82ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [22.58ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [6.58ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [4.13ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [5.89ms]
(pass) node none > Client Basics > should be able to send data using end [780.74ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [760.23ms]
(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 745ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/125] gen JS modules (bundle-modules)
Preprocess modules (7988ms)
Bundle modules (205ms)
Postprocesss modules (175ms)
Bundle Functions (784ms)
Generate Code (50ms)

[9.21s] 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                               | 116 +++++++-------
 .../js/node/http2/http2-response-status.fixture.js | 171 +++++++++++++++++++++
 test/js/node/http2/node-http2.test.js              | 141 ++++++++++++++++-
 3 files changed, 368 insertions(+), 60 deletions(-)

gate history · 3 passed · 0 rejected · iteration 0

evidence per changed file
file                                                 reads  edits  tests
src/js/node/http2.ts                                    12     12     41
test/js/node/http2/http2-response-status.fixture.js      2      7     44
test/js/node/http2/node-http2.test.js                    6      9     40

respond(), respondWithFile() and respondWithFD() share a port of node's
prepareResponseHeadersObject() and validatePreparedResponseHeaders(): a
:status that coerces to 0 becomes 200, the date default is added at that
point, and the status range is checked before the never-index list.

The file methods validate options first, then fd, then the headers, like
node, so the synchronous status throw does not change which error wins on
doubly invalid input. A never-index list that is not an array now throws
from respondWithFile() and respondWithFD() instead of arriving later as a
stream error, and statCheck sees the integer :status and the date.
@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:39 PM PT - Sep 19th, 2026

✅ @robobun, your commit 93874fb11d7b66a551845383b8c2994be21c758b passed in Build #118570! 🎉


🧪   To try this PR locally:

bunx bun-pr 43526

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

bun-43526 --bun

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced against node v26.3.0 with the fixture from this PR:

printf 'file body' > /tmp/body.txt
node test/js/node/http2/http2-response-status.fixture.js /tmp/body.txt > node.txt
bun test/js/node/http2/http2-response-status.fixture.js /tmp/body.txt > bun.txt
diff node.txt bun.txt

Node prints 56 lines. Bun on the base branch (#43453) differs in three places:

  • A :status that coerces to 0 throws ERR_HTTP2_STATUS_INVALID. Node sends 200.
  • A never-index list that is not an array becomes a stream 'error' in respondWithFile() and respondWithFD(). Node throws ERR_INVALID_ARG_VALUE from the call.
  • The check order differs on doubly invalid input, so a different error wins.

With this branch the two outputs are identical.

PR: #43526, stacked on #43453. Merge #43453 first.

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

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

  • 🟡 src/js/node/http2.ts — A statCheck callback that writes a non-numeric, empty, null or NaN :status now results in a 200 being sent silently after merging; on the base branch the same callback produced ERR_HTTP2_STATUS_INVALID through onError or the stream 'error' event. doSendFileFD at http2.ts:3043 calls this.respond(headers, options) after statCheck, and respond() at http2.ts:3590 re-runs prepareResponseHeadersObject, whose | 0 || 200 at http2.ts:3186 rewrites the mutated value to 200. Fix: after statCheck, validate rather than re-default: range-check the statCheck result and reject a 0-coerced :status, or pass the prepared headers to a respond path that does not re-apply the default. [also at: src/js/node/http2.ts:3186 - Servers whose statCheck removes the date header, or resets :status to 0 to mean 'use default', get a header set different from node: the second prepare in respond() re-adds date and sets 200 silently. prepareResponseHeadersObject at src/js/node/http2.ts:3186-3190 runs once before fstat and again…]

    Extended reasoning...

    respondWithFile is called with valid headers; http2.ts:3367 prepares :status to 200 and adds date. fstat completes and doSendFileFD:3022 calls options.statCheck(stat, headers, options). The callback sets headers[':status'] = someValue where someValue is null, '', NaN or a non-numeric string (for example a lookup that returned nothing). On the base branch respond() then ran headers[':status'] |= 0 giving 0 and threw ERR_HTTP2_STATUS_INVALID, caught at 3044 and delivered to onError or this.destroy, so the app saw the mistake. After this diff respond() calls prepareResponseHeadersObject (http2.ts:3590), which computes 0 || 200 and sends a 200 with the file body. Node does not re-prepare at all: it sends the raw value so the client observes a protocol failure, also an error. Bun is the only runtime that silently reports success. The PR text lists this as unchanged, but the base did not…

    Verification: nit — acknowledged in diff: the PR description's "Not changed here" bullet says "A statCheck that changes :status or deletes date. doSendFileFD() calls respond(), which prepares the headers again. Node does not check them again at that point" — that note is accurate about the mechanism but does not state the visible effect (a silent 200 where the base surfaced an error). Triggering…

  • 🟣 src/js/node/http2.ts — A respondWithFD() caller whose fd fails fstat gets a 200 response header sent and then an INTERNAL_ERROR reset, and if the stream was destroyed meanwhile the process gets an uncaught throw. doSendFileFD's error branch at http2.ts:2965-2969 calls this.respond(headers, options) with no try/catch, unlike the guarded call at http2.ts:3042-3053, and unlike node's doSendFD which only calls this.destroy(err). Fix: on fstat error route to onError or this.destroy(err) without calling respond(), and wrap any remaining respond() in the same try/catch as line 3043.

    Extended reasoning...

    stream.respondWithFD(fd, headers) is called with an fd that later fails fstat (already closed by the caller, EBADF), or respondWithFile hits a fstat failure. afterOpen at http2.ts:3132 calls fs.fstat; the callback lands in doSendFileFD with err set. Line 2965: if no onError is given, line 2967 runs this.respond(headers, options). respond() at http2.ts:3519-3525 throws ERR_HTTP2_INVALID_STREAM if the stream was destroyed during the fstat round trip (client RST arriving in between), and throws ERR_HTTP2_HEADERS_SENT if the app responded in the meantime. That throw happens inside an fs callback with no catch, so it is an uncaught exception that ends the process. When respond() does not throw, the client receives a 200 HEADERS frame followed by RST_STREAM INTERNAL_ERROR, while node destroys the stream with the fstat error and sends no headers. The base has the identical line, but this PR now prepares status and date before fstat so the headers object always looks valid here and the branch is reached with the same shape as the guarded call at 3043; the finder read the line but treated the…

    Verification: pre-existing. Trigger: respondWithFD()/respondWithFile() without options.onError whose fstat fails (e.g. caller already closed the fd -> EBADF), and for the crash variant the stream is destroyed/closed (peer RST_STREAM, session close) between the call and the fstat callback. Mechanism verified in /home/claude/bun/src/js/node/http2.ts. doSendFileFD's error branch (lines 2960-2970) is: ``` if…

Comment thread src/js/node/http2.ts Outdated
@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Overlap note: #43550 changes the same lines of respondWithFD(). Both PRs move the fd block below the options checks and above the headers checks. They start from different bases (#43453 here, #43491 there), so the second one to land needs a rebase in that method.

#43550 also adds || this.closed to the state check of respondWithFD() and respondWithFile() (node throws ERR_HTTP2_INVALID_STREAM after stream.close()), with test rows for a bad fd on a stream that already responded, is closed, or is destroyed. If this PR lands first, #43550 shrinks to that part.

…m in file responses

respond() prepares the headers again after statCheck. Its 200 default is
for the caller's headers, so doSendFileFD() rejects a :status that
statCheck replaced with a value that coerces to 0, as before the shared
port.

respondWithFile() and respondWithFD() throw ERR_HTTP2_INVALID_STREAM for
a stream that is closed but not destroyed yet, like node.
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated

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

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up to the review, in 47e5dbd and 93874fb.

A statCheck that writes a :status that coerces to 0. Fixed. The first push turned this into a silent 200, because respond() prepares the headers again after statCheck. doSendFileFD() now rejects such a value before it calls respond(). The result is ERR_HTTP2_STATUS_INVALID through onError or the stream 'error', the same as on main. The 200 default applies only to the headers the caller passed. The new test "http2 file responses reject a :status that statCheck set to a value that coerces to 0" covers "abc", null and 0 for both file methods. It fails on the first push and passes now. A date that statCheck deleted still comes back, as on main. The PR notes say so.

The closed half of node's guard. Added to respondWithFile() and respondWithFD(): they throw ERR_HTTP2_INVALID_STREAM for destroyed || closed, before every other check. Two new fixture rows cover it, and node prints the same lines. respond() has the same gap. I left it as it is, because internal callers reach respond() (the implicit response in _write(), the compat layer), and that needs its own change.

The fstat failure branch that calls respond() with no try/catch. This PR does not change that branch. #43448 tracks it and #43463 fixes it.

I shortened the new code comments to one line each.

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

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

A note on an overlap with #43587.

The test http2 file responses reject a :status that statCheck set to a value that coerces to 0 expects six onError calls. Three of them come from respondWithFD(). Node v26.3.0 never reads onError in respondWithFD(), and #43587 removes those calls. The three respondWithFD() entries then see a reset stream, and no 500 from the handler.

The two PRs have no text conflict. So the one that lands second makes node-http2.test.js red on main, unless this test changes. A version that keeps only the respondWithFile() entries passes with both PRs. So does a version that reads the error of the respondWithFD() entries from the stream 'error' event.

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