From efea81071f6ffd7160ae235a95dcfdbdd2c2ff2a Mon Sep 17 00:00:00 2001 From: Umais-Adeed Date: Wed, 11 Nov 2026 09:35:32 +0500 Subject: [PATCH] fix(server): T3 Connect remembers when the relay client falls back to HTTP/2 on its own When cloudflared, started with `auto`, gave up on QUIC and registered over HTTP/2 by itself, T3 recorded the protocol but not the fallback, so every later start went back to QUIC and spent minutes relearning it. Persist that fallback the same way as T3's own switch, for the same 24-hour window. Fixes #33928 Co-Authored-By: Claude Opus 5.5 --- .../src/cloud/ManagedEndpointRuntime.test.ts | 51 +++++++++++++++++++ .../src/cloud/ManagedEndpointRuntime.ts | 24 ++++++--- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 2916c8f96538..ba6d4a635c28 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -516,6 +516,57 @@ describe("CloudManagedEndpointRuntime", () => { }), ); + it.effect("remembers an HTTP/2 fallback the relay client made on its own", () => + Effect.gen(function* () { + const context = yield* Layer.build( + ServerSecretStore.layer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-transport-test-" })), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ); + const secretStoreLayer = Layer.succeed( + ServerSecretStore.ServerSecretStore, + context.pipe(Context.get(ServerSecretStore.ServerSecretStore)), + ); + const start = (spawner: ReturnType) => + buildCloudManagedEndpointRuntime( + spawner, + layerRelayClientAvailable, + unusedHttpClient, + secretStoreLayer, + ); + const registered = (protocol: string) => + `2026-11-10T01:43:17Z INF Registered tunnel connection connIndex=0 connection=c1 event=0 ip=198.41.192.7 location=fra08 protocol=${protocol}\n`; + + yield* Effect.gen(function* () { + const fixture = yield* makeTransportSpawner(); + const runtime = yield* start(fixture.spawner); + yield* runtime.applyConfig(tunnelConfig); + yield* fixture.emit(0, registered("quic")); + yield* Stream.runHead(runtime.tunnelConnected); + }).pipe(Effect.scoped); + yield* Effect.gen(function* () { + const fixture = yield* makeTransportSpawner(); + const runtime = yield* start(fixture.spawner); + yield* runtime.applyConfig(tunnelConfig); + expect(fixture.spawned[0]!.args).not.toContain("--protocol"); + // cloudflared, started with `auto`, gave up on QUIC itself. + yield* fixture.emit(0, registered("http2")); + yield* Stream.runHead(runtime.tunnelConnected); + // The connector already runs on HTTP/2, so it is not restarted. + expect(fixture.spawned).toHaveLength(1); + expect(fixture.killed).toEqual([]); + }).pipe(Effect.scoped); + + yield* Effect.gen(function* () { + const fixture = yield* makeTransportSpawner(); + const runtime = yield* start(fixture.spawner); + yield* runtime.applyConfig(tunnelConfig); + expect(fixture.spawned[0]!.args.slice(-3)).toEqual(["--protocol", "http2", "run"]); + }).pipe(Effect.scoped); + }), + ); + it.effect( "ignores invalid or unreadable fallback state and still falls back if persistence fails", () => diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 6e86f87ffef9..c018bbf6c2f1 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -571,10 +571,7 @@ export const make = Effect.gen(function* () { } }); - const switchToHttp2 = Effect.fnUntraced(function* (connector: ActiveConnector) { - const active = yield* Ref.get(activeRef); - const desiredConfig = yield* Ref.get(desiredConfigRef); - if ((yield* Ref.get(http2Ref)) || active !== connector || !desiredConfig) return; + const rememberHttp2Fallback = Effect.fnUntraced(function* () { yield* Ref.set(http2Ref, true); yield* secrets .set( @@ -586,6 +583,13 @@ export const make = Effect.gen(function* () { .pipe( Effect.catch(() => Effect.logWarning("Could not persist the relay client HTTP/2 fallback")), ); + }); + + const switchToHttp2 = Effect.fnUntraced(function* (connector: ActiveConnector) { + const active = yield* Ref.get(activeRef); + const desiredConfig = yield* Ref.get(desiredConfigRef); + if ((yield* Ref.get(http2Ref)) || active !== connector || !desiredConfig) return; + yield* rememberHttp2Fallback(); yield* Effect.logWarning("Relay client keeps losing QUIC connections; switching to HTTP/2", { pid: Number(connector.child.pid), tunnelId: connector.config.tunnelId, @@ -630,11 +634,18 @@ export const make = Effect.gen(function* () { output, }; switch (classifyRelayClientOutput(line)) { - case "connected": + case "connected": { rejectedRegistrations = 0; + const protocol = RELAY_CLIENT_PROTOCOL.exec(line)?.[1] ?? null; return Deferred.succeed(connector.registered, undefined).pipe( + Effect.andThen(Ref.set(connector.protocol, protocol)), + // cloudflared started with `auto` fell back on its own; later starts skip QUIC too. Effect.andThen( - Ref.set(connector.protocol, RELAY_CLIENT_PROTOCOL.exec(line)?.[1] ?? null), + protocol === "http2" && !transportChosenByUser + ? Ref.get(http2Ref).pipe( + Effect.flatMap((http2) => (http2 ? Effect.void : rememberHttp2Fallback())), + ) + : Effect.void, ), Effect.andThen( Effect.logInfo("Relay client tunnel connection registered", attributes), @@ -652,6 +663,7 @@ export const make = Effect.gen(function* () { ), Effect.asVoid, ); + } case "unregistered": return Effect.logInfo("Relay client tunnel connection unregistered", attributes); case "warning":