node:http2: apply SETTINGS_HEADER_TABLE_SIZE to the HPACK encoder and decoder
RO
2mo ago+859+859+012345678901234567890123456789-75-75-01234567890123456789
Fixes #19152
Problem
- A
node:http2peer that sendsSETTINGS_HEADER_TABLE_SIZE=0rejects bun's header blocks. nginx does this for every upstream and logsupstream sent invalid http2 table index: 67. A node peer sends GOAWAY 9 (ERR_HTTP2_SESSION_ERROR - Session closed with error code 9). A localheaderTableSizeother than 4096 fails the same way. - Both HPACK tables took the local
headerTableSizeat construction and never changed: the encoder inH2FrameParser(src/runtime/api/bun/h2_frame_parser.rs:7658), the decoder inConnection::new(src/runtime/api/bun/h2/connection.rs:320). No header block carried a size update.
Fix
- The encoder starts at 4096 and follows the peer's value, capped at 4096 (node's default
maxDeflateDynamicTableSize). The next header block opens with the size update: the minimum since the last block, then the final size (RFC 7541 4.2). - The decoder starts at 4096 and takes the local value when the peer ACKs it. A header block of only size updates now decodes as an empty block, not COMPRESSION_ERROR.
- Verified: 22 new tests in
test/js/node/http2/h2-conformance.test.ts(bun 1.4.3 fails 17). Also all oftest/js/node/http2/(598 pass), the upstreamtest-http2-*.jsfiles, Windows x64, and node v26.3.0 as peer. - Self-reviewed: 6 concerns raised, 6 addressed.
Background
- HPACK compresses HTTP/2 headers. Both sides keep matching copies of a dynamic table, and a header block names entries by index (62 and above).
- The decoder's side sets the table size with
SETTINGS_HEADER_TABLE_SIZE. The value binds once the other side ACKs it. The next header block must start with a Dynamic Table Size Update. H2FrameParseris the native object behind onenode:http2session. Its engine (src/runtime/api/bun/h2/) parses inbound frames, andh2_frame_parser.rsencodes outbound header blocks.
Notes
- This continues #32775, which made the same two changes and was closed as stale, not rejected.
- The first draft changed only the encoder. Two of the self-review concerns asked for the decoder half, because a local
headerTableSizeother than 4096 still failed without it. hpack::Coder::new()takes no size now. Before,lshpack_wrapper_initgave one number to the encoder and the decoder.Coder::set_peer_header_table_sizemoves the encoder andCoder::set_acked_header_table_sizemoves the decoder. The second one uses the newlshpack_wrapper_dec_set_max_capacitybinding.- The engine reports each
SETTINGS_HEADER_TABLE_SIZEentry through the newSink::on_remote_header_table_size, per entry and not once per frame. One SETTINGS frame can carryHEADER_TABLE_SIZEtwice (RFC 9113 6.5.3 says to process the values in order), and nghttp2's decoder then expects the minimum first. One of the new tests sends0, 4096in one frame. - The encoder's capacity change applies when the SETTINGS frame arrives, as in nghttp2 (
nghttp2_hd_deflate_change_table_size). The SETTINGS ACK leaves in the same call, before any later header block. begin_header_blockwrites the pending size update at the start of a block, andcommit_header_blockconsumes it.request,send_trailersandpush_promisecall both. Arespond()that throws in the middle of a block does not lose the update. New tests cover the dropped block, trailers and PUSH_PROMISE.- The decoder change sits in the SETTINGS ACK branch of
Connection::handle_settings. It uses the per-frame snapshot that branch already pops forinitialWindowSizeandmaxHeaderListSize, so an ACK applies the oldest outstanding SETTINGS frame, not the latest. lshpack_dec_set_max_capacitysets the limit and the current table size together. After a raise the decoder's table can be larger than the peer encoder's table until the peer sends its size update. That is harmless: an index counts from the newest entry, and the encoder only names entries it still has.- Behavior change: a local
headerTableSizeno longer sizes the encoder. Some users setheaderTableSize: 0on a bun server as a workaround for #19152. That still works, because the encoder now takes 0 from nginx itself. maxDeflateDynamicTableSizeis still accepted and ignored. The cap is the constantMAX_ENCODER_TABLE_SIZE. On main the encoder never followed the peer, so the cap keeps a peer from making bun hold more than 4096 bytes of header history.- The oracle for the size update was node v26.3.0 (nghttp2), not nginx. I could not install nginx in the test environment. nginx sends
HEADER_TABLE_SIZE=0in its connection preface to a gRPC upstream and rejects every index above 61. The new tests assert that no header block uses such an index after a peer sends 0. - Interop runs with the debug build against node v26.3.0. Each one ends with error code 9 on bun 1.4.3 and passes with this change:
- bun server with
headerTableSize: 65536, node client - node server with
headerTableSize: 0, bun client - node client with
headerTableSize: 0, bun server - node client that sets 0 then 4096 before its first request, bun server
- bun server with
headerTableSize: 0, node client that sends 4 requests in one tick - bun server that raises
headerTableSizefrom 1024 to 8192 after the first request, node client
- bun server with
- The 261 upstream
test-http2-*.jsfiles pass on the first, second and fourth commit. The third commit only shortens comments and removes three lines that no upstream test reaches. For the fifth commit I do not have my own result: the run reached 142 of 261 files with no failure, and then the test machine was reset and the rest was lost. The CI run for that commit (Buildkite build 117976, 181 jobs, none failed) ran those files and this PR's test file on every platform, Windows included. Each of my runs used a private copy of the debug binary, because a rebuild of the shared one during an earlier run made that run useless. - 5 of the 22 new tests pass on bun 1.4.3. Three are guards: the 4096 cap, and the COMPRESSION_ERROR for a size update above the ACKed local value (with fields after it, and alone in a block). The other two are bun to bun sessions that raise
headerTableSizefrom 1024 to 4096. They pass on 1.4.3 because neither half works there, and they fail when only the encoder half is applied. - Update-only blocks: lshpack applies a size update and then reports an error when no field follows.
Coder::is_size_update_onlylets the engine tell that case from a real error. It accepts only a block that is size updates from start to end, each within the acknowledged limit, so an update above the limit is still COMPRESSION_ERROR (node does the same). ThefetchHTTP/2 client uses the same lshpack wrapper and probably rejects update-only response trailers too. I did not test or change it. - The pending size update is consumed by
commit_header_block, which runs when nothing can drop the block any more and before its first frame byte is written. The first three commits consumed it after the write. On a JS transport a header block larger than the 16 KB cork reaches the transport'swrite()from insiderequest(), and a peer that answers inside that write made two things go wrong: a SETTINGS frame with a new table size lost its size update (CodeRabbit found this), and a header block that user code sent from inside that write repeated the update of the outer block, so the peer evicted what the outer block had just inserted (the first review found this). I reported both as not reproducible at first. My runs did not reach that window. Two tests now reach it, and they check that they do. Both fail on the third commit. - A third case, found by both reviews on the fourth commit: user code runs between the fields of a header block (value coercion, getters, the options object), and on a JS transport it can deliver a peer SETTINGS frame. The block that was open then consumed the new pending size, although it was opened before the change. A block now consumes only what was pending when it was opened (
AnnouncedAt), so the next block announces the change. A test reaches this and fails on the fourth commit. This needs user code that feeds the transport from inside a header getter, so I do not expect it in real programs. - Not handled: the header block that is open when such a SETTINGS frame arrives. It was partly encoded against the old table size, and its size update was written before the change. After a shrink it can use an index that the peer has already evicted. I saw that on the wire (
be, index 62, after the peer set 0). After a growth it can insert entries that the peer does not store. I did not run that one. Node does not have this problem, because nghttp2 encodes a block after all user code has run. #41520 and #37568 give bun the same order. - Not handled: a header value whose
toString()sends another block. Those blocks reach the peer in a different order than they were encoded, which breaks HPACK state on main without any size update. #37568 covers that. A header block that is dropped after some of its fields were encoded also leaves those entries in the encoder's table, as on main. #41520 covers that. session.state.deflateDynamicTableSizenow reports the encoder's capacity andinflateDynamicTableSizethe acknowledged decoder limit. Both reported the localheaderTableSize, which on main was also the encoder's size. Node reports the bytes in use. Bun reports the capacity, as before.- The first push failed on Windows in CI with
read ECONNRESET. The cause was the new PUSH_PROMISE test: it destroyed the raw client and closed the server while the pushed stream was open and had noerrorlistener. The tests now give each stream a listener, and the push test waits for both responses to end. On Windows, bun reports that reset on the session and on the open pushed stream. Node reports it only on the socket. Bun 1.4.3 does the same as this branch, so this PR does not change it. - Not touched: the HPACK encoders of
Bun.serve(packages/bun-uws/src/Http2Context.h) and of thefetchHTTP/2 client (src/http/h2_client/dispatch.rs). Both already follow the peer's value. Thefetchone has no 4096 cap. - #41520 and #37568 (both open) change the same three functions to stop a dropped header block from leaving entries in the encoder's table. They do not touch the table size. The merge with this PR is mechanical:
begin_header_blockgoes before the first encoded field, andcommit_header_blockgoes before the first frame byte is written.