Skip to content

node:quic(h3): lift outbound 128-header cap in us_nq_stream_send_headers - #35713

Closed
robobun wants to merge 3 commits into
mainfrom
claude/farm/1cd49045/quic-h3-outbound-header-cap
Closed

robobun wants to merge 3 commits into
mainfrom
claude/farm/1cd49045/quic-h3-outbound-header-cap

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Sending an HTTP/3 HEADERS block with more than 128 field lines via node:quic silently stalls the stream and eventually kills the whole connection:

const h = { ":method": "GET", ":path": "/", ":scheme": "https", ":authority": "localhost" };
for (let i = 0; i < 125; i++) h["x-h" + i] = "v";   // 125 custom + 4 pseudo = 129 lines
const st = await cli.createBidirectionalStream({ headers: h });
for await (const c of st) {}
// status never arrives; after maxIdleTimeout the connection CONNECTION_CLOSEs with code 0

sendHeaders() returns false and nothing goes on the wire. The stream never settles. Other streams on the session continue to work. If the session has no other traffic, the idle timeout closes it, and stream.closed / session.closed resolve without an error. Setting application.maxHeaderPairs: 65535 has no effect on the outbound path.

Corrected after the PR was closed: an earlier version of this paragraph said that every concurrent stream on the session dies. That was wrong.

Cause

us_nq_stream_send_headers in packages/bun-usockets/src/node_quic_shim.c builds a stack struct lsxpack_header[US_NQ_MAX_HEADERS] with US_NQ_MAX_HEADERS = 128 and bails with -1 when expected > 128. That was never a policy limit, just the stack buffer size; application.maxHeaderPairs is an inbound decoder limit (applied in nq_hsi_process_header) and node's encoder path imposes no count cap.

With the shim returning -1, the Rust caller returns false from sendHeaders, createBidirectionalStream({headers}) ignores it, and the stream sits in lsquic with want_read(true) but no bytes ever sent. The for await blocks until maxIdleTimeout fires a CONNECTION_CLOSE(0).

Fix

Heap-allocate the lsxpack_header[] array when expected > 128; keep the stack buffer for the common case. lsquic_stream_send_headers encodes into its own buffer before returning, so the array is freed immediately after.

Also: in bind_raw, only set wrote_to_lsquic once the pending-headers replay actually reaches lsquic (matches the already-bound path at send_headers).

Test

test/js/node/quic/quic-stream.test.ts round-trips a request and a response with 204 field lines each, asserting sendHeaders() returns true and every header arrives at the peer. Fails on main with expect(stream.sendHeaders(req, { terminal: true })).toBe(true) receiving false.


[review] gate passed · iteration 1 · 3 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/quic/quic-stream.test.ts
bun test v1.4.0 (2e3cb71a9)

test/js/node/quic/quic-stream.test.ts:
ExperimentalWarning: quic is an experimental feature and might change at any time
      at node:quic (node:quic:16:20)

(pass) QuicStream.destroy after the app ended the send side > delivers the committed response instead of retracting it with RESET_STREAM [857.27ms]
(pass) HTTP/3 header encoding > round-trips non-ASCII header values byte-for-byte [175.42ms]
(pass) HTTP/3 header encoding > rejects a value whose latin1 encoding splices in an extra header [1190.24ms]
237 |       onheaders(headers: Record<string, string>) {
238 |         gotHeaders.resolve(headers);
239 |       },
240 |     });
241 |     stream.closed.catch(gotHeaders.reject);
242 |     expect(stream.sendHeaders(req, { terminal: true })).toBe(true);
                                                              ^
error: expect(received).toBe(expected)

Expected: true
Received: false

      at <anonymous> (/workspace/bun/test/js/node/quic/quic-stream.test.ts:242:57)
(fa
... (truncated)

release without fix: BUILD FAILED (no junit output)
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/quic/quic-stream.test.ts:

# Unhandled error between tests
-------------------------------
8 | import { connect, listen } from "node:quic";
                                    ^
error: Could not resolve: "node:quic". Maybe you need to "bun install"?
    at /workspace/bun/test/js/node/quic/quic-stream.test.ts:8:33
-------------------------------


 0 pass
 1 fail
 1 error
Ran 1 test across 1 file. [182.00ms]
__F:-1:S:0
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/quic/quic-stream.test.ts
bun test v1.4.0 (2e3cb71a9)

test/js/node/quic/quic-stream.test.ts:
ExperimentalWarning: quic is an experimental feature and might change at any time
      at node:quic (node:quic:16:20)

(pass) QuicStream.destroy after the app ended the send side > delivers the committed response instead of retracting it with RESET_STREAM [727.12ms]
(pass) HTTP/3 header encoding > round-trips non-ASCII header values byte-for-byte [194.57ms]
(pass) HTTP/3 header encoding > rejects a value whose latin1 encoding splices in an extra header [1183.97ms]
(pass) HTTP/3 header encoding > encodes a HEADERS block with more than 128 field lines [333.96ms]

 4 pass
 0 fail
 16 expect() calls
Ran 4 tests across 1 file. [6.37s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     2e3cb71a93
  features     baseline

22 deps, 108 codegen, 1171 objects in 1814ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] gen bindgenv2
[2/1234] gen ErrorCode+*.h
[3/1234] gen .bind.ts → GeneratedBindings.cpp
[4/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[5/1234] fetch picohttpparser
[picohttpparser] up to date
[6/1234] fetch zlib
[zlib] up to date
[7/1234] fetch tinycc
[tinycc] up to date
[8/1234] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[9/1234] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[10/1234] gen ProcessBindingHTTPParser.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingHTTPParser.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingHTTPParser.cpp
[11/1234] subst deps/zlib/zconf.h
[12/1234] subst dep
... (truncated)
diff hotspot
packages/bun-usockets/src/node_quic_shim.c | 38 +++++++++-----
 src/runtime/node/quic/stream.rs            | 16 +++---
 test/js/node/quic/quic-stream.test.ts      | 80 ++++++++++++++++++++++++++++++
 3 files changed, 115 insertions(+), 19 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                        reads  edits  tests
packages/bun-usockets/src/node_quic_shim.c      5      3      0
src/runtime/node/quic/stream.rs                 1      1      0
test/js/node/quic/quic-stream.test.ts           3      8      0

The shim's stack lsxpack_header[128] buffer was an allocation
convenience, not a policy limit, but the bounds check turned it into a
hard outbound cap: any HEADERS block with more than 128 field lines
(125 custom + 4 pseudo) made sendHeaders() return false, nothing went
on the wire, and the idle timeout eventually tore down the whole
connection cleanly with no error surfaced.

application.maxHeaderPairs is an inbound decoder limit (node's
DefaultApplication applies it in the header-set callback), so the
encoder path must not cap the count. Heap-allocate the lsxpack_header
array above the stack threshold; lsquic copies the encoded block before
returning so the buffer can be freed immediately.

Also: only mark wrote_to_lsquic in bind_raw once the replay actually
reaches lsquic, matching the already-bound path.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 35ce0728-5ff5-4a5d-84b6-9aae253f064d

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and 2e3cb71.

📒 Files selected for processing (3)
  • packages/bun-usockets/src/node_quic_shim.c
  • src/runtime/node/quic/stream.rs
  • test/js/node/quic/quic-stream.test.ts

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

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:14 AM PT - Jul 25th, 2026

⏳ @robobun, your commit 2e3cb71 is still building in Build #81075, but has 1 failures so far (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    ❌ bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    ❌ bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    ❌ bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    ❌ bun-linux-x6472.47 MB71.95 MB+528.0 KB
    ❌ bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    ❌ bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    ❌ bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    ❌ bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    ❌ bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    ❌ bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    ❌ bun-windows-x6480.26 MB79.70 MB+570.5 KB
    ❌ bun-windows-aarch6470.86 MB70.34 MB+533.0 KB

    Add [skip size check] to the commit message if this increase is intentional.

Comment thread packages/bun-usockets/src/node_quic_shim.c Outdated
Comment thread test/js/node/quic/quic-stream.test.ts Outdated
lsxpack_header_set_offset2 asserts val_offset <= LSXPACK_MAX_STRLEN
(65535), live in debug builds. Rebasing hdr->buf to the start of each
name keeps the stored offsets at 0 and name_len+1 regardless of how far
into the joined buffer the entry sits, so a >64KB header block no longer
trips the assert. Tighten the name_len guard to keep name_len+1 in range.

Test: capture the server-side sendHeaders() return via a resolver and
assert it from the test body rather than throwing inside the onheaders
callback; wire stream.closed to the response resolver so a regression
surfaces the real error instead of hanging. Lengthen request header
values so the joined buffer runs past 64KB.

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

Both earlier findings are addressed in c6cb882 — the per-entry buf + name_off rebase keeps val_offset = name_len + 1 within lsxpack_strlen_t, and the test now resolves serverSendOk from the callback and asserts in the body (with stream.closed.catch(gotHeaders.reject) wired). I didn't find anything new, but this is manual malloc/free on an H3 wire-encode path, so worth a human look.

What was reviewed:

  • Heap spill: free(hdrs) reached on every exit (early goto done, count != expected, and success); hdrs != stack_hdrs correctly gates it.
  • Tightened name_len >= LSXPACK_MAX_STRLEN guard keeps the rebased val_offset ≤ UINT16_MAX; val_len > LSXPACK_MAX_STRLEN unchanged.
  • bind_raw now sets wrote_to_lsquic only after send_headers == 0, matching the already-bound path — checked abort_for_destroy's defer branch still behaves.
  • Test's 200×400-byte values push the joined buffer past 64 KB and asserts first/last header values byte-for-byte at the peer.
Extended reasoning...

Overview

The PR removes the hard 128-header cap in us_nq_stream_send_headers (packages/bun-usockets/src/node_quic_shim.c) by heap-allocating the lsxpack_header[] array when expected > 128, and rebases each entry's buf pointer to buf + name_off so the u16 name_offset/val_offset fields never wrap regardless of total buffer size. It also adjusts bind_raw in src/runtime/node/quic/stream.rs to set wrote_to_lsquic only after the pending-headers replay actually succeeds, and adds a round-trip test with 204 field lines and an ~82 KB joined buffer.

Prior review

On the first revision (16a53f1) I flagged (🔴) that lifting the count cap without addressing the u16 offset fields would silently corrupt headers once the joined buffer crossed 64 KB, and (🟡) that the test asserted inside an event callback. Commit c6cb882 applies the per-entry rebase I suggested (with the name_len >= LSXPACK_MAX_STRLEN tightening needed to keep name_len + 1 in range), moves the server-side assertion into the test body via a resolver, and wires stream.closed to reject gotHeaders. The test now uses 400-byte values specifically to push the buffer past 64 KB and asserts the first and last received values byte-for-byte, so the offset-rebase path is actually exercised.

Security risks

The encode path takes user-controlled header data. The offset-wraparound risk (garbage bytes on the wire) is what the rebase fixes; individual name/value lengths are still bounded by LSXPACK_MAX_STRLEN. (size_t)expected * sizeof(*hdrs) widens before multiply and expected is a signed int derived from the JS pair count, so no realistic allocation-size overflow on 64-bit. The heap buffer is freed on every path via the done: label. I don't see a new injection or memory-safety exposure.

Level of scrutiny

Medium-high: this is native C doing manual allocation on an HTTP/3 wire-encode path in a Node-compat module. The change is small and localized, but the failure mode of getting it wrong is silent wire corruption or a leak. The bind_raw reordering also subtly changes when abort_for_destroy takes the defer-vs-reset branch for a stream whose pending-headers replay failed — that looks correct (matches the already-bound send_headers path) but is the kind of thing a maintainer familiar with the lsquic session lifecycle should confirm.

Other factors

The bug-hunting pass on this revision found nothing. The test is in the existing quic-stream.test.ts file, follows the sibling tests' resolver pattern, and covers both directions (client→server and server→client) plus both the count and >64 KB paths. Deferring rather than approving because native protocol-shim changes with manual memory management are outside the "simple/mechanical" bar even when they look right.

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

Both earlier review points are addressed in c6cb882 — the per-entry buf + name_off rebase keeps val_offset = name_len+1 bounded regardless of joined-buffer size, and the server-side sendHeaders return is now asserted from the test body with stream.closed wired to reject. I didn't find anything further, but this is native C heap management on an H3 encode path so worth a human pass.

What was reviewed:

  • Traced every exit from us_nq_stream_send_headers (early returns, both breaks, goto done, success fall-through) — heap hdrs is freed on all of them; malloc (not calloc) is fine since lsxpack_header_set_offset2 memsets the struct and the pre-PR stack array was also uninitialized.
  • Confirmed the stack-then-heap-spill shape mirrors the existing us_quic_stream_send_headers in quic.c:985-1013.
  • Checked the bind_raw change: wrote_to_lsquic now gates on send_headers == 0, matching the already-bound path; only downstream reader is abort_for_destroy, where the new behavior (defer abort if nothing actually reached lsquic) is the more correct one.
Extended reasoning...

Overview

The PR removes a hard 128-entry cap in us_nq_stream_send_headers (packages/bun-usockets/src/node_quic_shim.c) by spilling the lsxpack_header[] array to the heap when expected > 128, keeping the stack buffer for the common case. After my earlier review it also rebases hdr->buf per entry so lsxpack offsets stay small independent of the joined buffer's total size, and tightens the name_len guard to >= so val_offset = name_len + 1 never exceeds LSXPACK_MAX_STRLEN. A small Rust change in bind_raw (src/runtime/node/quic/stream.rs) delays setting wrote_to_lsquic until the pending-headers replay actually succeeds, matching the direct send_headers path. The test round-trips 204 field lines (200 × 400-byte values, ~82KB joined) in both directions.

Prior feedback and its resolution

I left two inline comments on the first revision: (1) the offset-into-single-buffer scheme could exceed the setter's LSXPACK_MAX_STRLEN assert once the joined buffer grew past 64KB, and (2) an expect() inside the server onheaders callback would degrade a regression into a timeout. Both are addressed in c6cb882: the per-entry base pointer keeps offsets at 0 and name_len+1; the test captures the server return via serverSendOk.resolve(...), asserts it in the test body, and wires stream.closed.catch(gotHeaders.reject). The test's header values were bumped to 400 bytes so the joined buffer exceeds 64KB and exercises the rebase path.

Security risks

The function operates on a buffer built by Bun's own buildNgHeaderString (Rust side), so expected and the buffer contents are trusted-caller inputs, not raw wire bytes. expected is an int cast to size_t before the multiply, and sizeof(*hdrs) is small, so the allocation size can't wrap on 64-bit; a pathological count would just fail malloc and return -1. The per-entry length guard rejects individual names/values ≥ LSXPACK_MAX_STRLEN. No new externally-controlled parsing was introduced.

Level of scrutiny

This is manual C memory management (malloc/free with goto-based cleanup) on an HTTP/3 QPACK encode path across an FFI boundary — exactly the category REVIEW.md flags as most-blocked. The change is small, follows the existing pattern in quic.c, and I've walked every exit path for the free, but native protocol code with heap allocation deserves a maintainer's eyes rather than a bot approval.

Other factors

node:quic is experimental (the test output shows the ExperimentalWarning). The gate evidence shows the new test fails on main (sendHeaders returns false) and passes on the PR under both ASAN-debug and release. No CODEOWNERS file matched. No unresolved reviewer threads remain — both of my prior inline comments are marked resolved with responsive fix commits.

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Correction: the CI account in this comment is wrong in part. See #35713 (comment).

Diff is ready. The new test fails on main (sendHeaders() returns false for 204 field lines) and passes with the fix under ASAN; all 15 test/js/node/quic/ tests pass locally.

CI red on builds 80942 and 81075 is unrelated to this change:

  • test/cli/run/no-orphans.test.ts, test/cli/install/bun-install-registry.test.ts, test/cli/install/migration/complex-workspace.test.ts, test/regression/issue/20144/20144.test.ts, test/js/node/test/parallel/test-http-agent-keepalive.js, test/regression/issue/27272.test.ts (Buildkite secret 502): all marked flaky by ci:errors, none touch node:quic / lsquic / h3.
  • binary-size (+~550 KB vs canary #79916): the baseline predates this branch point; this diff changes ~40 lines of C/Rust and cannot account for the delta.

Both review threads are resolved in c6cb882.

@github-actions

Copy link
Copy Markdown
Contributor

Closing this PR because it has been inactive for more than 90 days.

@github-actions github-actions Bot closed this Oct 24, 2026
@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

The stale sweep closed this PR. It was not merged.

Edited after posting: the first version of this comment said that the bug is still present on main, and that the entry for this PR in #46528 is wrong. Both statements were too broad. I had run only stream.sendHeaders(). The results for each entry point are below.

Status today, on canary 1.4.4-canary.1+5c6d4e24c, with a HEADERS block of 129 field lines

Corrections to my earlier text on this PR

  • The PR body said that every concurrent stream on the session dies. That was wrong, and I have corrected the body. Other requests on the same session get their responses.
  • My CI comment said that flaky tests made CI red. That reading was wrong. On build 81075 the only failed job was binary-size. The tests in that list passed on a retry. Later pushes to this branch canceled builds 80850 and 80942 while they ran.
  • The same comment said that the binary-size baseline was older than the branch point. I had not checked that. It is correct: canary build 79916 (ae4b17de6d) is 8 commits older than df6c7eed6b, and those commits include node:quic on lsquic — Node v26 compat, HTTP/3 #32602.
  • The code comment in the first commit cited node/src/quic/application.cc. I had not read that file. In Node v26.3.0 the limit is receive-only, as the comment said. But the check is CanAddHeader in src/quic/http3.cc, which Stream::AddHeader in src/quic/streams.cc calls. The send path (NgHeaders in src/node_http_common-inl.h) has no count limit.
  • The message of c6cb882 says that a header block larger than 64 KB no longer trips an assert. I did not see that assert fail. I inferred it from lsxpack_header.h.
  • I did not review the design or self-review the diff before the pushes to this PR.

I am preparing a small PR from current main for the count limit alone.

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