Conversation
Bun.serve({ http3: true }) auto-answers Expect: 100-continue with an
interim HEADERS(:status 100) before invoking the handler. On the first
stream of a fresh QUIC connection lsquic cannot write that block inline
(QPACK/flow-control is still settling), so it stashes it in
stream->sm_header_block to flush on the next writable event. The
handler's final response then called send_headers again, which
reallocated sm_header_block and clobbered the stashed interim block,
leaving the HQ framing inconsistent and resetting the stream
(HTTP3StreamReset). Warm connections write the interim inline, so the
second send_headers started clean and the request worked.
lsquic keeps a single pending header block, so the interim and final
blocks must be sent sequentially. The HTTP/3 response writer now defers
the final header block, and routes any body bytes through backpressure,
until the interim block has drained, then flushes it from the stream's
writable event. A read-only lsquic accessor
(lsquic_stream_header_block_pending) reports whether a block is still in
flight so the warm path is unaffected.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (9)
WalkthroughAdds a patch to lsquic exposing ChangesHTTP/3 100-continue deferred headers fix
Docs formatting
Possibly related issues
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 10:56 AM PT - Jun 29th, 2026
❌ @robobun, your commit c67530a has 1 failures in
🧪 To try this PR locally: bunx bun-pr 33083That installs a local version of the PR into your bun-33083 --bun |
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
There was a problem hiding this comment.
I didn't find any bugs, but this reworks the HTTP/3 response-writer state machine across several entry points and adds a patch to vendored lsquic, so it warrants a human look (CI is also currently red on Build #66805).
Extended reasoning...
Overview
This PR fixes #33082, where Bun.serve({ http3: true }) resets the stream when a request carrying Expect: 100-continue arrives on a cold QUIC connection. The fix threads a new headersDeferred / deferredEndStream state through Http3Response so the final HEADERS block is held back until lsquic has drained the stashed interim 100 block, and adds a read-only accessor (lsquic_stream_header_block_pending) to vendored lsquic via a new patch file. Touched: Http3Response.h (control flow in sendBufferedHeaders, drain, write, endWithoutBody, sendTerminatingChunk, internalEnd), Http3ResponseData.h (two new flags + reset), quic.c/quic.h (wrapper), patches/lsquic/expose-header-block-pending.patch, scripts/build/deps/lsquic.ts (register patch), and a regression test. The two docs/ diffs are unrelated autofix.ci table/indent reflows.
Security risks
None apparent. The lsquic patch is a const, read-only state inspector; no parsing, auth, or trust-boundary changes. The new deferral path buffers into the existing per-stream backpressure buffer, so it doesn't introduce new unbounded allocation beyond what write() already did.
Level of scrutiny
High. This is production server protocol code: the change touches every path that emits the final response header block (flushHeaders, endWithoutBody, internalEnd, sendTerminatingChunk) and the writable-drain loop, and the new state must compose correctly with the existing backpressure/endAfterDrain/HTTP_WRITE_CALLED flags. It also adds a patch to a vendored third-party library, which someone familiar with the lsquic build/patch flow should sanity-check (e.g., that the line-anchored hunk applies at the pinned commit).
Other factors
- robobun reports failures on Build #66805 for the latest commit; worth confirming whether they're related before merging.
- The new regression test covers the cold-connection 100-continue case and the description says the full serve-http3 / fetch-http3-adversarial suites pass locally.
- The logic looks coherent to me (deferred headers gate body writes into backpressure,
drain()re-checks pending headers and re-issuessendBufferedHeadersonce clear, header-only/FIN handled viadeferredEndStream), but the number of interacting entry points and the vendored-lib patch put this outside what I'd auto-approve.
|
Update now that build #66814 has concluded: 283 of 286 jobs passed, and all three failures are on lanes unrelated to this change. Every HTTP/3 lane is green.
The change itself is verified: |
|
Superseded by #33162, which upgrades lsquic to 4.6.3 (litespeedtech/lsquic#637) instead of carrying a custom patch, per @Jarred-Sumner's request. Closing. |
Fixes #33082.
Repro
Bun.serve({ http3: true })auto-answers a request carryingExpect: 100-continuewith an interimHEADERS(:status 100)before invoking the handler. On the first stream of a fresh QUIC connection the final response never arrives and the fetch rejects withHTTP3StreamReset. Once the connection is warm (any earlier request completed), the same request works.Deterministic: 0/12 cold, 12/12 warm on the unfixed build.
Cause
The interim
100and the final response are twolsquic_stream_send_headers()calls on the same stream. lsquic keeps a single pending header block (stream->sm_header_block): when a block can't be written inline it is stashed and flushed on the next writable event.On a cold connection the
100block encodesQWH_FULLbutlsquic_stream_write_avail()is still false (QPACK encoder / flow control settling), sosend_headers_ietf()stashes it and arms a wantwrite. The handler then produces the final response, whosesend_headerscall unconditionallymallocssm_header_blockagain, clobbering the stashed interim block and leaving the HQ-framing / send-headers state machine inconsistent. When the writable event fires, the clobbered block is written over stale framing, the peer sees a malformed HEADERS sequence and resets the stream (HTTP3StreamReset). On a warm connection the100writes inline, the state returns toSSHS_BEGIN, and the secondsend_headersstarts clean.Fix
The two header blocks must be sent sequentially. The HTTP/3 response writer (
Http3Response) now checks whether an earlier header block is still in flight before sending the final one. If it is, the final block stays buffered, any body bytes route through the existing backpressure buffer, and everything is flushed in order from the stream's writable event once the interim block drains. The warm path (interim written inline) sends the final block immediately, unchanged.To tell whether a block is still pending, a small read-only accessor is added to vendored lsquic via
patches/lsquic/expose-header-block-pending.patch:us_quic_stream_has_pending_headers()wraps it for the uWS layer. lsquic itself is unchanged in behavior (additive, read-only), and the HTTP/3 client path is unaffected (it sends request headers once per stream, never two blocks).RFC 9114 §4.1 interim-response semantics are preserved: any number of 1xx HEADERS may precede the final response, and the final response is now delivered whether the connection is fresh or reused.
Verification
test/js/bun/http/serve-http3.test.ts(Expect: 100-continue delivers the final response on a cold connection): fails onmainwithHTTP3StreamReset, passes with the fix.serve-http3.test.ts(46) andfetch-http3-adversarial.test.ts(27) suites pass, including the204 then 200 on the same connectionmulti-block path.