Skip to content

node:http2: encode each header block in one call, all fields or none - #41520

Draft
robobun wants to merge 6 commits into
mainfrom
robobun/7ce16bad/h2-validate-headers-before-encode
Draft

robobun wants to merge 6 commits into
mainfrom
robobun/7ce16bad/h2-validate-headers-before-encode

Conversation

@robobun

@robobun robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A node:http2 header call that fails part-way leaves its earlier fields in the HPACK encoder table, unsent. Later blocks decode against shifted entries: another stream's set-cookie, or ERR_HTTP2_ERROR Protocol error.
  • send_trailers, push_promise and request (src/runtime/api/bun/h2_frame_parser.rs) encoded field by field. A later field could fail validation or exceed the encoder's 65536 bytes.

Fix

  • lshpack_wrapper_encode_block checks every field, then encodes the block. bun_runtime has no per-field encode.
  • The three functions stage validated fields in a per-VM scratch. They encode after the last check that can refuse the block. A field enters the table only when its block is sent.
  • cork() keeps frames that transport JS queues during a hand-over. The encode corks first.
  • Verified: test/js/node/http2/node-http2.test.js (54 new rows, 51 fail on main), test/js/node/http2/, node's 256 test-http2-*.js.

Background

  • HPACK keeps one dynamic table per connection. The peer mirrors each insertion. Later blocks name entries by index.
  • A thread's sessions share one cork buffer. Taking it flushes the previous owner, which can run JS.
  • Alternatives: a size check in the walks costs one more allocation and copy per field. Clearing the table after a failure lets unsent fields enter.

Downsides

  • maxSendHeaderBlockLength counts the uncompressed bound, like node. A block that fits only after compression is refused. A refused response gets a FRAME_SIZE_ERROR reset.
  • Per header call: one 16 KB mi_malloc less, +1.3% instructions (28,324 against 27,947, 7-field respond()). Per VM: +72 bytes, up to 128 KiB kept. .text: +2,304 bytes.
  • Not fixed: a field over 65536 bytes still ends the whole session.
Notes

Three ways a block failed after its first fields were in the table

  1. A later field fails validation and the call throws: an invalid value or name, undefined, null, a Symbol, a toString that throws.
  2. The encoder refuses a later field, because the name plus the value is longer than 65536 bytes. The call returns. The session error (code 9) arrives from the event loop. Blocks that other streams send in the same tick go out first, with shifted indices. On main, the maxSendHeaderBlockLength check in request() also ran after the encode.
  3. cork() flushes the previous owner of the cork buffer through its transport. When that transport is a JS Duplex, its _write can make a header call on this session. cork() then reset the buffer offset and dropped those frames, after their fields were in the table.

Repro for the first way (bun 1.4.3, bun client and bun server):

import http2 from "node:http2";
const srv = http2.createServer();
srv.on("stream", (st, h) => {
  if (h[":path"] === "/poison") {
    try { st.additionalHeaders({ ":status": 103, "x-a": "AAAA", "x-v": "a\r\nb" }); } catch (e) { console.log("S threw", e.code); }
    st.respond({ ":status": 200, "x-after-info": "1" }); st.end("p");
  } else { st.respond({ ":status": 200, "x-clean": "clean-value", "content-type": "text/plain" }); st.end("c"); }
});
srv.listen(0, async () => {
  const s = http2.connect("http://127.0.0.1:" + srv.address().port);
  s.on("error", e => console.log("C sess", e.code));
  const get = p => new Promise(r => { const q = s.request({ ":path": p }); q.on("response", h => { delete h.date; console.log("C", p, JSON.stringify(h)); }); q.resume(); q.on("error", e => console.log("C", p, "err", e.code)); q.on("close", r); });
  await get("/clean1"); await get("/poison"); await get("/clean2"); s.close(); srv.close();
});

Before:

C /clean1 {":status":200,"x-clean":"clean-value","content-type":"text/plain"}
S threw ERR_HTTP2_INVALID_HEADER_VALUE
C /poison {":status":200,"x-after-info":"1","x-clean":"clean-value"}
C sess ERR_HTTP2_ERROR
C /clean2 err ERR_HTTP2_ERROR

After:

C /clean1 {":status":200,"x-clean":"clean-value","content-type":"text/plain"}
S threw ERR_HTTP2_INVALID_HEADER_VALUE
C /poison {":status":200,"x-after-info":"1"}
C /clean2 {":status":200,"x-clean":"clean-value","content-type":"text/plain"}

What a refused block does now

  • respond(), additionalHeaders(), pushStream(), request(), compat writeHead() and writeEarlyHints() with a field over 65536 bytes: the session still ends with ERR_HTTP2_SESSION_ERROR: Session closed with error code 9 one tick later, as before. Blocks that other streams send in that tick now decode right.
  • sendTrailers() with such a field: frameError, the stream ends with FRAME_SIZE_ERROR, then a graceful GOAWAY, as before.
  • maxSendHeaderBlockLength: the check uses nghttp2's bound (12, plus 12 per field, plus the name and value bytes, plus 5) and runs before the encode. A refused server response dispatches frameError and resets the stream. A refused 1xx block leaves the stream open for the final response. The client side is as before, with the new bound.
  • A throw from ClientHttp2Session.request() is no longer reported a second time as a session 'error'. Nothing reached the wire, and node reports the throw only.

Measurements

Release builds of main (bbdc5a5) and of this PR, linux x64, same toolchain. The container has no perf, valgrind, strace, ltrace or bloaty. The counts come from gdb: breakpoints on the allocator entry points and single steps, counted only inside the host function, in steady state.

  • Allocator calls per call (mi_malloc calls, bytes requested):
    • respond(), 7 fields: 19 (17,136 B) on main, 18 (752 B) on the PR
    • client request(): 27 (17,328 B), then 26 (944 B)
    • sendTrailers(), 2 fields: 7 (16,688 B), then 6 (304 B)
    • pushStream(), 5 fields: 15 and 1 mi_realloc, then 14 and 0
    • additionalHeaders(): 9, then 8
    • The first header call in a VM allocates the scratch. Later calls reuse it.
  • Instructions per call, callees included:
    • respond(), 7 fields: 27,947, then 28,324 (+1.3%)
    • client request(): 33,361, then 33,735 (+1.1%)
    • sendTrailers(): 14,183, then 14,132 (-0.4%)
    • pushStream(): 21,602, then 21,633 (+0.1%)
  • Copies: one copy of each name and value on both builds. Main also zero-fills name + value + 32 bytes per field before it encodes.
  • Code size of the three host functions and their helpers: 26,028 B, then 26,584 B (+556 B). .text: 65,382,485 B, then 65,384,789 B (+2,304 B). The stripped binary is 88,864,328 B on both.
  • Host functions: 29 on both. lshpack_wrapper_* symbols: 2, then 3.
  • size_of::<H2HeaderScratch>() is 72, so RareData grows by 72 B. H2FrameParser stays at 1,544 B. The slot keeps a scratch only when each of its two buffers is at most 64 KiB.
  • write() of a 9-byte chunk when the session owns the cork: 79 instructions, then 71.
  • Wall clock, user CPU for 200,000 request and response pairs, 10 interleaved runs: median 10.995 s on main, 11.013 s on the PR (+0.16%). The noise floor is 1.65%, so this shows no difference.

Tests

test/js/node/http2/node-http2.test.js has 54 new rows. 51 fail on bun 1.4.3-canary.1.

  • "a header call that throws leaves the HPACK encoder in sync with the peer" (40 rows). Seven kinds of bad field across additionalHeaders, respond, compat res.end, server and client sendTrailers, pushStream and client request. Each row makes a clean request, the failing call, and a clean request on the same session. The invalid-name rows of respond() and of client request() pass on main.
  • "a header block the encoder refuses does not reach the HPACK table" (8 rows). Six server calls send a fresh field and then a field of 65537 bytes. Three held streams get their answers in the same tick and must decode right. One row does the same with a client request(). One row reads raw frames: a field of exactly 65536 bytes is sent, and the next block is indexed against it. That row passes on main.
  • "maxSendHeaderBlockLength on a server response" (1 row). With a limit of 300, a 246-byte value is sent, a 247-byte value resets the stream, and a refused 1xx block leaves the stream open. Node v26.3.0 gives the same result for the same script.
  • "header calls made from another session's transport _write during a cork hand-over" (5 rows).

Also run: all of test/js/node/http2/ and node's 256 test-http2-*.js files on a debug build and on a release build of the PR, and the source lints in test/internal/. On the debug build in my container, 15 older tests of this file that start a child process need more than the 5 s default timeout, on main too. They pass with --timeout 120000.

Not in this PR

Related PRs

History

The first version of this PR (September, 90f334a) fixed the validation case only. Its text said that an encoder failure needs no pre-check, because it ends the session. That statement was not tested and it is wrong: see the second way above. This version replaces it.


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

fails on main (without fix)
ASAN without fix: 33 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 (f42e98025)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [1273.26ms]
(pass) node none > Client Basics > should be able to send a POST request [900.63ms]
(pass) node none > Client Basics > constants [23.25ms]
(pass) node none > Client Basics > getDefaultSettings [8.90ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [22.36ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [7.54ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [4.55ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [6.35ms]
(pass) node none > Client Basics > should be able to send data using end [932.65ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [922.14ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving 
... (truncated)

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

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [1.08ms]
(pass) node none > Client Basics > getDefaultSettings [0.26ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.37ms]
(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.09ms]
(pass) node none > Client Basics > is possible to abort request [2.66ms]
(pass) node none > Client Basics > aborted event should work with abortController [1.08ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [0.82ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [1.32ms]
(pass) node none > Client Basics > should fail to connect over HTTP/1.1 [44.37ms]
(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 (f42e98025)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [1373.71ms]
(pass) node none > Client Basics > should be able to send a POST request [859.40ms]
(pass) node none > Client Basics > constants [28.66ms]
(pass) node none > Client Basics > getDefaultSettings [12.80ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [26.12ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [8.67ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [6.50ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [8.74ms]
(pass) node none > Client Basics > should be able to send data using end [910.71ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [905.16ms]
(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)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     f7dc184a46
  features     baseline

23 deps, 131 codegen, 1172 objects in 1269ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] gen bindgenv2
[2/1244] gen ErrorCode+*.h
[3/1244] fetch zlib
[zlib] up to date
[4/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[5/1217] fetch tinycc
[tinycc] up to date
[6/1216] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[7/1216] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[8/1216] gen .bind.ts → GeneratedBindings.cpp
[9/1216] gen ProcessBindingBuffer.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingBuffer.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingBuffer.cpp
[10/1216] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[11/1216] install /workspace/bu
... (truncated)
diff hotspot
src/js/node/http2.ts                   |  15 +-
 src/runtime/api/bun/h2_frame_parser.rs | 362 +++++++++++++++------------------
 test/js/node/http2/node-http2.test.js  | 323 +++++++++++++++++++++++++++++
 3 files changed, 487 insertions(+), 213 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                    reads  edits  tests
src/js/node/http2.ts                        2      2     31
src/runtime/api/bun/h2_frame_parser.rs      9     18     32
test/js/node/http2/node-http2.test.js       1      0     31

root cause · written by the author bot

The header-serialization paths for request, sendTrailers, additionalHeaders, and pushStream validated and HPACK-encoded each field one at a time, so when a later field failed validation the call threw after earlier fields had already been inserted into the connection's shared dynamic table without ever being sent, leaving the peer's decoder out of sync and causing later header blocks to decode onto the wrong stream or trigger a COMPRESSION_ERROR GOAWAY. The fix collects and validates the complete header list into a private buffer before the encoder is touched, then performs a single…

@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on bun 1.4.3-canary.1 in three ways. Each one leaves fields in the HPACK encoder table that the peer never receives.

  1. A header call that throws on a later field (the script in the PR body): the /poison response carries x-clean from stream 1, then the session dies with ERR_HTTP2_ERROR.
  2. A header call with a field over 65536 bytes, for example stream.respond({ ":status": 200, "x-fresh": "v", "x-big": <65537 bytes> }): three responses that other streams send in the same tick arrive with wrong headers and no error.
  3. A header call made from the _write of another session's JS Duplex transport during a cork hand-over: the frames are dropped after the encode.

With this branch (88481d8) all three decode right. The 54 new rows in test/js/node/http2/node-http2.test.js cover them: 51 fail on 1.4.3-canary.1, all pass on the branch.

Status: draft. The branch is rebased on main and the code is complete. A self-review of the final diff is in progress. Not fixed here: a field over 65536 bytes still ends the session (see Downsides and Notes in the PR body).

@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:29 PM PT - Oct 7th, 2026

✅ @robobun, your commit 88481d8b8a19b15de8671bcbf7e428b6c635125b passed in Build #123809! 🎉


🧪   To try this PR locally:

bunx bun-pr 41520

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

bun-41520 --bun

@coderabbitai

coderabbitai Bot commented Sep 6, 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: Essentials

Run ID: aee6d4e2-fe18-489e-ac4f-486b5f012989

📥 Commits

Reviewing files that changed from the base of the PR and between 16eb85a and 9c3ca13.

📒 Files selected for processing (3)
  • src/js/node/http2.ts
  • src/runtime/api/bun/h2_frame_parser.rs
  • test/js/node/http2/node-http2.test.js

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


Walkthrough

HTTP/2 header serialization now validates fields before HPACK mutation across request, trailer, and PUSH_PROMISE paths. Request size checks and native request error handling were updated. Regression tests cover invalid headers and session continuity.

Changes

HTTP/2 header encoding

Layer / File(s) Summary
Header collection and batch encoding
src/runtime/api/bun/h2_frame_parser.rs
Header fields are copied into a private HeaderList, validated, and encoded in one HPACK operation.
Trailer and PUSH_PROMISE integration
src/runtime/api/bun/h2_frame_parser.rs
Trailer and PUSH_PROMISE fields are collected before encoding. Allocation, compression, frame-size, and session-error handling remain covered.
Request encoding and session errors
src/runtime/api/bun/h2_frame_parser.rs, src/js/node/http2.ts
Request headers use deferred encoding and pre-compression size checks. Native request failures rethrow without scheduling a session error.
Header validation regression coverage
test/js/node/http2/node-http2.test.js
Tests cover invalid response, trailer, push-stream, and client-request headers, including later successful operations and session stability.

Suggested reviewers: jarred-sumner, cirospaciari

Merge Risk: ⚪ Minimal · up to 9c3ca

HTTP/2 header handling now validates complete header lists before HPACK encoding, preventing invalid fields from corrupting later connection activity. No concrete current-head merge-blocking risk remains.

🚥 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 summarizes the primary change: encoding each HTTP/2 header block atomically so either all fields are encoded or none are.
Description check ✅ Passed The description explains the problem, implementation, trade-offs, scope, and verification results. It does not use the exact template headings, but it provides the required change summary and verifica…

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.

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

  • 🟡 src/js/node/http2.ts — nit: removing the process.nextTick(emitErrorNT, ...) call in ClientHttp2Session#request()'s catch block leaves emitErrorNT (line 447) with no callers in this file — it is now dead code. Fix: delete the emitErrorNT function in the same PR (REVIEW.md: "Delete dead code in the same PR that makes it dead… helpers whose last caller you rewired").

    Extended reasoning...

    Grep of emitErrorNT in src/js/node/http2.ts after the diff returns only line 447 (the definition); the sole call site at the old line 6269 was removed by this change. The identically-named helper in src/js/internal/streams/destroy.ts is a separate module-local function with a different signature and does not reference this one. Nothing else in the module (or the codebase) imports or calls http2.ts's three-argument emitErrorNT, so the function is unreachable after merge. REVIEW.md lists dead-code deletion as required scope for the PR that orphans the helper.

    Verification: nit — The diff removes the sole call site: - process.nextTick(emitErrorNT, this, e, this.#connections === 0 && this.#closed); at src/js/node/http2.ts:6269 (base). After the change, grep of emitErrorNT in src/js/node/http2.ts returns only line 447, the definition function emitErrorNT(self: any, error: any, destroy: boolean) { ... }. This is a module-local (non-exported) function with…

Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Both review findings are addressed in 0bffe36: HeaderField stores its lengths as usize, so there is no .expect() on a user-reachable path, and the now unused emitErrorNT helper is deleted.

Also ran node's test-http2-*.js files (256 of them) against this branch with the debug build: 256 pass, 0 fail.

Comment thread src/js/node/http2.ts Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs

@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 6, 2026

Copy link
Copy Markdown
Collaborator Author

#41614 is stacked on this branch. It adds node's value semantics (undefined skipped, null stringified, symbol keys never on the wire) on top of the HeaderList change here, and removes the "null value" row from the poison matrix because null is no longer a throw.

@robobun
robobun force-pushed the robobun/7ce16bad/h2-validate-headers-before-encode branch from f7dc184 to 90f334a Compare September 6, 2026 12:15

@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 21, 2026

Copy link
Copy Markdown
Collaborator Author

A note for whoever merges this: there is an overlap with #43474 (stacked on #43440).

#43474 now stages every header block in the native request() before the encoder sees it, so it covers the request() / respond() / additionalHeaders() part of this fix as a side effect. Measured with a node v26.3.0 server as the peer: client.request({ ":path": "/one", "x-a": "v", "x-bad": "line\nbreak" }) throws ERR_HTTP2_INVALID_HEADER_VALUE. On main the next request that carries x-a makes the peer report Protocol error and the session ends with code 9. On #43474 the next requests get 200.

#43474 does not touch push_promise() or send_trailers(), and it does not change the JS that reports the synchronous throw as a session 'error'. Both PRs rewrite the walk in request() in h2_frame_parser.rs, so they conflict in source. The one that lands second needs a rebase.

…ees it

sendTrailers, additionalHeaders, respond, pushStream and request validated
each field and encoded it into the shared HPACK dynamic table one at a time.
A later field that failed validation threw after the earlier fields were
already in the table and never sent, so the peer's table fell behind and
every later header block on the connection decoded against the wrong
entries.

The JS walk now collects the validated fields into a HeaderList and the
encode runs once the whole object is known. request() also checks
maxSendHeaderBlockLength against the pre-compression bound, as nghttp2
does, and runs the option and memory-limit checks before the encode, so a
refused block never touches the table either. A client request() that
throws no longer emits a session 'error' as well.
@robobun

robobun commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator Author

This PR is not complete. I found a case it does not fix.

What still breaks. A header call whose HPACK encode fails on a later field leaves the earlier fields of that call in the encoder table. The encoder refuses a field when the name plus the value is longer than 65536 bytes (LSHPACK_MAX_HEADER_SIZE in c-bindings.cpp). Example: stream.additionalHeaders({ ":status": 103, "x-trace": "t-1", link: <70 KB string> }). x-trace goes into the table, link fails, and nothing is sent. The session error (code 9) arrives from the event loop one tick later. Responses that other streams send in that same tick reach the peer first, with every dynamic index shifted.

Measured. Server: main bbdc5a5 plus this PR, debug build. Client: node v26.3.0. One connection, three streams in flight, answered in the same tick as the failed call. 3 of 3 answers arrive with wrong headers and no error, and they carry a set-cookie of an earlier stream. Main (1.4.3-canary.1+367d939d9) gives the same result. A node server gives 3 correct answers.

What the PR body said. The Notes said an encoder failure ends the session, so this case needed no check before the encode. I did not test that statement. It is wrong. The body is corrected.

What does hold. The validation cases (invalid name, CR/LF, undefined, null, Symbol, a throwing toString) are fixed by this branch. Two reproduction scripts for those cases (additionalHeaders with CR LF, sendTrailers and respond with an undefined value) exit 0 with this branch as the server, 3 of 3 runs each with a node v26.3.0 client.

The branch is rebased on main locally. There was one conflict hunk in each of h2_frame_parser.rs and node-http2.test.js. I will push the rebase together with the fix for the case above. The PR is a draft until then.

@robobun
robobun marked this pull request as draft October 7, 2026 07:15
The cork buffer is shared by every session on the thread. cork() flushes the
session that owns it through that session's transport. When the transport is
a JS Duplex, its _write can make a header call on the session that is taking
the buffer over. cork() then replaced the owner and reset the offset, so the
frames of that call were dropped while their fields stayed in the HPACK
table. A block that was encoded before the hand-over also went out behind a
block that was encoded after it.

cork() now reads the slot again after each forced uncork and keeps what a
re-entrant call corked. The header encode takes the cork before it encodes,
so no JS runs between the encode and the last byte of the block. A native
socket on top of a JS Duplex goes through the unit-assembling write, like a
session with no native socket, so a header block larger than the cork is
handed over whole.
The HPACK encoder refuses a field whose name plus value is over 65536
bytes. The walks encoded field by field, so a block with such a field
left its earlier fields in the dynamic table. The call does not throw:
the session error arrives from the event loop, and header blocks that
other streams wrote in the same tick went out with shifted indices.

lshpack_wrapper_encode_block checks every field of a block before the
first one reaches the encoder, then encodes the staged bytes in place.
HPACK::encode_block is the only encode the runtime crate can call:
HPACK::encode is crate-private to bun_http now. The walks stage into a
per-VM scratch (RareData), so a header call makes no allocation once the
scratch is warm. The h2 engine stages and encodes the same way.

request() checks maxSendHeaderBlockLength on the pre-compression bound.
A refused server response now resets its stream with FRAME_SIZE_ERROR,
as node does, and a refused 1xx block leaves the stream open. Before,
the peer got no frame for it.
@robobun
robobun force-pushed the robobun/7ce16bad/h2-validate-headers-before-encode branch from 90f334a to 57fa470 Compare October 7, 2026 22:38
Comment thread src/http/lshpack.rs Outdated
Comment thread src/http/lshpack.rs Outdated
Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment thread src/jsc/rare_data.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/hpack.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
…s header block senders

Shorten the comments the header block encode added.
…test

node closes the session after a frame error, so the 1xx case gets a session of
its own, and the test no longer asks for a later request on the same session.
Comment thread src/runtime/api/bun/h2_frame_parser.rs
clippy::chunks_exact_to_as_chunks rejects chunks_exact with a constant size.
@robobun robobun changed the title node:http2: validate the whole header list before the HPACK encoder sees it node:http2: encode each header block in one call, all fields or none Oct 8, 2026

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.

2 participants