Skip to content

Bun.serve(http3): fix 100-continue resetting the stream on a cold connection - #33083

Closed
robobun wants to merge 3 commits into
mainfrom
farm/b53c647f/fix-h3-100-continue-cold-reset
Closed

robobun wants to merge 3 commits into
mainfrom
farm/b53c647f/fix-h3-100-continue-cold-reset

Conversation

@robobun

@robobun robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Fixes #33082.

Repro

Bun.serve({ http3: true }) auto-answers a request carrying Expect: 100-continue with an interim HEADERS(:status 100) before invoking the handler. On the first stream of a fresh QUIC connection the final response never arrives and the fetch rejects with HTTP3StreamReset. Once the connection is warm (any earlier request completed), the same request works.

using server = Bun.serve({
  port: 0, tls, http3: true, http1: false,
  async fetch(req) { return new Response("body:" + (await req.bytes()).length); },
});
const res = await fetch(`https://127.0.0.1:${server.port}/`, {
  protocol: "http3", tls: { rejectUnauthorized: false },
  method: "POST", body: "request-content", headers: { expect: "100-continue" },
});
// cold: rejects with HTTP3StreamReset; warm: 200 "body:15"

Deterministic: 0/12 cold, 12/12 warm on the unfixed build.

Cause

The interim 100 and the final response are two lsquic_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 100 block encodes QWH_FULL but lsquic_stream_write_avail() is still false (QPACK encoder / flow control settling), so send_headers_ietf() stashes it and arms a wantwrite. The handler then produces the final response, whose send_headers call unconditionally mallocs sm_header_block again, 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 the 100 writes inline, the state returns to SSHS_BEGIN, and the second send_headers starts 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:

int
lsquic_stream_header_block_pending (const struct lsquic_stream *stream)
{
    return stream->sm_send_headers_state != SSHS_BEGIN;
}

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

  • New regression test test/js/bun/http/serve-http3.test.ts (Expect: 100-continue delivers the final response on a cold connection): fails on main with HTTP3StreamReset, passes with the fix.
  • Full serve-http3.test.ts (46) and fetch-http3-adversarial.test.ts (27) suites pass, including the 204 then 200 on the same connection multi-block path.

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

coderabbitai Bot commented Jun 29, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 07c08668-ea3a-44ee-b059-91e94d975bf3

📥 Commits

Reviewing files that changed from the base of the PR and between fb24aac and c67530a.

📒 Files selected for processing (9)
  • docs/guides/util/base64.mdx
  • docs/runtime/web-apis.mdx
  • packages/bun-usockets/src/quic.c
  • packages/bun-usockets/src/quic.h
  • packages/bun-uws/src/Http3Response.h
  • packages/bun-uws/src/Http3ResponseData.h
  • patches/lsquic/expose-header-block-pending.patch
  • scripts/build/deps/lsquic.ts
  • test/js/bun/http/serve-http3.test.ts

Walkthrough

Adds a patch to lsquic exposing lsquic_stream_header_block_pending(), wires it through a new us_quic_stream_has_pending_headers() usockets helper, and updates Http3Response/Http3ResponseData to defer final response headers until an in-flight interim 100-continue header block drains. Also adds a regression test and minor docs formatting fixes.

Changes

HTTP/3 100-continue deferred headers fix

Layer / File(s) Summary
lsquic patch: expose header_block_pending API
patches/lsquic/expose-header-block-pending.patch, scripts/build/deps/lsquic.ts
Adds lsquic_stream_header_block_pending() implementation and public declaration to lsquic, and registers the patch in the build dependency list.
usockets wrapper: us_quic_stream_has_pending_headers
packages/bun-usockets/src/quic.h, packages/bun-usockets/src/quic.c
Declares and implements us_quic_stream_has_pending_headers() forwarding to lsquic_stream_header_block_pending().
Http3ResponseData: deferred-header state flags
packages/bun-uws/src/Http3ResponseData.h
Adds headersDeferred and deferredEndStream boolean fields and resets them in reset().
Http3Response: buffering and drain logic
packages/bun-uws/src/Http3Response.h
Updates write(), endWithoutBody(), sendBufferedHeaders(), drain(), and internalEnd() to buffer body data and defer final headers when headersDeferred is set, then send them after the pending header block clears.
Regression test
test/js/bun/http/serve-http3.test.ts
Adds a test for Expect: 100-continue on a cold HTTP/3 connection, asserting a 200 response with the correct body.

Docs formatting

Layer / File(s) Summary
Base64 guide and web-apis table
docs/guides/util/base64.mdx, docs/runtime/web-apis.mdx
Moves the legacy btoa()/atob() example into a fenced ts block inside the warning, and reformats the web-apis markdown table alignment.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#33040: Modifies the same docs/guides/util/base64.mdx file with Base64 documentation updates.

Suggested reviewers

  • cirospaciari
  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is clear, concise, and accurately summarizes the HTTP/3 100-continue cold-connection fix.
Description check ✅ Passed The description covers what changed and how it was verified, though it uses custom headings instead of the template's exact sections.
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.

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

@robobun

robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:56 AM PT - Jun 29th, 2026

❌ @robobun, your commit c67530a has 1 failures in Build #66814 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33083

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

bun-33083 --bun

@mintlify

mintlify Bot commented Jun 29, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jun 29, 2026, 3:04 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@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 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-issues sendBufferedHeaders once clear, header-only/FIN handled via deferredEndStream), but the number of interacting entry points and the vendored-lib patch put this outside what I'd auto-approve.

@robobun

robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

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.

  • darwin 26 aarch64 - test-bun: fails before any test runs with buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun' (it fetches the 23 MiB binary in ~109s and trips the 120s budget). Infra timeout on that agent, reproduced identically on #66805 and #66814; the darwin-aarch64-build-bun step that produces the artifact passed.
  • alpine 3.23 x64 - test-bun and alpine 3.23 x64-baseline - test-bun: both fail in test/js/node/test/parallel/test-net-connect-memleak.js (assert.strictEqual(collected, true) -> false !== true), a GC-timing memory-leak assertion on net.connect (TCP). This PR only touches HTTP/3/QUIC server code, so it never constructs the objects that test exercises; it's a flaky GC test on musl.

The change itself is verified: test/js/bun/http/serve-http3.test.ts ("Expect: 100-continue delivers the final response on a cold connection") fails on the unfixed build with HTTP3StreamReset and passes with the fix (fail-before/pass-after via the gate's git stash push -- src/ packages/), and the Linux x64 ASAN test lane is green. The lsquic patch applies cleanly at the pinned commit (the build re-fetches pristine lsquic and applies all five patches in order).

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

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.

@robobun robobun closed this Jul 1, 2026
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.serve HTTP/3: 100-continue interim response breaks the final response on a fresh QUIC connection

1 participant