Skip to content

node:http2: apply SETTINGS_HEADER_TABLE_SIZE to the HPACK decoder and encoder - #32775

Closed
robobun wants to merge 5 commits into
mainfrom
farm/f43b8cc2/h2-decoder-header-table-size
Closed

robobun wants to merge 5 commits into
mainfrom
farm/f43b8cc2/h2-decoder-header-table-size

Conversation

@robobun

@robobun robobun commented Jun 26, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #19152

HTTP/2's SETTINGS_HEADER_TABLE_SIZE has two independent effects, and node:http2 applied neither after the connection was up:

  • the value we advertise bounds the peer encoder's RFC 7541 6.3 Dynamic Table Size Update and must grow our decoder once the peer ACKs it,
  • the value the peer advertises bounds our encoder, which must shrink/grow its table and announce the change with a 6.3 size update at the start of its next header block.

Both HPACK coder capacities were only ever set at parser construction. Neither fetch()'s HTTP/2 client nor anything else is affected (it never changes its advertised table size after the preface).

1. Local SETTINGS (decoder side)

A server (or client) that calls session.settings({ headerTableSize }) after the connection is established advertises the new maximum but never applies it to its decoder. A conforming peer that ACKs the SETTINGS and opens its next header block with a size update up to that maximum then kills the session:

GOAWAY errorCode=9 (COMPRESSION_ERROR) debug="HPACK decode error"
error: Stream closed with error code NGHTTP2_COMPRESSION_ERROR
 code: "ERR_HTTP2_STREAM_ERROR"

Node v26.3.0 accepts the same byte sequence. So a client that honours the SETTINGS the Bun server itself sent (nghttp2-based clients, gRPC, browsers behind a proxy, Bun's own fetch h2 client) takes down the session, and the resulting session error is uncaught unless the app installed a handler. Passing headerTableSize in the constructor options already worked, because the engine is created with that value; only post-construction settings() was broken.

Cause: lshpack_dec_set_max_capacity is only called from lshpack_wrapper_init (src/jsc/bindings/c-bindings.cpp). The ACK branch of handle_settings in src/runtime/api/bun/h2/connection.rs never touches the decoder, so lshpack rejects the peer's size update (new_capacity > dec->hpd_max_capacity in vendor/lshpack/lshpack.c) and finish_header_block maps that to send_go_away(CompressionError, "HPACK decode error").

2. Remote SETTINGS (encoder side, #19152)

A client that advertises SETTINGS_HEADER_TABLE_SIZE=0 (nginx sends this to every h2 upstream) must never be sent a dynamic-table reference, and the first header block after the change must open with a 6.3 size update. Bun does neither: its second response already references dynamic indexes, which nginx rejects:

upstream sent invalid http2 table index: 67 while reading response header from upstream

Node v26.3.0 emits the size update and zero dynamic indexes against the same client. A strict nghttp2 decoder (a node client doing client.settings({ headerTableSize: <smaller> })) rejects the whole block because the mandatory size update is missing.

Cause: the live outbound encoder is the H2FrameParser's own HpackHandle; the engine's encoder-side handling (Connection::begin_header_block / take_pending_size_update) is only reached from its unit tests, and set_encoder_max_capacity had no callers on the live handle. Sink::on_remote_settings bridged the peer's settings into a Cell but never resized the encoder. The encoder was also initialized from OUR advertised headerTableSize instead of the 4096 default the peer is actually bound by until it receives our SETTINGS, a latent desync with any non-default setting.

Fix

  • src/jsc/bindings/c-bindings.cpp / src/http/lshpack.rs: add lshpack_wrapper_dec_set_max_capacity, the decoder counterpart of the existing encoder wrapper.
  • src/runtime/api/bun/h2/hpack.rs: Coder::set_decoder_capacity, a no-op when unchanged so re-submitting the same size (Bun packs all 7 standard parameters into every SETTINGS frame) cannot clobber the table size the peer's encoder last chose via a size update.
  • src/runtime/api/bun/h2/hpack.rs: Coder also now tracks the MINIMUM capacity the peer requested since the last header block. RFC 7541 4.2 requires the interval minimum to be signaled before the final value (at most two size updates per block), because the peer's decoder evicts eagerly at each of its SETTINGS; signaling only the latest value left its table smaller than ours, and a strict nghttp2 inflater rejects a first size update above the interval minimum outright.
  • src/runtime/api/bun/h2/connection.rs: resize the decoder from the SETTINGS ACK branch of handle_settings. Applying on ACK rather than on send matches nghttp2 (nghttp2_session_update_local_settings): the peer applies our SETTINGS before emitting the ACK, so every header block after the ACK on the wire is encoded under the new limit and none before it is. The per-6.5.3 ack queue previously carried only initial_window_size; it now carries the full Settings snapshot so header_table_size is attributed to the correct submission when several SETTINGS are in flight, the same way the window already was.
  • src/runtime/api/bun/h2_frame_parser.rs: replace the raw HpackHandle with the engine's hpack::Coder, which already carries the queued-capacity state and the 6.3 opcode writer. on_remote_settings queues the peer's value, and encode_header_into_list (the single funnel every outbound header block's fields go through) drains it at the start of the block. The encoder now initializes to the 4096 RFC default.
  • While in the same branch: on_local_settings now receives the ACKed snapshot rather than the latest submission, so session.localSettings and the localSettings event report the values the peer actually acknowledged (what Node reports) when several SETTINGS are in flight.

Verification

Four tests in test/js/node/http2/h2-conformance.test.ts, each driving a raw byte-level h2c client against a Bun node:http2 server:

  1. Decoder, grow. After the server grows headerTableSize to 8192 mid-session and the client ACKs, a 6.3 size update to 8192 followed by a 6037-byte incremental-indexing insertion is accepted, and a later request that references that entry from the dynamic table (index 62) gets a response. Proves the table actually grew, not that the size update was merely tolerated.
  2. Decoder, 6.5.3 ordering. Two settings() in flight (8192 then 512); the client ACKs only the first and size-updates to 8192, which must be accepted because 8192 is the value that ACK bound. Also asserts session.localSettings.headerTableSize reports 8192, not 512.
  3. Encoder, Bun's HTTP/2 server potentially ignores Nginx's HPACK for header compression setting SETTINGS_HEADER_TABLE_SIZE=0 #19152. The client advertises HEADER_TABLE_SIZE=0 in its preface. The first response block must open with a 6.3 size update to 0, and neither response block may reference a dynamic-table index, checked by a small HPACK opcode walker over the raw frame payloads.
  4. Encoder, 4.2 interval minimum. The client sends HEADER_TABLE_SIZE=0 then HEADER_TABLE_SIZE=4096 before the server emits any header block. The first response block must open with size-update(0) then size-update(4096), in that order.
# src/ restored to unmodified origin/main, debug build:
 23 pass, 4 fail    # all four new tests
# with the fix:
 27 pass, 0 fail

Both standalone repros pass under the fix and pass under Node v26.3.0 unchanged. test/js/node/http2/node-http2.test.js (287) and the other http2 suites still pass.

Standalone repro for #19152 (raw h2c client against a Bun node:http2 server)
// bun  repro.mjs  -> FAIL (dynamic indexes in response 3, no size update)
// node repro.mjs  -> PASS
import http2 from "node:http2";
import net from "node:net";
import { once } from "node:events";
const PREFACE = Buffer.from("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", "latin1");
function encodeFrame(t, f, s, p = Buffer.alloc(0)) {
  const h = Buffer.alloc(9);
  h.writeUIntBE(p.length, 0, 3); h.writeUInt8(t, 3); h.writeUInt8(f, 4); h.writeUInt32BE(s & 0x7fffffff, 5);
  return Buffer.concat([h, p]);
}
function hpInt(v, pb, fb) {
  const m = (1 << pb) - 1;
  if (v < m) return Buffer.from([fb | v]);
  const o = [fb | m]; let r = v - m;
  while (r >= 128) { o.push((r & 0x7f) | 0x80); r >>= 7; }
  o.push(r); return Buffer.from(o);
}
const hpStr = s => { const b = Buffer.from(s, "latin1"); return Buffer.concat([hpInt(b.length, 7, 0), b]); };
const baseReq = () => Buffer.concat([Buffer.from([0x82, 0x86, 0x84, 0x01]), hpStr("localhost")]);
function dynamicIndexes(block) {
  let i = 0; const out = [];
  const readInt = pb => { const m = (1 << pb) - 1; let v = block[i++] & m; if (v < m) return v;
    for (let e = 0; ; e += 7) { const b = block[i++]; v += (b & 0x7f) * 2 ** e; if (!(b & 0x80)) return v; } };
  const skipStr = () => { const n = readInt(7); i += n; };
  while (i < block.length) {
    const b = block[i];
    if (b & 0x80) { const x = readInt(7); if (x > 61) out.push(x); }
    else if (b & 0x40) { const x = readInt(6); if (x > 61) out.push(x); if (x === 0) skipStr(); skipStr(); }
    else if ((b & 0xe0) === 0x20) readInt(5);
    else { const x = readInt(4); if (x > 61) out.push(x); if (x === 0) skipStr(); skipStr(); }
  }
  return out;
}
const SHARED = Buffer.alloc(40, 0x61).toString("latin1");
const server = http2.createServer();
server.on("stream", st => { st.on("error", () => {}); st.respond({ ":status": 200, "x-shared": SHARED }); st.end("ok"); });
server.listen(0); await once(server, "listening");
const sock = net.connect(server.address().port, "127.0.0.1");
sock.on("error", () => {}); await once(sock, "connect");
let buf = Buffer.alloc(0); const frames = []; const waiters = [];
sock.on("data", d => { buf = Buffer.concat([buf, d]);
  while (buf.length >= 9) { const l = buf.readUIntBE(0, 3); if (buf.length < 9 + l) break;
    const f = { type: buf.readUInt8(3), flags: buf.readUInt8(4), streamId: buf.readUInt32BE(5) & 0x7fffffff, payload: buf.subarray(9, 9 + l) };
    buf = buf.subarray(9 + l); frames.push(f);
    const j = waiters.findIndex(w => w.pred(f)); if (j !== -1) waiters.splice(j, 1)[0].resolve(f); } });
const waitFor = (pred, ms = 3000) => { const e = frames.find(pred); if (e) return Promise.resolve(e);
  return new Promise((res, rej) => { const w = { pred, resolve: res }; waiters.push(w);
    const t = setTimeout(() => rej(new Error("timeout")), ms); const o = w.resolve; w.resolve = f => { clearTimeout(t); o(f); }; }); };
const s = Buffer.alloc(6); s.writeUInt16BE(0x1, 0); s.writeUInt32BE(0, 2);
sock.write(PREFACE); sock.write(encodeFrame(4, 0, 0, s));
await waitFor(f => f.type === 4 && (f.flags & 1) === 0); sock.write(encodeFrame(4, 1, 0));
await waitFor(f => f.type === 4 && (f.flags & 1) === 1);
sock.write(encodeFrame(1, 0x5, 1, baseReq())); const r1 = await waitFor(f => f.type === 1 && f.streamId === 1);
sock.write(encodeFrame(1, 0x5, 3, baseReq())); const r3 = await waitFor(f => f.type === 1 && f.streamId === 3);
const sizeUpdate = (r1.payload[0] & 0xe0) === 0x20;
const d1 = dynamicIndexes(r1.payload), d3 = dynamicIndexes(r3.payload);
console.log("response 1 starts with a 6.3 size update:", sizeUpdate);
console.log("response 1 dynamic indexes:", d1, " response 3 dynamic indexes:", d3);
const ok = sizeUpdate && d1.length === 0 && d3.length === 0;
console.log(ok ? "PASS" : "FAIL"); sock.destroy(); server.close(); process.exit(ok ? 0 : 1);
Standalone repro for the decoder side
// bun  repro.mjs  -> FAIL (GOAWAY errorCode=9 "HPACK decode error")
// node repro.mjs  -> PASS
import http2 from "node:http2";
import net from "node:net";
import { once } from "node:events";
const PREFACE = Buffer.from("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", "latin1");
const NEW_TABLE = 8192;
function encodeFrame(t, f, s, p = Buffer.alloc(0)) {
  const h = Buffer.alloc(9);
  h.writeUIntBE(p.length, 0, 3); h.writeUInt8(t, 3); h.writeUInt8(f, 4); h.writeUInt32BE(s & 0x7fffffff, 5);
  return Buffer.concat([h, p]);
}
function hpInt(v, pb, fb) {
  const m = (1 << pb) - 1;
  if (v < m) return Buffer.from([fb | v]);
  const o = [fb | m]; let r = v - m;
  while (r >= 128) { o.push((r & 0x7f) | 0x80); r >>= 7; }
  o.push(r); return Buffer.from(o);
}
const hpStr = s => { const b = Buffer.from(s, "latin1"); return Buffer.concat([hpInt(b.length, 7, 0), b]); };
const dtsu = v => hpInt(v, 5, 0x20);
const baseReq = () => Buffer.concat([Buffer.from([0x82, 0x86, 0x84, 0x01]), hpStr("localhost")]);
const server = http2.createServer();
let bumped = false;
server.on("session", s => s.on("error", e => console.log("SESSION ERROR:", e.code ?? e.message)));
server.on("stream", st => {
  st.on("error", () => {}); st.respond({ ":status": 200 }); st.end("ok");
  if (!bumped) { bumped = true; st.session.settings({ headerTableSize: NEW_TABLE }); }
});
server.listen(0); await once(server, "listening");
const sock = net.connect(server.address().port, "127.0.0.1");
sock.on("error", () => {}); await once(sock, "connect");
let buf = Buffer.alloc(0); const frames = []; const waiters = [];
sock.on("data", d => { buf = Buffer.concat([buf, d]);
  while (buf.length >= 9) { const l = buf.readUIntBE(0, 3); if (buf.length < 9 + l) break;
    const f = { type: buf.readUInt8(3), flags: buf.readUInt8(4), streamId: buf.readUInt32BE(5) & 0x7fffffff, payload: buf.subarray(9, 9 + l) };
    buf = buf.subarray(9 + l); frames.push(f);
    const j = waiters.findIndex(w => w.pred(f)); if (j !== -1) waiters.splice(j, 1)[0].resolve(f); } });
const waitFor = (pred, ms = 3000) => { const e = frames.find(pred); if (e) return Promise.resolve(e);
  return new Promise((res, rej) => { const w = { pred, resolve: res }; waiters.push(w);
    const t = setTimeout(() => rej(new Error("timeout")), ms); const o = w.resolve; w.resolve = f => { clearTimeout(t); o(f); }; }); };
const readSetting = (f, id) => { for (let i = 0; i + 6 <= f.payload.length; i += 6) if (f.payload.readUInt16BE(i) === id) return f.payload.readUInt32BE(i + 2); };
sock.write(PREFACE); sock.write(encodeFrame(4, 0, 0));
await waitFor(f => f.type === 4 && (f.flags & 1) === 0); sock.write(encodeFrame(4, 1, 0));
sock.write(encodeFrame(1, 0x5, 1, baseReq())); await waitFor(f => f.type === 1 && f.streamId === 1);
await waitFor(f => f.type === 4 && (f.flags & 1) === 0 && readSetting(f, 0x1) === NEW_TABLE);
sock.write(encodeFrame(4, 1, 0)); // ACK the grown SETTINGS: we may now size-update to 8192
const big = Buffer.concat([Buffer.from([0x40]), hpStr("x-big"), hpStr(Buffer.alloc(6000, 0x61).toString("latin1"))]);
sock.write(encodeFrame(1, 0x5, 3, Buffer.concat([dtsu(NEW_TABLE), baseReq(), big])));
try {
  await waitFor(f => f.type === 1 && f.streamId === 3);
  sock.write(encodeFrame(1, 0x5, 5, Buffer.concat([baseReq(), Buffer.from([0x80 | 62])])));
  await waitFor(f => f.type === 1 && f.streamId === 5);
  console.log("PASS"); process.exit(0);
} catch {
  const g = frames.find(f => f.type === 7);
  console.log(g ? `FAIL: GOAWAY errorCode=${g.payload.readUInt32BE(4)} debug=${JSON.stringify(g.payload.subarray(8).toString())}` : "FAIL: timeout");
  process.exit(1);
}

[review] gate passed · iteration 6 · 6 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/http2/h2-conformance.test.ts"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (223e4927c)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [431.58ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [125.74ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [78.39ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [67.39ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (87cb0aae5)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [7.39ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [1.83ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [1.29ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [0.82ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [0.75ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [0.68ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [0.66ms]
(pass) PING (checklist §3.7) > a PING on a non-zero stream id is a PROTOCOL_ERROR [0.63ms]
(pass) WINDOW_UPDATE (checklist §6) > a connection-level WINDOW_UPDATE with a 0 increment is a PROTOCOL_ERROR [2.19ms]
(pass) WINDOW_UPDATE
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/http2/h2-conformance.test.ts"
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (223e4927c)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [423.01ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [126.58ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [88.80ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [61.48ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 733ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/23] gen generated_host_exports.rs
generated_host_exports.rs: 90 exports (host=3, lazy=10, generic=77, rust=0); 254 extern-C blocks audited
[2/23] gen cpp.rs (cppbind)
[3/23] gen JS modules (bundle-modules)
Preprocess modules (6256ms)
Bundle modules (45ms)
Postprocesss modules (20ms)
Bundle Functions (574ms)
Generate Code (78ms)

[6.99s] Bundled "src/js" for production
  1888 kb
  161 internal modules
  12 native modules
  90 internal functions across 19 files
[3/9] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-
... (truncated)
diff hotspot
src/http/lshpack.rs                       |   9 +
 src/jsc/bindings/c-bindings.cpp           |   5 +
 src/runtime/api/bun/h2/connection.rs      |  35 ++--
 src/runtime/api/bun/h2/hpack.rs           |  49 ++++-
 src/runtime/api/bun/h2_frame_parser.rs    |  65 ++++---
 test/js/node/http2/h2-conformance.test.ts | 289 +++++++++++++++++++++++++++++-
 6 files changed, 410 insertions(+), 42 deletions(-)

gate history · 2 passed · 0 rejected · iteration 6

evidence per changed file
file                                       reads  edits  tests
src/http/lshpack.rs                            1      2     30
src/jsc/bindings/c-bindings.cpp                1      1     30
src/runtime/api/bun/h2/connection.rs           4      6     31
src/runtime/api/bun/h2/hpack.rs                2      3     30
src/runtime/api/bun/h2_frame_parser.rs        11     10     30
test/js/node/http2/h2-conformance.test.ts      7     19     30

@coderabbitai

coderabbitai Bot commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

HTTP/2 HPACK settings handling now updates decoder capacity through new wrapper plumbing, tracks full SETTINGS snapshots for ACK processing, adjusts outbound HPACK size updates, and adds conformance coverage for decoder growth and encoder shrinking.

Changes

HTTP/2 HPACK SETTINGS capacity handling

Layer / File(s) Summary
HPACK decoder-capacity wrapper
src/http/lshpack.rs, src/jsc/bindings/c-bindings.cpp
HPACK adds a decoder-capacity setter, and the new C wrapper forwards the value to lshpack_dec_set_max_capacity.
HPACK capacity state tracking
src/runtime/api/bun/h2/hpack.rs
Coder stores decoder capacity state, tracks the minimum pending encoder capacity, and can emit one or two size updates before a header block.
Acknowledged SETTINGS application
src/runtime/api/bun/h2/connection.rs
Connection records full SETTINGS snapshots, applies the ACKed header-table size, and passes the acknowledged settings to sink.on_local_settings.
Frame parser SETTINGS and HPACK wiring
src/runtime/api/bun/h2_frame_parser.rs
H2FrameParser stores full local SETTINGS submissions, drains them into engine.pending_local_settings_acks, switches to Coder, queues peer header_table_size updates, and emits pending size updates before new header blocks.
SETTINGS_HEADER_TABLE_SIZE conformance coverage
test/js/node/http2/h2-conformance.test.ts
The HPACK helpers add prefixed-integer encoding and dynamic-table index parsing, and new SETTINGS_HEADER_TABLE_SIZE tests cover decoder resizing and encoder shrinking.

Possibly related PRs

  • oven-sh/bun#31584: Changes the HTTP/2 inbound engine and the same Connection/hpack paths that this PR updates for SETTINGS and HPACK capacity handling.

Suggested reviewers

  • Jarred-Sumner
  • cirospaciari
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #19152 by honoring peer SETTINGS_HEADER_TABLE_SIZE=0 and adding the related HPACK tests.
Out of Scope Changes check ✅ Passed The added decoder, encoder, and test changes all support the HPACK settings fix; no unrelated changes stand out.
Title check ✅ Passed The title accurately summarizes the core change: applying SETTINGS_HEADER_TABLE_SIZE to HPACK decoder and encoder.
Description check ✅ Passed The description is detailed and includes purpose, fixes, objectives, and verification, though it uses custom headings instead of the template.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jun 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:00 PM PT - Jul 9th, 2026

❌ @robobun, your commit 223e492 has 2 failures in Build #71207 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32775

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

bun-32775 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun's HTTP/2 server potentially ignores Nginx's HPACK for header compression setting SETTINGS_HEADER_TABLE_SIZE=0 #19152 - Nginx sends SETTINGS_HEADER_TABLE_SIZE=0 to disable dynamic HPACK compression; Bun ignores the ACKed setting and keeps using dynamic table indexes, causing Nginx to reject responses — exactly the HPACK decoder/encoder resize-on-ACK bug this PR fixes.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #19152

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed #32488 - Also widens pending_local_window_acks to carry full Settings snapshots and applies the HPACK decoder table size on SETTINGS ACK, delivered as part of a larger node:http2 compat batch

🤖 Generated with Claude Code

Comment thread src/runtime/api/bun/h2/connection.rs Outdated
@robobun robobun changed the title node:http2: resize the HPACK decoder when a local SETTINGS_HEADER_TABLE_SIZE is ACKed node:http2: apply SETTINGS_HEADER_TABLE_SIZE to the HPACK decoder and encoder Jun 26, 2026
@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Two of the automated comments above needed verification, so for the record:

#19152 is real, but was not fixed by the first commit here. The find-issues match pointed at the encoder side of the remote peer's SETTINGS_HEADER_TABLE_SIZE (nginx advertising 0 to an upstream), which is the mirror of the decoder-side, local-settings bug the first commit fixed. Reproduced: against a raw client advertising HEADER_TABLE_SIZE=0, Bun's second response referenced dynamic indexes 62 and 63 and never emitted the RFC 7541 6.3 size update, while Node v26.3.0 emits the update and no dynamic references. Root cause: the live outbound encoder is the H2FrameParser's own HPACK handle and set_encoder_max_capacity had no callers on it (the engine's Connection::begin_header_block, which would have handled this, is only reached from its unit tests). Since it is the exact mirror of the decoder bug in the same files, and the decoder fix alone would have made it newly observable Bun-to-Bun, 94efae7 fixes it here and the PR now carries Fixes #19152.

This PR is not a duplicate of #32488. That PR widens the same per-6.5.3 ack queue, but it does so for acked.settings.max_header_list_size; its ACK branch never resizes the HPACK decoder, and its diff contains no call to any lshpack capacity function on either coder, so it fixes neither of the two bugs here. The overlap is plumbing only: both PRs turn pending_local_window_acks: VecDeque<u32> into a queue of settings snapshots, and both append a describe block to h2-conformance.test.ts. Whichever lands second has a small, mechanical rebase (read header_table_size off the acked snapshot, keep both test blocks).

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/api/bun/h2_frame_parser.rs`:
- Around line 2205-2212: The HPACK size update handling in h2_frame_parser’s
header encoding path is being cleared too early by take_pending_size_update()
before encode() succeeds. Update the header block generation flow so the pending
size update stays armed until the encoded_headers block is actually produced,
and if encode() fails, restore or requeue the pending update instead of
committing it. Prefer fixing this in the Coder/HPACK state management or in the
header assembly logic around encoded_headers so pending_enc_capacity is only
consumed after successful serialization.

In `@test/js/node/http2/h2-conformance.test.ts`:
- Around line 697-715: The test in h2-conformance.test.ts currently only
verifies that a dynamic table size update to NEW_TABLE is accepted after the
peer ACKs the grown SETTINGS, but it misses the pre-ACK boundary case. In the
same test around serverThatThenCalls, RawH2.connect, and dynamicTableSizeUpdate,
add a small negative step before sending the SETTINGS ACK that attempts
dynamicTableSizeUpdate(NEW_TABLE) and asserts it is not accepted until the ACK
arrives, then keep the existing successful post-ACK path unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7539e91d-03d2-4e17-9b04-1d374effad83

📥 Commits

Reviewing files that changed from the base of the PR and between 0589548 and 94efae7.

📒 Files selected for processing (6)
  • src/http/lshpack.rs
  • src/jsc/bindings/c-bindings.cpp
  • src/runtime/api/bun/h2/connection.rs
  • src/runtime/api/bun/h2/hpack.rs
  • src/runtime/api/bun/h2_frame_parser.rs
  • test/js/node/http2/h2-conformance.test.ts

Comment thread src/runtime/api/bun/h2_frame_parser.rs
Comment thread test/js/node/http2/h2-conformance.test.ts
Comment thread src/runtime/api/bun/h2/hpack.rs

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/http2/h2-conformance.test.ts`:
- Line 799: Bind the test helper server to the same loopback address used by
RawH2.connect() so the client and server always match. Update the h2-conformance
test setup around h2server.listen() to follow the existing RawH2Server.listen()
pattern in neighboring tests, explicitly using the loopback address instead of
leaving it unspecified to avoid IPv4/IPv6 binding differences.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cb55d1e7-3b57-43ad-9ae5-ddeb6ef4cbac

📥 Commits

Reviewing files that changed from the base of the PR and between 94efae7 and 97da641.

📒 Files selected for processing (2)
  • src/runtime/api/bun/h2/hpack.rs
  • test/js/node/http2/h2-conformance.test.ts

Comment thread test/js/node/http2/h2-conformance.test.ts Outdated
@robobun

robobun commented Jun 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status for a maintainer: the diff is complete and gate-verified on the rebased base, every review thread is resolved, and CI is green except for two recurring unrelated flakes. I have used the one ci: retrigger I allow myself (build 71173 → 71207 improved 4 unrelated failures to 2), so I am stopping here rather than pushing more empty commits.

Rebase

Rebased onto main (87cb0aae51); the only conflict was with #33072 (hardening round 11), which appended new describe blocks to h2-conformance.test.ts at the same EOF spot this PR does and added a dispatch_depth field adjacent to the one this PR renames. Both resolutions were mechanical (keep both sides). connection.rs auto-merged; #33072 did not touch the SETTINGS ACK branch.

Verification of the diff itself

Against a debug build with src/ restored to unmodified origin/main, test/js/node/http2/h2-conformance.test.ts is 37 pass / 4 fail (the four failures being exactly the four new tests); with the diff it is 41 pass / 0 fail, and test/js/node/http2/node-http2.test.js is 294 pass / 0 fail. Both standalone repros in the PR body pass under Node v26.3.0 unchanged.

What CI failed on (build 71207, final: 228/230 test lanes passed)

  • test/regression/issue/26030.test.ts on alpine-3.23-x64: MySQL Docker container harness (test/docker/index.ts) unhandled error during container startup. This same test has failed on all three of my recent builds (65076, 71173, 71207). SQL test infrastructure.
  • test/js/bun/http/proxy-stress-concurrent.test.ts on darwin-14-x64: 1 of 1200 concurrent https-through-proxy requests failed (Bun.serve + fetch proxy stress, not node:http2). Failed on 2 of the last 2 builds.

No annotation on either build 71173 or 71207 names any http2 or HPACK file. The retrigger cleared the other two unrelated failures from build 71173 (postgres-binary-array-bounds: Postgres connection refused; next-pages/dev-server: Puppeteer integration).

If anything that does touch this change turns up I will pick it up.

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Independent corroboration of the encoder half of this change: a fresh report arrived of the same failure mode (a node:http2 client with {settings:{headerTableSize:0}} against a Bun server gets COMPRESSION_ERROR on the very first response because the outbound encoder never shrinks nor emits the RFC 7541 6.3 size update).

Reproduced deterministically on current main (3f5d816) using a real node subprocess as the nghttp2 oracle:

node server: ["r0:status=200","r1:status=200","r2:status=200"]
bun server:  ["r0:error:ERR_HTTP2_ERROR","r0:close:rst=2", ...]   (rst=2 = COMPRESSION_ERROR)

The client direction fails the same way (Bun client vs a node server that advertises headerTableSize:0).

I put a minimal encoder-only variant with nghttp2-oracle tests (server and client, across headerTableSize 0/2048/4095) on farm/23aef5a2/h2-encoder-header-table-size in case it's useful for rebasing this PR; the approach here (swapping the legacy HpackHandle for h2::hpack::Coder) is cleaner and covers the 4.2 two-update case and the decoder side as well, so this PR is the right one to land.

This PR currently has merge conflicts with main.

robobun added 4 commits July 9, 2026 21:22
…LE_SIZE is ACKed

The SETTINGS_HEADER_TABLE_SIZE a node:http2 session advertises bounds
the peer encoder's RFC 7541 6.3 Dynamic Table Size Update, but
lshpack_dec_set_max_capacity was only ever called from
lshpack_wrapper_init. A session.settings({ headerTableSize }) issued
after the connection was up therefore advertised the larger table
without applying it to the decoder, so a conforming peer that ACKed the
SETTINGS and opened its next header block with a size update up to that
maximum was torn down with GOAWAY(COMPRESSION_ERROR) and an uncaught
ERR_HTTP2_SESSION_ERROR. Node accepts the same sequence.

Add the decoder-side wrapper next to the existing encoder one, and
resize the decoder from handle_settings when the peer ACKs our SETTINGS
(6.5.3: the ACK binds the oldest outstanding submission), mirroring the
existing encoder handling for the peer's settings 40 lines below. The
per-SETTINGS ack queue is widened from the initial window size to the
full Settings snapshot so header_table_size is attributed to the right
submission when several SETTINGS are in flight.
…encoder

The encoder mirror of the previous commit. The live outbound encoder is the
H2FrameParser's own HpackHandle (the engine's encoder path is only reached
from its unit tests), and set_encoder_max_capacity had no callers there, so
a peer that lowered SETTINGS_HEADER_TABLE_SIZE was ignored: responses kept
referencing the dynamic table and never carried the RFC 7541 6.3 Dynamic
Table Size Update the change requires. nginx advertises 0 to every h2
upstream and rejects the result with "upstream sent invalid http2 table
index" (#19152); a strict nghttp2 decoder rejects the whole block because
the mandatory size update is missing.

Replace the parser's raw HpackHandle with the engine's hpack::Coder, which
already carries the queued-capacity state and the 6.3 opcode writer:
on_remote_settings queues the peer's value, and encode_header_into_list
(the single funnel every outbound block's fields go through) drains it at
the start of the block. The encoder is also now initialized to the 4096 RFC
default the peer is bound by, instead of OUR advertised headerTableSize
(which governs the decoder, owned by the engine).

Also pass the ACKed settings snapshot rather than the latest submission to
on_local_settings, so `session.localSettings` and the `localSettings` event
report the values the peer actually acknowledged when several SETTINGS are
in flight, matching nghttp2_session_update_local_settings.

Fixes #19152
…final value

RFC 7541 4.2: when the peer changes SETTINGS_HEADER_TABLE_SIZE more than once
between two of our header blocks, the SMALLEST value in that interval must be
signaled (as the first of at most two Dynamic Table Size Updates), because the
peer's decoder evicts eagerly at each of its SETTINGS. Coder kept only the
latest queued value, so a peer sending HEADER_TABLE_SIZE=0 then 4096 got a
single size-update(4096): its decoder's table is empty while ours still has
entries, and a strict nghttp2 inflater rejects the block outright because the
first size update after a shrink must not exceed the interval minimum.

Track the pending minimum alongside the pending value and, when it is below the
final value, apply and emit it first. MAX_SIZE_UPDATE_BYTES grows to cover the
two opcodes a block may now open with; both callers size their scratch from it.

This was latent in the engine's Coder but only became reachable on the wire
once the previous commit routed the live outbound encoder through it.
RawH2.connect always dials 127.0.0.1; match the explicit loopback bind
the file's RawH2Server.listen helper already uses, so the client and
server can never end up on different address families.
@robobun
robobun force-pushed the farm/f43b8cc2/h2-decoder-header-table-size branch from 790cf80 to 87cb0aa Compare July 9, 2026 21:38
Build 71173: 226/230 test lanes passed. The 4 failures are all unrelated to
this change (test/js/sql/postgres-binary-array-bounds: Postgres connection
refused on one Windows box; test/js/bun/http/proxy-stress-concurrent: 1/1200
requests failed on darwin; test/regression/issue/26030: MySQL docker harness;
test/integration/next-pages/dev-server: Next.js/Puppeteer integration). No
annotation names any http2/HPACK file.
Comment thread src/runtime/api/bun/h2_frame_parser.rs
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-09, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. The linked issue (#19152) stays open. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

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.

Bun's HTTP/2 server potentially ignores Nginx's HPACK for header compression setting SETTINGS_HEADER_TABLE_SIZE=0

1 participant