http3: split H3Client into h3_client/ + Alt-Svc upgrade flag#29863
Conversation
Mirrors the h2_client/ layout so the file shape matches across the two
multiplexed transports:
H3Client.zig - thin entry point: re-exports + live counters
h3_client/
Stream.zig - one in-flight request
ClientSession.zig - one QUIC connection (pool entry, deliver loop)
ClientContext.zig - process-global lsquic engine + session registry
encode.zig - request header/body framing onto a quic.Stream
callbacks.zig - lsquic -> Zig glue (on_hsk_done / on_stream_*)
PendingConnect.zig - DNS-pending connect resolution (moved from h3/)
No functional change; pure code motion plus the visibility tweaks needed
for the cross-file calls (fail/retryOrFail/deliver are now pub on
ClientSession, callback registration is bundled into callbacks.register).
|
Updated 4:45 PM PT - Apr 28th, 2026
❌ @Jarred-Sumner, your commit a94aa99 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 29863That installs a local version of the PR into your bun-29863 --bun |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughRefactors the in-file HTTP/3 client into new Changes
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
LGTM — pure code motion splitting H3Client.zig into an h3_client/ module to mirror the h2_client/ layout.
Extended reasoning...
Overview
This PR splits the monolithic src/http/H3Client.zig (~660 lines) into a directory module src/http/h3_client/ with six files (Stream.zig, ClientSession.zig, ClientContext.zig, encode.zig, callbacks.zig, PendingConnect.zig), leaving H3Client.zig as a thin re-export façade plus the atomic live-object counters and TestingAPIs. PendingConnect.zig is a pure rename from src/http/h3/ → src/http/h3_client/ with no content change.
I diffed the moved bodies line-by-line against the deletions and they are byte-identical modulo: (a) fn → pub fn on fail/retryOrFail/deliver/writeRequest/drainSendBody so they're reachable across the new file boundaries, (b) the eight qctx.onX(...) registrations folded into callbacks.register(qctx) which contains exactly the same eight calls in the same order, (c) ClientContext.instance → ClientContext.get() in onConnClose (get() is a one-line accessor returning instance), (d) live_sessions/live_streams → H3.live_sessions/H3.live_streams since the counters stayed in the parent file, and (e) writeRequest's receiver renamed this → session with the single use (this.hostname) updated accordingly. A few doc comments were expanded (e.g. the RefCount holder list) but no logic changed.
Security risks
None. This is a file reorganization of the experimental HTTP/3 fetch client; no auth, crypto, parsing, or trust-boundary code is altered. The QPACK header building, body draining, and lsquic callback handling are moved verbatim.
Level of scrutiny
Low–medium. The change is mechanical and mirrors the already-landed h2_client/ split (#29809), so the target shape is established. The HTTP/3 client itself is an experimental feature gated behind protocol: "http3" (landed in #29795 a few commits ago). The author reports all three HTTP/3 test suites (115 tests) and zig:check-all pass; the bug-hunting system found nothing.
Other factors
No CODEOWNERS cover src/http/. No outstanding reviewer comments. The old src/http/h3/ directory is gone and nothing in the tree references the old h3/PendingConnect path. Public surface (H3.Stream, H3.ClientSession, H3.ClientContext, H3.PendingConnect) is preserved via re-exports, so external callers are unaffected.
Mirrors the --experimental-http2-fetch / BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT
pair. When enabled, fetch() records Alt-Svc: h3 from HTTPS responses into a
small HTTP-thread-local origin cache (h3_client/AltSvc.zig) and routes the
next request to that origin over QUIC instead of TCP.
- AltSvc.parse(): RFC 7838 field-value parser returning {port, ma} for the
first same-host h3 alternative, or error.Clear for the 'clear' token.
- AltSvc.record/lookup(): origin-keyed cache with ma-based expiry.
- canTryH3AltSvc() gate in http.zig matching the canOfferH2() shape; consulted
in start_() before opening TCP and in handleResponseMetadata when reading
the header.
- fetchH3Internals.liveCounts exposed via bun:internal-for-testing so the
test can prove the second fetch actually opened a QUIC session.
(TestingAPIs.quicLiveCounts — distinct name needed because
generate-js2native mangles digits, so H2Client/H3Client collide.)
Tests cover env var, CLI flag, and off-by-default.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/http.zig`:
- Around line 1200-1210: H3 Alt-Svc lookup currently discards the returned
advertised port; update the logic around H3.AltSvc.lookup(this.url.hostname,
this.url.getPortAuto()) so that when it returns a port you store it (e.g., in a
local variable or a new field like alt_svc_port) and set this.flags.force_http3
as before, then use that cached port instead of this.url.getPortAuto() when
opening the QUIC connection (the connect call that initiates HTTP/3); ensure the
lookup is still conditional on canTryH3AltSvc() and that fallback semantics
remain unchanged.
In `@src/http/h3_client/AltSvc.zig`:
- Around line 123-125: Change the expiry check to expire entries on the boundary
by using >= instead of > when comparing std.time.timestamp() and rec.expires_at
in AltSvc.zig; update the conditional that currently reads "if
(std.time.timestamp() > rec.expires_at)" to use ">=" so that when the current
timestamp equals rec.expires_at (e.g., ma=0) the code will call
cache.fetchRemove(k) and free the key via bun.default_allocator.free(kv.key) and
then return null, ensuring immediate expiration; adjust only this comparison in
the block that references rec.expires_at, cache.fetchRemove, and
bun.default_allocator.free.
In `@src/http/H3Client.zig`:
- Around line 24-34: The comment above the TestingAPIs struct references
TestingAPIs.liveCounts but the exported function is named quicLiveCounts; update
the comment to reference quicLiveCounts (or "quicLiveCounts" /
"TestingAPIs.quicLiveCounts") so it correctly documents how JS reads
live_sessions and live_streams; locate the comment near the declarations of
live_sessions, live_streams and the TestingAPIs struct and replace
"TestingAPIs.liveCounts" with the correct identifier
"TestingAPIs.quicLiveCounts".
🪄 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: fdc529aa-4af9-414d-8c01-5a74dbeb7fc1
📒 Files selected for processing (11)
src/bun.js.zigsrc/cli.zigsrc/cli/Arguments.zigsrc/cli/test/parallel/runner.zigsrc/cli/test_command.zigsrc/env_var.zigsrc/http.zigsrc/http/H3Client.zigsrc/http/h3_client/AltSvc.zigsrc/js/internal-for-testing.tstest/js/web/fetch/fetch-http3-client.test.ts
- start_(): use the alt-authority port from AltSvc.lookup() for the QUIC connect instead of always dialing the origin port (RFC 7838 §3 allows the alternative to listen elsewhere; SNI/cert validation still use the origin hostname). - AltSvc.lookup(): expire on >= so ma=0 is treated as immediately stale. - H3Client.zig: doc comment now references quicLiveCounts.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/http.zig (1)
1211-1233:⚠️ Potential issue | 🟠 MajorKeep Alt-Svc as a soft upgrade, not a hard protocol pin.
At Line 1213 the cache hit flips
this.flags.force_http3before we know the QUIC path is usable. That makes the failure branches at Lines 1228-1233 behave like an explicitprotocol: "http3"request: a stale/unusable Alt-Svc entry now fails the fetch instead of falling back to the normal TCP connect. Please track the Alt-Svc attempt separately and only hard-fail when HTTP/3 was explicitly requested.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/http.zig` around lines 1211 - 1233, Currently a successful H3.AltSvc.lookup flips this.flags.force_http3 before we know QUIC will work; change this to keep Alt-Svc as a soft upgrade by not mutating this.flags.force_http3 on lookup success. Instead, store the alt port in a local (e.g., h3_alt_port) and a local bool (e.g., attempted_alt_svc) and use that to try ctx.connect(this, this.url.hostname, h3_alt_port) without setting force_http3; only set force_http3 (or hard-fail) when the request explicitly asked for HTTP/3, or if connect succeeds and you want to persist the preference. Ensure the existing failure branches that call this.fail(error.HTTP3Unsupported) or this.fail(error.ConnectionRefused) only execute when the request explicitly required HTTP/3, otherwise fall back to the normal TCP path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@src/http.zig`:
- Around line 1211-1233: Currently a successful H3.AltSvc.lookup flips
this.flags.force_http3 before we know QUIC will work; change this to keep
Alt-Svc as a soft upgrade by not mutating this.flags.force_http3 on lookup
success. Instead, store the alt port in a local (e.g., h3_alt_port) and a local
bool (e.g., attempted_alt_svc) and use that to try ctx.connect(this,
this.url.hostname, h3_alt_port) without setting force_http3; only set
force_http3 (or hard-fail) when the request explicitly asked for HTTP/3, or if
connect succeeds and you want to persist the preference. Ensure the existing
failure branches that call this.fail(error.HTTP3Unsupported) or
this.fail(error.ConnectionRefused) only execute when the request explicitly
required HTTP/3, otherwise fall back to the normal TCP path.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 715ee397-5d2e-4273-9b50-631f6eb0b800
📒 Files selected for processing (3)
src/http.zigsrc/http/H3Client.zigsrc/http/h3_client/AltSvc.zig
ban-words.test.ts forbids std.StringHashMapUnmanaged in favour of the faster-eql wrapper.
| if (!this.flags.force_http3 and this.canTryH3AltSvc()) { | ||
| if (H3.AltSvc.lookup(this.url.hostname, h3_port)) |alt_port| { | ||
| this.flags.force_http3 = true; | ||
| h3_port = alt_port; | ||
| } |
There was a problem hiding this comment.
🔴 Setting this.flags.force_http3 = true here for an opportunistic Alt-Svc upgrade persists across redirects: doRedirectMultiplexed() resets flags.protocol and flags.proxy_tunneling but never clears force_http3, so the redirect leg re-enters start_() with it still set. A cross-origin redirect target gets forced onto QUIC even though it never advertised h3 (plain fetch() fails with HTTP3HandshakeFailed), a same-origin alt-port redirect skips the !force_http3-gated lookup and dials QUIC on the origin port instead of the advertised alt-port, and an https→http redirect hard-fails with HTTP3Unsupported. Either clear force_http3 in doRedirectMultiplexed() (when it was set opportunistically rather than via protocol:"http3"), or use a one-shot local instead of persisting the decision into flags.
Extended reasoning...
What the bug is
The new Alt-Svc upgrade path at src/http.zig:1211-1215 writes the opportunistic h3 decision into this.flags.force_http3:
if (!this.flags.force_http3 and this.canTryH3AltSvc()) {
if (H3.AltSvc.lookup(this.url.hostname, h3_port)) |alt_port| {
this.flags.force_http3 = true;
h3_port = alt_port;
}
}force_http3 is a persistent HTTPClient flag. Grepping src/http.zig for force_http3 shows only the field decl (line 612), the read at 1211, the write at 1213, and the read at 1219 — it is never cleared anywhere. doRedirectMultiplexed() (lines 2321-2348) resets flags.protocol = .http1_1 and flags.proxy_tunneling = false and then calls this.start(...), which re-enters start_() based on the new URL's isHTTPS(). start() (lines 1155-1166) does not touch force_http3 either. So once line 1213 fires, every subsequent redirect leg sees force_http3 == true.
The specific code path
On the redirect leg, start_() runs again with the flag still set:
- The Alt-Svc lookup at line 1211 is gated on
!this.flags.force_http3, so it is skipped —h3_portstays atthis.url.getPortAuto()(the redirect target's origin port), and no per-origin check is done for the new host. - Line 1219
if (this.flags.force_http3)is true, so the request is unconditionally routed onto the QUIC engine viactx.connect(this, this.url.hostname, h3_port). - If the redirect target is
http://,start_(false)is called and lines 1220-1222 immediatelyfail(error.HTTP3Unsupported).
Why existing code doesn't prevent it
Before this PR, force_http3 was only ever set by the user via fetch(url, {protocol: "http3"}) — the field's doc comment still says exactly that. In that case, stickiness across redirects is the user's explicit choice (they asked for h3-or-fail). This PR repurposes the same flag for an opportunistic, per-origin decision derived from a cached Alt-Svc header. That decision is only valid for the origin it was looked up against (RFC 7838 §2: an alternative service is associated with a specific origin), not for an arbitrary redirect target. doRedirectMultiplexed() was never updated to account for the new write site.
Impact
Three concrete failure modes for a plain fetch() with --experimental-http3-fetch enabled and no protocol: hint:
- Cross-origin redirect (very common: short-URL services, OAuth, CDN bounces):
https://a.compreviously advertisedAlt-Svc: h3, so the request goes over QUIC and gets a302 Location: https://b.com/.... The follow-up is forced onto QUIC againstb.com:443even thoughb.comnever advertised h3. Ifb.comhas no QUIC listener, the user'sfetch()fails withHTTP3HandshakeFailed. - Same-origin redirect with alt-port ≠ origin-port:
a.com:443advertisedAlt-Svc: h3=":8443". The first leg correctly dials QUIC on:8443(per the eef6351 fix). The302 → /otherre-entersstart_(), the!force_http3gate skips the lookup,h3_port = getPortAuto() = 443, andctx.connectdials QUIC on:443where nothing is listening. - https→http redirect:
start_(false)hitsif (this.flags.force_http3) { if (!is_ssl) fail(HTTP3Unsupported) }, so an opportunistically-upgraded request hard-fails on a downgrade redirect that would have worked fine over HTTP/1.1.
Step-by-step proof (cross-origin case)
--experimental-http3-fetchis on. A previous response fromhttps://a.comcarriedAlt-Svc: h3=":443", soAltSvc.cache["a.com:443"] = { h3_port: 443, ... }.fetch("https://a.com/x")→start_(true):force_http3is false,canTryH3AltSvc()is true,lookup("a.com", 443)returns443, soforce_http3 = trueand the request goes over QUIC.a.comresponds302 Location: https://b.com/y.ClientSession.deliver→doRedirectH3()→doRedirectMultiplexed().doRedirectMultiplexed()setsflags.protocol = .http1_1,flags.proxy_tunneling = false, callsthis.start(...).force_http3is stilltrue.start()seesisHTTPS()forhttps://b.com/y→start_(true).- Line 1211:
!this.flags.force_http3is false → lookup skipped.h3_port = b.com's port = 443. - Line 1219:
force_http3is true →ctx.connect(this, "b.com", 443)opens QUIC tob.com:443. b.comhas no UDP/QUIC listener on 443 → handshake fails →fetch()rejects withHTTP3HandshakeFailed, even though the user never asked for h3 andb.comwould have served the request fine over TCP.
How to fix
Don't persist the opportunistic decision into flags.force_http3. Either:
- Use a one-shot local (e.g.,
var use_h3 = this.flags.force_http3; ... if (lookup(...)) |p| { use_h3 = true; h3_port = p; }and branch onuse_h3at line 1219), so the Alt-Svc choice is re-evaluated perstart_()call; or - Add a separate
flags.alt_svc_h3bit thatdoRedirectMultiplexed()clears alongsideprotocol/proxy_tunneling, so the explicitprotocol:"http3"semantics (sticky) stay distinct from the opportunistic ones (per-origin).
The first option also fixes the alt-port-on-redirect case automatically, since the lookup runs again against the redirect target.
flags.force_http3 is the user's explicit protocol:"http3" choice and survives doRedirectMultiplexed(). An opportunistic Alt-Svc upgrade is per-origin, so a cross-origin redirect must re-evaluate from h1 — set up the QUIC connect inline instead of toggling the flag, so the redirected start_() consults the cache for the new origin (and falls through to TCP when there's no entry).
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/http/h3_client/AltSvc.zig`:
- Around line 85-87: The helper `key` currently masks formatting failures by
returning `catch buf`, which can yield an invalid/non-canonical slice; change
`fn key(buf: []u8, hostname: []const u8, port: u16) []const u8` to a fallible
signature (e.g., return an error union like `?[]const u8` or `![]const u8`) and
remove the `catch buf` fallback so `std.fmt.bufPrint` errors propagate. Then
update the call sites in `record` and `lookup` to handle the error (propagate it
upward or return a meaningful error) instead of relying on a bogus key.
Reference `std.fmt.bufPrint`, `key`, and the `record`/`lookup` functions when
making these changes so the formatting failure is surfaced and handled properly.
- Around line 83-113: The Alt-Svc cache (cache) grows unbounded because record()
always inserts new origins; modify record() to enforce a maximum size and
opportunistically sweep expired entries when inserting: implement a configurable
MAX_ALT_SVC_ENTRIES constant, check cache.count() (or track a simple counter)
before inserting and if the cap is exceeded run a short bounded sweep through
cache to remove expired entries (use the same expires_at logic as
lookup/expiry), and if still over cap evict the oldest/least-recent/any expired
entry (e.g., by checking timestamps on values) before calling getOrPut; keep all
changes inside record(), reusing key(), parse(), fetchRemove(), getOrPut() and
preserves existing clear behavior.
🪄 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: 5a9d60e3-11ec-4bba-9b48-9d4610b97b11
📒 Files selected for processing (1)
src/http/h3_client/AltSvc.zig
| fn key(buf: []u8, hostname: []const u8, port: u16) []const u8 { | ||
| return std.fmt.bufPrint(buf, "{s}:{d}", .{ hostname, port }) catch buf; | ||
| } |
There was a problem hiding this comment.
Avoid returning a fallback key slice from key() on formatting failure.
catch buf can return a non-canonical/undefined key slice if bufPrint ever fails. Make this helper fallible and handle the error at call sites (record/lookup) instead of producing a fallback key.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/http/h3_client/AltSvc.zig` around lines 85 - 87, The helper `key`
currently masks formatting failures by returning `catch buf`, which can yield an
invalid/non-canonical slice; change `fn key(buf: []u8, hostname: []const u8,
port: u16) []const u8` to a fallible signature (e.g., return an error union like
`?[]const u8` or `![]const u8`) and remove the `catch buf` fallback so
`std.fmt.bufPrint` errors propagate. Then update the call sites in `record` and
`lookup` to handle the error (propagate it upward or return a meaningful error)
instead of relying on a bogus key. Reference `std.fmt.bufPrint`, `key`, and the
`record`/`lookup` functions when making these changes so the formatting failure
is surfaced and handled properly.
- record() sweeps expired entries when the cap is hit and refuses the insert if still full, so memory stays bounded for long-lived processes hitting many distinct origins. - key()'s bufPrint cannot overflow given the 256-byte hostname guard; catch unreachable instead of returning an undefined slice.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/http.zig`:
- Around line 2845-2849: The code currently skips recording Alt-Svc when
canTryH3AltSvc() is false, which prevents learning H3 alternatives from HTTP/1.1
or HTTP/2 responses; change the hashHeaderConst("Alt-Svc") handler to call
H3.AltSvc.record(this.url.hostname, this.url.getPortAuto(), header.value)
whenever this.isHTTPS() is true (remove the canTryH3AltSvc() gate) so responses
always update the Alt-Svc cache even if the original request wouldn’t
immediately use H3; keep the existing hostname/port usage as-is to locate the
record call.
🪄 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: fff82ad2-f7b1-4c63-a217-23df1983ced3
📒 Files selected for processing (1)
src/http.zig
Recording an Alt-Svc header shouldn't depend on whether *this* request was h3-eligible (force_http1, proxy, sendfile, etc.) — the header is authoritative for the origin and a future request may be eligible. h3AltSvcEnabled() now checks only the feature flag; canTryH3AltSvc() adds the per-request shape constraints and is used only on the lookup side in start_().
…#29863) Two related changes to the HTTP/3 client. ### 1. Split `H3Client.zig` into `h3_client/` (no functional change) Mirrors the `h2_client/` layout so the file shape matches across the two multiplexed transports: ``` H3Client.zig - thin entry point: re-exports + live counters h3_client/ Stream.zig - one in-flight request ClientSession.zig - one QUIC connection (pool entry, deliver loop) ClientContext.zig - process-global lsquic engine + session registry encode.zig - request header/body framing onto a quic.Stream callbacks.zig - lsquic -> Zig glue (on_hsk_done / on_stream_*) PendingConnect.zig - DNS-pending connect resolution (moved from h3/) ``` ### 2. `--experimental-http3-fetch` / `BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP3_CLIENT` Mirrors the `--experimental-http2-fetch` pair. When enabled, `fetch()` records `Alt-Svc: h3` from HTTPS responses into a small HTTP-thread-local origin cache (`h3_client/AltSvc.zig`, RFC 7838) and routes the next request to that origin over QUIC instead of TCP. - `AltSvc.parse()` — field-value parser returning `{port, ma}` for the first same-host `h3` alternative, or `error.Clear` for the `clear` token. - `AltSvc.record/lookup()` — origin-keyed cache with `ma`-based expiry. - `canTryH3AltSvc()` gate matching `canOfferH2()`; consulted in `start_()` before opening TCP and in `handleResponseMetadata` when reading the header. - `fetchH3Internals.liveCounts` exposed via `bun:internal-for-testing` so tests can prove the second fetch actually opened a QUIC session. Tested locally: - `fetch-http3-client.test.ts` — 48 pass (incl. 3 new Alt-Svc tests covering env var, CLI flag, and off-by-default) - `fetch-http3-adversarial.test.ts` — 26 pass - `serve-http3.test.ts` — 44 pass - `zig:check-all` — all targets
Two related changes to the HTTP/3 client.
1. Split
H3Client.zigintoh3_client/(no functional change)Mirrors the
h2_client/layout so the file shape matches across the two multiplexed transports:2.
--experimental-http3-fetch/BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP3_CLIENTMirrors the
--experimental-http2-fetchpair. When enabled,fetch()recordsAlt-Svc: h3from HTTPS responses into a small HTTP-thread-local origin cache (h3_client/AltSvc.zig, RFC 7838) and routes the next request to that origin over QUIC instead of TCP.AltSvc.parse()— field-value parser returning{port, ma}for the first same-hosth3alternative, orerror.Clearfor thecleartoken.AltSvc.record/lookup()— origin-keyed cache withma-based expiry.canTryH3AltSvc()gate matchingcanOfferH2(); consulted instart_()before opening TCP and inhandleResponseMetadatawhen reading the header.fetchH3Internals.liveCountsexposed viabun:internal-for-testingso tests can prove the second fetch actually opened a QUIC session.Tested locally:
fetch-http3-client.test.ts— 48 pass (incl. 3 new Alt-Svc tests covering env var, CLI flag, and off-by-default)fetch-http3-adversarial.test.ts— 26 passserve-http3.test.ts— 44 passzig:check-all— all targets