Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/bun.js.zig
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ pub const Run = struct {
jsc.VirtualMachine.is_main_thread_vm = true;

bun.http.experimental_http2_client_from_cli = ctx.runtime_options.experimental_http2_fetch;
bun.http.experimental_http3_client_from_cli = ctx.runtime_options.experimental_http3_fetch;
doPreconnect(ctx.runtime_options.preconnect);

const callback = OpaqueWrap(Run, Run.start);
Expand Down Expand Up @@ -292,6 +293,7 @@ pub const Run = struct {
vm.transpiler.env.loadTracy();

bun.http.experimental_http2_client_from_cli = ctx.runtime_options.experimental_http2_fetch;
bun.http.experimental_http3_client_from_cli = ctx.runtime_options.experimental_http3_fetch;
doPreconnect(ctx.runtime_options.preconnect);

vm.main_is_html_entrypoint = (loader orelse vm.transpiler.options.loader(std.fs.path.extension(entry_path))) == .html;
Expand Down
1 change: 1 addition & 0 deletions src/cli.zig
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ pub const Command = struct {
} = .{},
preconnect: []const []const u8 = &[_][]const u8{},
experimental_http2_fetch: bool = false,
experimental_http3_fetch: bool = false,
dns_result_order: []const u8 = "verbatim",
/// `--expose-gc` makes `globalThis.gc()` available. Added for Node
/// compatibility.
Expand Down
2 changes: 2 additions & 0 deletions src/cli/Arguments.zig
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ pub const runtime_params_ = [_]ParamType{
clap.parseParam("--conditions <STR>... Pass custom conditions to resolve") catch unreachable,
clap.parseParam("--fetch-preconnect <STR>... Preconnect to a URL while code is loading") catch unreachable,
clap.parseParam("--experimental-http2-fetch Offer h2 in fetch() TLS ALPN. Same as BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT=1") catch unreachable,
clap.parseParam("--experimental-http3-fetch Honor Alt-Svc: h3 in fetch() and upgrade to HTTP/3. Same as BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP3_CLIENT=1") catch unreachable,
clap.parseParam("--max-http-header-size <INT> Set the maximum size of HTTP headers in bytes. Default is 16KiB") catch unreachable,
clap.parseParam("--dns-result-order <STR> Set the default order of DNS lookup results. Valid orders: verbatim (default), ipv4first, ipv6first") catch unreachable,
clap.parseParam("--expose-gc Expose gc() on the global object. Has no effect on Bun.gc().") catch unreachable,
Expand Down Expand Up @@ -880,6 +881,7 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
ctx.runtime_options.smol = args.flag("--smol");
ctx.runtime_options.preconnect = args.options("--fetch-preconnect");
ctx.runtime_options.experimental_http2_fetch = args.flag("--experimental-http2-fetch");
ctx.runtime_options.experimental_http3_fetch = args.flag("--experimental-http3-fetch");
ctx.runtime_options.expose_gc = args.flag("--expose-gc");

if (args.option("--console-depth")) |depth_str| {
Expand Down
2 changes: 2 additions & 0 deletions src/cli/test/parallel/runner.zig
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ fn buildWorkerArgv(arena: std.mem.Allocator, ctx: Command.Context) ![:null]?[*:0
try argv.append(arena, "--smol");
if (ctx.runtime_options.experimental_http2_fetch)
try argv.append(arena, "--experimental-http2-fetch");
if (ctx.runtime_options.experimental_http3_fetch)
try argv.append(arena, "--experimental-http3-fetch");
if (ctx.args.allow_addons == false)
try argv.append(arena, "--no-addons");
if (ctx.debug.macros == .disable)
Expand Down
1 change: 1 addition & 0 deletions src/cli/test_command.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1496,6 +1496,7 @@ pub const TestCommand = struct {
vm.preload = ctx.preloads;
vm.transpiler.options.rewrite_jest_for_tests = true;
bun.http.experimental_http2_client_from_cli = ctx.runtime_options.experimental_http2_fetch;
bun.http.experimental_http3_client_from_cli = ctx.runtime_options.experimental_http3_fetch;
vm.transpiler.options.env.behavior = .load_all_without_inlining;

const node_env_entry = try env_loader.map.getOrPutWithoutValue("NODE_ENV");
Expand Down
5 changes: 5 additions & 0 deletions src/env_var.zig
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,11 @@ pub const feature_flag = struct {
/// server selects it. Off by default while the client implementation
/// matures. `--experimental-http2-fetch` is the CLI equivalent.
pub const BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT = newFeatureFlag("BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT", .{});
/// Honor `Alt-Svc: h3` from fetch() responses: subsequent requests to the
/// same origin go over QUIC/HTTP-3 instead of TCP. Off by default while
/// the client implementation matures. `--experimental-http3-fetch` is the
/// CLI equivalent.
pub const BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP3_CLIENT = newFeatureFlag("BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP3_CLIENT", .{});
pub const BUN_FEATURE_FLAG_FORCE_IO_POOL = newFeatureFlag("BUN_FEATURE_FLAG_FORCE_IO_POOL", .{});
pub const BUN_FEATURE_FLAG_FORCE_WINDOWS_JUNCTIONS = newFeatureFlag("BUN_FEATURE_FLAG_FORCE_WINDOWS_JUNCTIONS", .{});
pub const BUN_INSTRUMENTS = newFeatureFlag("BUN_INSTRUMENTS", .{});
Expand Down
55 changes: 55 additions & 0 deletions src/http.zig
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ pub var async_http_id_monotonic: std.atomic.Value(u32) = std.atomic.Value(u32).i
/// Set once at startup from `--experimental-http2-fetch` (before the HTTP
/// thread spawns) and then only read on that thread, so no atomics needed.
pub var experimental_http2_client_from_cli: bool = false;
/// Set once at startup from `--experimental-http3-fetch`. Same threading
/// rules as the http2 flag.
pub var experimental_http3_client_from_cli: bool = false;

const MAX_REDIRECT_URL_LENGTH = 128 * 1024;

Expand Down Expand Up @@ -228,6 +231,28 @@ pub fn alpnOffer(client: *const HTTPClient) BoringSSL.SSL.AlpnOffer {
return if (client.flags.force_http2) .h2_only else .h1_or_h2;
}

/// Whether the experimental Alt-Svc-driven HTTP/3 upgrade is enabled at all
/// (CLI flag or env var). Used on its own to gate `H3.AltSvc.record` — a
/// response that arrived over a request shape h3 can't serve (proxy, sendfile,
/// `force_http1`) still carries an authoritative Alt-Svc for the origin.
pub fn h3AltSvcEnabled() bool {
return experimental_http3_client_from_cli or
bun.feature_flag.BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP3_CLIENT.get();
}

/// Whether this request shape is eligible to *use* a cached Alt-Svc h3
/// alternative (HTTPS, no proxy/unix-socket, no sendfile, not pinned to a
/// specific protocol). When true, `start_()` consults `H3.AltSvc.lookup`
/// before opening TCP.
pub fn canTryH3AltSvc(client: *const HTTPClient) bool {
if (client.flags.force_http1 or client.flags.force_http2) return false;
if (client.http_proxy != null) return false;
if (client.flags.is_preconnect_only) return false;
if (client.unix_socket_path.length() > 0) return false;
if (client.state.original_request_body == .sendfile) return false;
return h3AltSvcEnabled();
}

pub fn firstCall(
client: *HTTPClient,
comptime is_ssl: bool,
Expand Down Expand Up @@ -1180,6 +1205,28 @@ fn start_(this: *HTTPClient, comptime is_ssl: bool) void {
}
}

if (comptime is_ssl) {
// Opportunistic Alt-Svc upgrade: a previous response from this origin
// advertised `h3`, and the experimental flag is on. Don't touch
// `flags.force_http3` — that's the user's explicit `protocol:"http3"`
// choice and persists across redirects, whereas an Alt-Svc upgrade is
// per-origin and a cross-origin redirect must re-evaluate from h1.
// `doRedirectMultiplexed` resets `flags.protocol`, so the redirected
// request lands back here with `force_http3` still false and consults
// the cache for the new origin.
if (!this.flags.force_http3 and this.canTryH3AltSvc()) {
if (H3.AltSvc.lookup(this.url.hostname, this.url.getPortAuto())) |alt_port| {
if (H3.ClientContext.getOrCreate(http_thread.loop.loop)) |ctx| {
if (!ctx.connect(this, this.url.hostname, alt_port)) {
this.fail(error.ConnectionRefused);
}
return;
}
// engine init failed: fall through to TCP
}
Comment thread
claude[bot] marked this conversation as resolved.
Comment on lines +1217 to +1226

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.

🔴 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:

  1. The Alt-Svc lookup at line 1211 is gated on !this.flags.force_http3, so it is skippedh3_port stays at this.url.getPortAuto() (the redirect target's origin port), and no per-origin check is done for the new host.
  2. Line 1219 if (this.flags.force_http3) is true, so the request is unconditionally routed onto the QUIC engine via ctx.connect(this, this.url.hostname, h3_port).
  3. If the redirect target is http://, start_(false) is called and lines 1220-1222 immediately fail(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.com previously advertised Alt-Svc: h3, so the request goes over QUIC and gets a 302 Location: https://b.com/.... The follow-up is forced onto QUIC against b.com:443 even though b.com never advertised h3. If b.com has no QUIC listener, the user's fetch() fails with HTTP3HandshakeFailed.
  • Same-origin redirect with alt-port ≠ origin-port: a.com:443 advertised Alt-Svc: h3=":8443". The first leg correctly dials QUIC on :8443 (per the eef6351 fix). The 302 → /other re-enters start_(), the !force_http3 gate skips the lookup, h3_port = getPortAuto() = 443, and ctx.connect dials QUIC on :443 where nothing is listening.
  • https→http redirect: start_(false) hits if (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)

  1. --experimental-http3-fetch is on. A previous response from https://a.com carried Alt-Svc: h3=":443", so AltSvc.cache["a.com:443"] = { h3_port: 443, ... }.
  2. fetch("https://a.com/x")start_(true): force_http3 is false, canTryH3AltSvc() is true, lookup("a.com", 443) returns 443, so force_http3 = true and the request goes over QUIC.
  3. a.com responds 302 Location: https://b.com/y. ClientSession.deliverdoRedirectH3()doRedirectMultiplexed().
  4. doRedirectMultiplexed() sets flags.protocol = .http1_1, flags.proxy_tunneling = false, calls this.start(...). force_http3 is still true.
  5. start() sees isHTTPS() for https://b.com/ystart_(true).
  6. Line 1211: !this.flags.force_http3 is false → lookup skipped. h3_port = b.com's port = 443.
  7. Line 1219: force_http3 is true → ctx.connect(this, "b.com", 443) opens QUIC to b.com:443.
  8. b.com has no UDP/QUIC listener on 443 → handshake fails → fetch() rejects with HTTP3HandshakeFailed, even though the user never asked for h3 and b.com would 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 on use_h3 at line 1219), so the Alt-Svc choice is re-evaluated per start_() call; or
  • Add a separate flags.alt_svc_h3 bit that doRedirectMultiplexed() clears alongside protocol/proxy_tunneling, so the explicit protocol:"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.

}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (this.flags.force_http3) {
if (comptime !is_ssl) {
this.fail(error.HTTP3Unsupported);
Expand Down Expand Up @@ -2803,6 +2850,14 @@ pub fn handleResponseMetadata(
hashHeaderConst("Last-Modified") => {
pretend_304 = this.flags.force_last_modified and response.status_code > 199 and response.status_code < 300 and this.if_modified_since.len > 0 and strings.eql(this.if_modified_since, header.value);
},
hashHeaderConst("Alt-Svc") => {
// Record regardless of *this* request's shape — a future
// request to the same origin may be h3-eligible even if this
// one was pinned/proxied/sendfile.
if (this.isHTTPS() and this.unix_socket_path.length() == 0 and h3AltSvcEnabled()) {
H3.AltSvc.record(this.url.hostname, this.url.getPortAuto(), header.value);
}
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.

else => {},
}
Expand Down
Loading
Loading