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