Skip to content

http3: deliver every header block that lsquic decodes while a stream is read - #42860

Merged
Jarred-Sumner merged 4 commits into
mainfrom
robobun/f7e99eaa/h3-claim-header-sets-before-fin
Sep 16, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
robobun/f7e99eaa/h3-claim-header-sets-before-fin

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Since the lsquic 4.9.4 update (deps: update libarchive, brotli, lsquic, libjpeg-turbo, sqlite and others #42523), fetch() over HTTP/3 rejects with HTTP3StreamReset when two 1xx blocks precede a final response with no body. A node:quic client gets no onheaders, and a node:quic server loses trailers that follow the request headers. Bun 1.4.2 passes. No release has it yet.
  • lsquic_stream_read now decodes a header block that follows another one, and returns 0 at the end of the stream with that block still queued. us_quic_on_read (packages/bun-usockets/src/quic.c:676) and on_stream_read (src/runtime/node/quic/stream.rs:1084) claim blocks only before they read. They deliver the FIN and lose the block.
  • A node:quic client gets the body before onheaders. It queues two decoded blocks, so one read decodes the final response behind a claimed 1xx and returns its DATA.

Fix

  • Both readers claim every queued block before the FIN.
  • us_nq_settings_init sets es_max_header_sets to 1, as a server has. lsquic then parses nothing past an unclaimed block, as in 1.4.2.
  • Verified: test/js/node/quic/quic-stream.test.ts (2 new) and test/js/web/fetch/fetch-http3-client.test.ts (/no-body) fail on main and pass on 1.4.2. Also test/js/node/quic/, serve-http3, fetch-http3-*, serve-protocols.
  • Self-reviewed: 8 concerns raised, 7 addressed. Open: no upstream lsquic report (Notes).

Background

  • A header set is one decoded HEADERS block (1xx, final response, trailers). lsquic queues them, and lsquic_stream_get_hset claims the next one.
  • es_max_header_sets bounds the queue. Parsing stops while it is full.
  • On receipt lsquic parses a stream only until a block is queued. It parses the rest inside lsquic_stream_read, so a block that shares a STREAM frame with an earlier one is decoded during a read.
Notes

Found by a read of the code, not from a user report. It came up while I worked on the late 100 Continue of Bun.serve({ http3: true }): a server that sends its 100 early puts it in its own STREAM frame, and that exposes these readers. The flush is #42867, stacked on this PR.

Versions. 1.4.2 (744846f, lsquic 3181911) against canary 09bb546 and main (lsquic 4.9.4), same scripts:

peer sends in one write 1.4.2 09bb546
100, 103, 204 to fetch() 204 HTTP3StreamReset
100, 103, 204 to a node:quic client info 100, info 103, headers 204 info 100, info 103
100, 103, 200 + body to a node:quic client headers 200 before the data headers 200 after the stream ended
request headers + trailers to a node:quic server ontrailers fires no ontrailers

The exact window. The block is lost only when one lsquic_stream_read call decodes it and also reaches the FIN:

  • three blocks plus FIN in one frame to a client. On receipt lsquic decodes two (the client's queue bound) and stops. The reader claims them, and the third is decoded inside the read.
  • a 1xx in one STREAM frame and the final HEADERS plus FIN in the next, both received before on_read runs. lsquic skips its parser on receipt while a decoded block is queued.
  • two blocks plus FIN in one frame to a server, whose queue bound is 1.

A 1xx and a final response that arrive in different event loop turns pass on every build, because on_read runs between them. Trailers that follow DATA were already delivered: the read returns the bytes first, the next read fails on the unclaimed block, and lsquic calls on_read again.

lsquic. At the pinned commit, stream_readf returns 0 when it read nothing and reached the FIN, also when uh is not empty (lsquic_stream.c#L1650-L1667). lsquic_stream_readf guards the same state only on entry (#L1693-L1703) and lets the next call fail with "header set not claimed". The queue bound defaults to 1 for a server and 2 for a client (lsquic.h#L330-L335). I did not report this upstream. The two loops are correct with and without an upstream change.

Why not a patches/lsquic patch. A few lines in stream_readf would cover both readers. The loops need no patch to carry across lsquic updates, and they are also correct against a queue bound above 1.

es_max_header_sets for fetch(). The quic.c client keeps lsquic's default of 2. ClientSession::deliver holds body bytes until the final headers arrive, so DATA ahead of its HEADERS is not observable there. I found no test that can tell the two values apart for fetch(), so I left it.

Each part is live. With the es_max_header_sets line removed, only the /body order assertion of the client test fails. With origin/main sources, both node:quic tests and the fetch() /no-body case fail on the debug build.

Not asserted. The order of ontrailers against the end of the request body on a server. Both events are queued by one lsquic callback, and the body iteration can finish in the microtasks between them. 1.4.2 has the same order.

Excluded. packages/h3blast/src/h3blast.c:435 has the same single claim. It is a standalone load generator, it does not link until #42406, and it never sends Expect.

Overlap. #35731 hoists the same claim block of on_stream_read into a loop and changes the would-block arm. It leaves the end-of-stream arm alone and predates #42523, so it does not fix this. It needs a rebase onto this change: the closure here has the same shape.

Other runs. The 25 test-quic-h3-*.mjs files: 24 pass. test-quic-h3-stream-idle-timeout.mjs fails the same way on canary 09bb546.


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

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/quic/quic-stream.test.ts "test/js/web/fetch/fetch-http3-client.test.ts"
bun test v1.4.3 (09bb54630)

test/js/web/fetch/fetch-http3-client.test.ts:
(node:380852) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun-debug --trace-warnings ...` to show where the warning was created)
(pass) fetch protocol: http3 > GET text [40.56ms]
(pass) fetch protocol: http3 > 'h3' alias [14.27ms]
(pass) fetch protocol: http3 > POST echo with headers [33.47ms]
(pass) fetch protocol: http3 > JSON + query string [31.69ms]
(pass) fetch protocol: http3 > route params [17.44ms]
(pass) fetch protocol: http3 > large response body (multi-packet) [21.82ms]
(pass) fetch protocol: http3 > large request body [59.43ms]
(pass) fetch protocol: http3 > compress: gzip request body — small (shared-buffer fast path) [32.80ms]
(pass) fetch protocol: http3 > compress: gzip request body — large (zlib-streaming spill path) [17.73ms]
(pass) fetch protocol: http3 > status 200 [19.93ms]
(pass) fetch protocol: http3 > status 
... (truncated)

release without fix: 5 FAILED
bun test v1.4.3-canary.1 (09bb54630)

test/js/web/fetch/fetch-http3-client.test.ts:
(node:380939) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun --trace-warnings ...` to show where the warning was created)
(pass) fetch protocol: http3 > GET text [4.63ms]
(pass) fetch protocol: http3 > 'h3' alias [0.38ms]
(pass) fetch protocol: http3 > POST echo with headers [0.84ms]
(pass) fetch protocol: http3 > JSON + query string [0.42ms]
(pass) fetch protocol: http3 > route params [0.33ms]
(pass) fetch protocol: http3 > large response body (multi-packet) [1.96ms]
(pass) fetch protocol: http3 > large request body [2.05ms]
(pass) fetch protocol: http3 > compress: gzip request body — small (shared-buffer fast path) [1.05ms]
(pass) fetch protocol: http3 > compress: gzip request body — large (zlib-streaming spill path) [2.33ms]
(pass) fetch protocol: http3 > status 200 [0.54ms]
(pass) fetch protocol: http3 > status 204 [0.18ms]
(pass) fetch protocol: http3 > status 404 [0.14ms]
(pass) fetch protocol: http3 > status 500 [0.12ms]
(pass) fetch protocol: http3 > HEAD has no body [0.36ms]
(pass) fetch protocol: http3 > the response to a 204
... (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/pr_gate.xml" test/js/node/quic/quic-stream.test.ts "test/js/web/fetch/fetch-http3-client.test.ts"
bun test v1.4.3 (09bb54630)

test/js/web/fetch/fetch-http3-client.test.ts:
(node:382834) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun-debug --trace-warnings ...` to show where the warning was created)
(pass) fetch protocol: http3 > GET text [58.80ms]
(pass) fetch protocol: http3 > 'h3' alias [19.68ms]
(pass) fetch protocol: http3 > POST echo with headers [57.54ms]
(pass) fetch protocol: http3 > JSON + query string [29.50ms]
(pass) fetch protocol: http3 > route params [25.71ms]
(pass) fetch protocol: http3 > large response body (multi-packet) [38.49ms]
(pass) fetch protocol: http3 > large request body [34.48ms]
(pass) fetch protocol: http3 > compress: gzip request body — small (shared-buffer fast path) [45.96ms]
(pass) fetch protocol: http3 > compress: gzip request body — large (zlib-streaming spill path) [25.14ms]
(pass) fetch protocol: http3 > status 200 [27.31ms]
(pass) fetch protocol: http3 > status 
... (truncated)

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     c446afddc7
  features     lto, baseline

23 deps, 131 codegen, 1176 objects in 822ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1254] mkdir codegen
[2/1254] mkdir stamps
[3/1254] mkdir pch
[4/1254] mkdir obj
[5/1254] install /workspace/bun
bun install v1.4.3-canary.1 (09bb54630)

Checked 22 installs across 61 packages (no changes) [24.00ms]
[6/1254] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (09bb54630)

Checked 1 install across 2 packages (no changes) [1.00ms]
[7/1254] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (09bb54630)

Checked 111 installs across 104 packages (no changes) [5.00ms]
[8/1254] gen ErrorCode+*.h
[9/1254] gen node-fallbacks/react-refresh.js
Bundled 1 module in 9ms

  react-refresh.js  4.81 KB  (entry point)

[10/1254] fetch picohttpparser
[picohttpparser] up to date
[11/1254] gen bindgenv2
[12/1254] fetch tinycc
[tinycc] up to date
[13/1254] fetch zlib
[zlib] up to date
[14/1254] gen JSBuffer.lut.h

... (truncated)
diff hotspot
packages/bun-usockets/src/node_quic_shim.c   |   3 +
 packages/bun-usockets/src/quic.c             |  36 ++++++----
 src/runtime/node/quic/stream.rs              |  93 +++++++++++++-----------
 test/js/node/quic/quic-stream.test.ts        | 104 +++++++++++++++++++++++++++
 test/js/web/fetch/fetch-http3-client.test.ts |  46 ++++++++++++
 5 files changed, 226 insertions(+), 56 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                          reads  edits  tests
packages/bun-usockets/src/node_quic_shim.c        2      3     48
packages/bun-usockets/src/quic.c                 17     21     51
src/runtime/node/quic/stream.rs                   2      3     49
test/js/node/quic/quic-stream.test.ts             2      6     22
test/js/web/fetch/fetch-http3-client.test.ts      3      8     40

…is read

lsquic 4.9 decodes a header block that follows another one inside
lsquic_stream_read, and that call returns 0 at the end of the stream with
the block still queued. us_quic_on_read (Bun.serve and fetch) and the
node:quic on_stream_read claimed a block only before they read, so they
delivered the FIN and the block was lost: a final response with no body
behind two 1xx responses, or trailers right behind the request headers.
Both readers now claim every queued block before the FIN.

A node:quic client also let lsquic queue two decoded blocks. One read
then decoded the final response behind a claimed 1xx and returned its
DATA, so the body reached JS ahead of onheaders. The node:quic engine
now queues one block, as a server already does.
@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on canary 09bb546 and on a debug build of main:

  • A node:quic server sends 100, 103 and a 204 with no body in one write. fetch() over HTTP/3 rejects with HTTP3StreamReset. A node:quic client gets info 100, info 103 and no onheaders.
  • The same server sends 100, 103 and a 200 with a body. A node:quic client reads the body before onheaders fires.
  • A node:quic client sends request headers and trailers in one write. The server gets no ontrailers.

Bun 1.4.2 passes all three, so this is a regression from the lsquic 4.9.4 update (#42523). The new tests in test/js/node/quic/quic-stream.test.ts and test/js/web/fetch/fetch-http3-client.test.ts are these scripts.

Fix: this PR.

CI, build 116289: 180 of 181 jobs passed. The failed job is the debian 13 x64-asan lane with test/js/bun/http/serve-pending-promise-abort-leak.test.ts. That test fails the same way on main, it does not use HTTP/3, and this diff does not touch its code. It is reported for triage on main. All other entries in the report passed on retry.

@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 0e9ecc79-fcba-441a-b648-a77b16946e99

📥 Commits

Reviewing files that changed from the base of the PR and between 8850d3e and c46ce62.

📒 Files selected for processing (6)
  • packages/bun-usockets/src/node_quic_shim.c
  • packages/bun-usockets/src/quic.c
  • src/runtime/node/quic/stream.rs
  • test/js/bun/http/serve-http3.test.ts
  • test/js/node/quic/quic-stream.test.ts
  • test/js/web/fetch/fetch-http3-client.test.ts

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.


Walkthrough

HTTP/3 QUIC handling now delivers multiple decoded header blocks per stream. It defers header flushing through write callbacks and processes headers decoded with EOF. Tests cover informational responses, final responses, bodies, trailers, and request-body ordering.

Changes

HTTP/3 header delivery

Layer / File(s) Summary
Decoder header delivery
packages/bun-usockets/src/node_quic_shim.c, packages/bun-usockets/src/quic.c
The decoder processes one header set at a time. QUIC delivery drains queued header sets before FIN. Header flushing can be deferred until the writable callback.
Runtime stream processing
src/runtime/node/quic/stream.rs
Stream reads process all available headers and classify interim, initial, and trailing headers. Invalid client requests containing :status reset and stop the stream.
HTTP/3 behavior coverage
test/js/node/quic/quic-stream.test.ts, test/js/web/fetch/fetch-http3-client.test.ts, test/js/bun/http/serve-http3.test.ts
Tests cover multiple informational responses, final responses with and without bodies, request trailers, request-body ordering, and handshake timing.

Suggested reviewers: cirospaciari, jarred-sumner

Merge Risk: ⚪ Minimal · up to 8850d

No actionable current-head issue remains; the response pipeline preserves final-header-before-body delivery.

🚥 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 main change: delivering every HTTP/3 header block decoded during a stream read.
Description check ✅ Passed The description explains the problem, fix, background, scope, and verification results. It does not use the template headings exactly, but it provides the required information and is complete.

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

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:47 PM PT - Sep 15th, 2026

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

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

Beyond the inline finding, I also checked the two new post-callback exits: the -1 arm of us_quic_deliver_hset in packages/bun-usockets/src/quic.c reuses the existing s->stream liveness check (nulled only in us_quic_on_close, which lsquic does not run synchronously inside on_read), and the reset break in the 0 arm of on_stream_read in src/runtime/node/quic/stream.rs ends in the same state as the old early return (mark_reset set, no want_read(false), FIN not pushed). The quic.c fetch client keeping lsquic's default of two queued header sets is not observable there because ClientSession holds body bytes until the final headers arrive.

Extended reasoning...

An inline finding is already posted, so this body only records what else was examined. The C helper us_quic_deliver_hset frees s->hset and replaces it before invoking on_stream_headers, then reads s->stream after the callback; s->stream is assigned only at us_quic_on_new_stream and nulled only in us_quic_on_close (which also frees s), which is the same pattern the pre-PR code relied on with if (!s->stream) return;, so the drain loop introduces no new lifetime hazard. In on_stream_read, the claim_header_sets closure returning false inside the 0 arm breaks out of the read loop without pushing the FIN or disabling reads, matching the pre-existing early-return behaviour when the malformed :status block was the first one claimed; got_any may still push a StreamWake, which only wakes consumers of data already delivered. The es_max_header_sets = 1 change lives in node_quic_shim.c and applies to node:quic sessions only; the quic.c client used by fetch() keeps the default, which the description acknowledges and which is masked by ClientSession buffering.

Comment thread test/js/node/quic/quic-stream.test.ts Outdated
A node:quic server runs onheaders after the lsquic tick that read the
request. By then the QPACK encoder stream is out, so lsquic takes three
header blocks on the first request of a connection too. The tests fail
on main and pass on 1.4.2 without the warm-up.
Comment thread src/runtime/node/quic/stream.rs Outdated
Comment thread src/runtime/node/quic/stream.rs
Comment thread src/runtime/node/quic/stream.rs
Comment thread src/runtime/node/quic/stream.rs
Comment thread src/runtime/node/quic/stream.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed the PR after the warm-up removal and didn't find any bugs; since this changes stream-read control flow in both lsquic consumers and an engine setting, a human look at the lsquic side is still worthwhile.

What was reviewed:

  • The earlier inline note about the /warm requests is addressed by 8a7eecb: both new tests now exercise the first request of a connection.
  • us_quic_deliver_hset in quic.c: the post-callback s->stream liveness check from the old inline block is preserved, and the FIN-arm drain loop returns before fin_delivered/shutdown when on_stream_headers closed the stream.
  • claim_header_sets in stream.rs: the malformed-request path still resets and stops before any push_inbound; in the FIN arm it breaks without pushing the FIN or calling want_read(false), which matches what the pre-read return already did on main.
  • es_max_header_sets = 1 in the node:quic shim applies to both client and server; I could not verify lsquic's default here (no vendor/ in this checkout), so the "server already has 1" claim is taken from the description.
Extended reasoning...

Overview

The diff touches two lsquic read callbacks — us_quic_on_read in packages/bun-usockets/src/quic.c (fetch/Bun.serve H3) and on_stream_read in src/runtime/node/quic/stream.rs (node:quic) — so that every header block lsquic has decoded is handed up before the FIN is delivered, plus a one-line es_max_header_sets = 1 in node_quic_shim.c. Two test files gain coverage for 100+103 followed by a 204 or a 200+body, and for request trailers on a server. The follow-up commit 8a7eecb removes the /warm warm-up requests that the prior review flagged, so the tests now cover the first request of a connection.

Security risks

No auth, crypto, or credential handling changes. The new loops consume header sets that lsquic already bounded via es_max_header_sets, so there is no new unbounded allocation from peer data. The H3_MESSAGE_ERROR reset for a :status in a request is preserved inside the loop and returns before any body bytes are pushed.

Level of scrutiny

Moderate-to-high: this is native code driven by peer-controlled network input, with a callback (on_stream_headers) that can close the stream re-entrantly. I checked that the C helper re-validates s->stream after the callback and that the FIN-arm loop bails out before fin_delivered/wantread(0)/shutdown(0) when it returns -1. In Rust the closure captures only &QuicStream, the stream wrapper and ctx, is Fn, and the FIN-arm break on a reset skips push_inbound(&[], true) and want_read(false) in the same way the pre-read return on main did; got_any from earlier data still triggers a StreamWake, which is harmless. The engine-setting change is applied for both server and client in the shim; I could not confirm lsquic's default from source in this checkout, so that part rests on the description and the test that the author says distinguishes it (/body ordering).

Other factors

The bug hunt ran dry with no findings, and the only prior inline note (warm-up requests hiding first-request behavior) was addressed by a later commit rather than by author self-resolution. The change is small and the tests are hermetic (port: 0, local node:quic origin, await using/destroy() cleanup). I am not approving outright because the correctness argument depends on lsquic's stream_readf semantics that I could not read here, and a human familiar with the lsquic integration should confirm the es_max_header_sets choice.

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

The lsquic facts this change rests on, at the pinned commit (LSQUIC_COMMIT in scripts/build/deps/lsquic.ts), since vendor/ is not in git:

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

### Problem
- `Bun.serve({ http3: true })` sends its automatic `100 Continue` only
with the final response. A client that waits for the 100 waits its full
timeout: curl with `Expect: 100-continue` takes 1.01 s over
`--http3-only`, 7 ms over HTTP/1.1 and HTTP/2.
- `fetch()` over HTTP/3 with a streamed request body does not reach the
server until the first chunk exists, so a body whose first chunk waits
for the server never gets there. HTTP/1.1 sends the request headers at
once.
- Cause: `lsquic_stream_send_headers` only buffers.
`us_quic_stream_send_informational`
(`packages/bun-usockets/src/quic.c:1125`) and
`us_quic_stream_send_headers` without `end_stream` (`quic.c:1136`) leave
the flush to the next body write.

### Fix
- Both writers call `us_quic_flush_from_on_write`, which asks for
`on_write`. `us_quic_on_write` flushes after `on_stream_writable`.
- Correct because `on_write` runs in the write phase of the same
`process_conns`, after lsquic wrote a held-back block. A response that
the handler wrote at once is behind the 100 by then. Both still leave in
one STREAM frame.
- Verified: `test/js/bun/http/serve-http3.test.ts` (2 new) and
`test/js/web/fetch/fetch-http3-client.test.ts` (1 new) time out without
it. Also `fetch-http3-*`, `serve-protocols`, `test/js/node/quic/`.
- Self-reviewed: 7 concerns raised, 7 addressed (Notes).

### Background
- lsquic buffers stream writes until a packet is full, the stream ends,
or a flush. `process_conns` runs read callbacks (the request handlers),
then write callbacks.
- On a new connection lsquic holds a header block back until its QPACK
encoder stream is out. A flush then does nothing. lsquic calls
`on_write` right after it writes it.
- Stacked on #42860: without it, a 100 in its own packet makes a reader
lose a final response with no body.

<details><summary>Notes</summary>

**No user reported this.** It was found while #42805 was in work. Reach:
the experimental `http3: true`, a client that sets `Expect` itself over
HTTP/3 and waits (curl does, `fetch()` does not), and a handler that
reads the body before it answers. The cost is the client's expect
timeout on each request, and the request still succeeds. The second
writer costs more: a `fetch()` body whose first chunk depends on the
server hangs until the idle timeout and rejects with `HTTP3StreamReset`.

**curl 8.14.1, 64 KB POST with `Expect: 100-continue`, three runs
each:**

| | `--http3-only` | `--http2` | `--http1.1` |
| --- | --- | --- | --- |
| canary 09bb546 | 1.012 s, 1.012 s, 1.010 s | 7 ms, 6 ms, 7 ms | 6
ms, 5 ms, 7 ms |
| this PR (debug build) | 0.337 s, 0.021 s, 0.017 s | 11 ms | 10 to 13
ms |

Without `Expect`, `--http3-only` takes 8 ms on canary. The first
debug-build run includes the first handshake of that process.

**Why the flush is in `on_write` and not in the two writers.**
- On the first request of a connection, `lsquic_qeh_write_headers`
returns `QWH_PARTIAL` and `send_headers_ietf` stashes the block.
`sm_n_buffered` is 0, so `lsquic_stream_flush` logs `flushing 0 bytes:
noop`. `on_write_header_wrapper` writes the block later and then calls
the user's `on_write`. The second server test covers this (lsquic debug
log: `stashed 4 bytes of header block`, once per run).
- A handler that answers in the same call writes its response in the
read phase. A flush at send time puts the 100 in its own STREAM frame
ahead of it. A flush in `on_write` keeps today's single frame
(`generated STREAM frame: stream 4, offset: 0, size: 55, fin: 1`).

**The flag is taken before the callback.** The `fetch()` client sends
its request from inside `on_stream_writable`. A block sent there is
flushed by the next `on_write`: lsquic calls it at once because
want-write is set again, or after it wrote a held-back block. No user of
`quic.c` clears want-write.

**Cost on the response path.** A response that ends in the same call
cancels the want-write in `stream_shutdown_write`, so no extra
`on_write` runs. A streamed response gets one extra `on_write`, and
`drain()` does nothing there without backpressure.

**Other details.**
- `pending_write_bytes++` in `us_quic_stream_send_informational` covers
a call from outside an lsquic callback (`uws_h3_res_write_continue`).
`us_quic_stream_send_headers` already bumps it.
- lsquic calls `on_close` and destroys a stream only from the service
phase of its tick, so the stream is valid after `on_stream_writable`
returns. A flush on a stream that the callback shut down is a no-op
(`EBADF`).
- Response headers are not a third site. `Bun.serve` has no HTTP/3
caller of `flushHeaders()`, and it sends response headers with the first
chunk on HTTP/1.1 too (701 ms on both for a first chunk that is 700 ms
late).

**#42805 (for #33082).** The two PRs change different functions and pass
together: merged locally, `serve-http3` has 74 passes. Its body says the
fix for the late 100 "needs this retry path". That is the case where the
handler answers while lsquic still holds the 100 back: lsquic refuses
the final block there, with and without this PR. This PR does not change
that case. If the maintainers take the alternative that #42805 offers
and remove the automatic 100 for HTTP/3, the 1xx half of this PR has no
caller left. The request-headers half still stands.

**`node:quic`** has its own header writer with the same gap when a
stream is opened before the handshake completes. It is tracked in #42866
and not changed here. The new-connection test takes `stream.writer`
before `sendHeaders`, as the `server.stop()` test in the same file does,
so it does not depend on that bug.

**With #42860 and this PR**, a busy `node:quic` client that receives the
100 and a later 204 in one batch gets `info 100`, `headers 204` (3 of 3
runs). Without #42860 it lost `headers 204` against this server (0 of
3).

**Suites run on the debug build:** `serve-http3` (65),
`fetch-http3-client` (61), `fetch-http3-adversarial` (27),
`fetch-http3-cold-post` (2), `fetch-http3-syscall-fault` (7),
`serve-protocols` (20), `quic-stream` (6), `quic-endpoint` (7),
`quic-sni` (6), and the other files that use HTTP/3:
`serve-http2-lifecycle` (23), `serve-direct-readable-stream` (149),
`body-stream` (9086). With the sources of #42860, the three new tests
time out.

`fetch-backpressure.test.ts` does not pass on the debug build in my
container: the HTTP/3 receive-backpressure tests time out and the S3
tests fail. It fails the same way there with the sources of
`origin/main`, and it passes in CI.

</details>

<!-- robobun:evidence:begin -->

---

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

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/bun/http/serve-http3.test.ts" "test/js/web/fetch/fetch-http3-client.test.ts"
bun test v1.4.3 (09bb546)

test/js/bun/http/serve-http3.test.ts:
(node:409468) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun-debug --trace-warnings ...` to show where the warning was created)
(pass) Bun.serve HTTP/3 > basic GET [1697.81ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [1688.75ms]
(pass) Bun.serve HTTP/3 > 204 with no body [1692.65ms]
(pass) Bun.serve HTTP/3 > query string is preserved [1952.17ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [1773.99ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [1830.79ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [1738.99ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [1689.01ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [2766.35ms]
(pass) Bun.serve HTTP/3 > maxReque
... (truncated)

release without fix: 1 failed, 1 skipped
bun test v1.4.3-canary.1 (96577ad)

test/js/bun/http/serve-http3.test.ts:
(node:410372) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun --trace-warnings ...` to show where the warning was created)
(pass) Bun.serve HTTP/3 > basic GET [161.08ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [139.08ms]
(pass) Bun.serve HTTP/3 > 204 with no body [182.96ms]
(pass) Bun.serve HTTP/3 > query string is preserved [161.93ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [155.42ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [146.86ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [159.51ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [148.89ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [1039.78ms]
(pass) Bun.serve HTTP/3 > maxRequestBodySize is enforced for H3 bodies without Content-Length [150.46ms]
(pass) Bun.serve HTTP/3 > unknown route returns 404 [145.06ms]
(pass) Bun.serve HTTP/3 > routes: handler with :params [163.24ms]
(pass) Bun.serve HTTP/3
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/bun/http/serve-http3.test.ts" "test/js/web/fetch/fetch-http3-client.test.ts"
bun test v1.4.3 (09bb546)

test/js/bun/http/serve-http3.test.ts:
(node:411333) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun-debug --trace-warnings ...` to show where the warning was created)
(pass) Bun.serve HTTP/3 > basic GET [2068.94ms]
(pass) Bun.serve HTTP/3 > POST echoes body, status, request headers [1988.03ms]
(pass) Bun.serve HTTP/3 > 204 with no body [1811.93ms]
(pass) Bun.serve HTTP/3 > query string is preserved [2127.37ms]
(pass) Bun.serve HTTP/3 > large response body crosses multiple QUIC packets [2005.83ms]
(pass) Bun.serve HTTP/3 > concurrent requests across separate connections [2007.58ms]
(pass) Bun.serve HTTP/3 > client abort mid-response does not crash the server [2383.24ms]
(pass) Bun.serve HTTP/3 > http1: false rejects HTTP/1.1 but accepts HTTP/3 [2323.66ms]
(pass) Bun.serve HTTP/3 > http1: false — url/address/stop see the QUIC listener [2927.11ms]
(pass) Bun.serve HTTP/3 > maxReque
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1127ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/10] cc obj/packages/bun-usockets/src/node_quic_shim.c.o
[2/10] cc obj/packages/bun-usockets/src/quic.c.o
[3/10] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 243 extern-C blocks audited
[3/10] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
packages/bun-usockets/src/quic.c             | 24 ++++++++-
 test/js/bun/http/serve-http3.test.ts         | 73 ++++++++++++++++++++++++++++
 test/js/web/fetch/fetch-http3-client.test.ts | 32 ++++++++++++
 3 files changed, 128 insertions(+), 1 deletion(-)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 1

<details><summary>evidence per changed file</summary>

```
file                                          reads  edits  tests
packages/bun-usockets/src/quic.c                 17     21     67
test/js/bun/http/serve-http3.test.ts              4      9     46
test/js/web/fetch/fetch-http3-client.test.ts      3      8     41
```

</details>

<!-- robobun:evidence:end -->
@Jarred-Sumner
Jarred-Sumner merged commit 9059e86 into main Sep 16, 2026
10 of 11 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/f7e99eaa/h3-claim-header-sets-before-fin branch September 16, 2026 05:17
Jarred-Sumner pushed a commit that referenced this pull request Sep 16, 2026
### Problem
- A `node:quic` client that opens an HTTP/3 stream before the handshake
completes and calls `sendHeaders(..)` without a terminal flag does not
send the request. The HEADERS frame stays in the lsquic stream buffer
until the client writes to `stream.writer` or ends it. A client that
waits for the server (for example for `100 Continue`) waits until the
idle timeout.
- The cause is `bind_raw` in `src/runtime/node/quic/stream.rs:235`. It
hands the queued `pending_headers` to `lsquic_stream_send_headers`,
which only buffers the block. The flush runs in `drain_outbound`, which
runs from the `on_write` callback. `bind_raw` asked for a write event
only when `outbound.started` was already true, so no `on_write` ran.

### Fix
- `bind_raw` now asks for a write event (`s.want_write(true)`) when it
queued at least one non-terminal header block, whatever the state of the
writer.
- This matches the path for a stream that already exists: `send_headers`
calls `kick_write()` after `s.send_headers(..)`. A terminal block does
not need it, because `s.shutdown(1)` makes lsquic flush on its own.
- The resulting `on_write` runs `drain_outbound` with an empty outbound
queue. It clears the write interest and calls `s.flush()`, so the
headers leave. Latency after `sendHeaders` is the same as for a stream
created after `client.opened` (about 30 ms on a debug build).
- Verified: `test/js/node/quic/quic-stream.test.ts` (new test, fails on
stock bun with the idle timeout close). Also `test/js/node/quic/`,
`test/js/bun/http/serve-http3.test.ts` and
`test/js/web/fetch/fetch-http3-client.test.ts`.

### Background
- `QuicStream` wraps one lsquic stream. `createBidirectionalStream`
before the handshake creates the JS object first, with `raw` null.
lsquic calls `on_new_stream` later, and `bind_raw` attaches the lsquic
stream and replays what JS queued in the meantime (`pending_headers`).
- lsquic is event driven: a stream gets an `on_write` callback only
while it has write interest (`lsquic_stream_wantwrite`).
`drain_outbound` is the `on_write` handler. It writes queued body bytes,
then calls `lsquic_stream_flush`.
- `outbound.started` becomes true when JS takes `stream.writer` or
passes a `body`. That is why taking the writer before the handshake hid
the bug.

<details><summary>Notes</summary>

Timing probe against `Bun.serve({ http3: true })` on a debug build, from
`sendHeaders` to the server handler: writer taken before the handshake
59 ms, `await client.opened` first 27 ms, neither (this fix) 38 ms.

PR #42860 and #42867 change the `quic.c` header writers and
`on_stream_read`. They do not touch `bind_raw`.
</details>

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

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

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