Skip to content

Commit efea810

Browse files
Umais-Adeedclaude
andcommitted
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 <[email protected]>
1 parent 0f13d2c commit efea810

2 files changed

Lines changed: 69 additions & 6 deletions

File tree

‎apps/server/src/cloud/ManagedEndpointRuntime.test.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,57 @@ describe("CloudManagedEndpointRuntime", () => {
516516
}),
517517
);
518518

519+
it.effect("remembers an HTTP/2 fallback the relay client made on its own", () =>
520+
Effect.gen(function* () {
521+
const context = yield* Layer.build(
522+
ServerSecretStore.layer.pipe(
523+
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-transport-test-" })),
524+
Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
525+
),
526+
);
527+
const secretStoreLayer = Layer.succeed(
528+
ServerSecretStore.ServerSecretStore,
529+
context.pipe(Context.get(ServerSecretStore.ServerSecretStore)),
530+
);
531+
const start = (spawner: ReturnType<typeof ChildProcessSpawner.make>) =>
532+
buildCloudManagedEndpointRuntime(
533+
spawner,
534+
layerRelayClientAvailable,
535+
unusedHttpClient,
536+
secretStoreLayer,
537+
);
538+
const registered = (protocol: string) =>
539+
`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`;
540+
541+
yield* Effect.gen(function* () {
542+
const fixture = yield* makeTransportSpawner();
543+
const runtime = yield* start(fixture.spawner);
544+
yield* runtime.applyConfig(tunnelConfig);
545+
yield* fixture.emit(0, registered("quic"));
546+
yield* Stream.runHead(runtime.tunnelConnected);
547+
}).pipe(Effect.scoped);
548+
yield* Effect.gen(function* () {
549+
const fixture = yield* makeTransportSpawner();
550+
const runtime = yield* start(fixture.spawner);
551+
yield* runtime.applyConfig(tunnelConfig);
552+
expect(fixture.spawned[0]!.args).not.toContain("--protocol");
553+
// cloudflared, started with `auto`, gave up on QUIC itself.
554+
yield* fixture.emit(0, registered("http2"));
555+
yield* Stream.runHead(runtime.tunnelConnected);
556+
// The connector already runs on HTTP/2, so it is not restarted.
557+
expect(fixture.spawned).toHaveLength(1);
558+
expect(fixture.killed).toEqual([]);
559+
}).pipe(Effect.scoped);
560+
561+
yield* Effect.gen(function* () {
562+
const fixture = yield* makeTransportSpawner();
563+
const runtime = yield* start(fixture.spawner);
564+
yield* runtime.applyConfig(tunnelConfig);
565+
expect(fixture.spawned[0]!.args.slice(-3)).toEqual(["--protocol", "http2", "run"]);
566+
}).pipe(Effect.scoped);
567+
}),
568+
);
569+
519570
it.effect(
520571
"ignores invalid or unreadable fallback state and still falls back if persistence fails",
521572
() =>

‎apps/server/src/cloud/ManagedEndpointRuntime.ts‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -571,10 +571,7 @@ export const make = Effect.gen(function* () {
571571
}
572572
});
573573

574-
const switchToHttp2 = Effect.fnUntraced(function* (connector: ActiveConnector) {
575-
const active = yield* Ref.get(activeRef);
576-
const desiredConfig = yield* Ref.get(desiredConfigRef);
577-
if ((yield* Ref.get(http2Ref)) || active !== connector || !desiredConfig) return;
574+
const rememberHttp2Fallback = Effect.fnUntraced(function* () {
578575
yield* Ref.set(http2Ref, true);
579576
yield* secrets
580577
.set(
@@ -586,6 +583,13 @@ export const make = Effect.gen(function* () {
586583
.pipe(
587584
Effect.catch(() => Effect.logWarning("Could not persist the relay client HTTP/2 fallback")),
588585
);
586+
});
587+
588+
const switchToHttp2 = Effect.fnUntraced(function* (connector: ActiveConnector) {
589+
const active = yield* Ref.get(activeRef);
590+
const desiredConfig = yield* Ref.get(desiredConfigRef);
591+
if ((yield* Ref.get(http2Ref)) || active !== connector || !desiredConfig) return;
592+
yield* rememberHttp2Fallback();
589593
yield* Effect.logWarning("Relay client keeps losing QUIC connections; switching to HTTP/2", {
590594
pid: Number(connector.child.pid),
591595
tunnelId: connector.config.tunnelId,
@@ -630,11 +634,18 @@ export const make = Effect.gen(function* () {
630634
output,
631635
};
632636
switch (classifyRelayClientOutput(line)) {
633-
case "connected":
637+
case "connected": {
634638
rejectedRegistrations = 0;
639+
const protocol = RELAY_CLIENT_PROTOCOL.exec(line)?.[1] ?? null;
635640
return Deferred.succeed(connector.registered, undefined).pipe(
641+
Effect.andThen(Ref.set(connector.protocol, protocol)),
642+
// cloudflared started with `auto` fell back on its own; later starts skip QUIC too.
636643
Effect.andThen(
637-
Ref.set(connector.protocol, RELAY_CLIENT_PROTOCOL.exec(line)?.[1] ?? null),
644+
protocol === "http2" && !transportChosenByUser
645+
? Ref.get(http2Ref).pipe(
646+
Effect.flatMap((http2) => (http2 ? Effect.void : rememberHttp2Fallback())),
647+
)
648+
: Effect.void,
638649
),
639650
Effect.andThen(
640651
Effect.logInfo("Relay client tunnel connection registered", attributes),
@@ -652,6 +663,7 @@ export const make = Effect.gen(function* () {
652663
),
653664
Effect.asVoid,
654665
);
666+
}
655667
case "unregistered":
656668
return Effect.logInfo("Relay client tunnel connection unregistered", attributes);
657669
case "warning":

0 commit comments

Comments
 (0)