authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-09 01:59:50-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 01:59:50-07:00
log101f8b2f55df50d4f8d70ed8e6b8e0edf71a2f31
treec2c6db526ac241e49d0a0b5d0117ea3b3196ef61
parent43f8a8de17a7ac1baa7a3cf36d681856de2d8add
signature Signed by PGP key B5690EEEBB952194

fix(server): relay client updates no longer drop the host off T3 Connect (#17366)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

3 files changed, 123 insertions(+), 23 deletions(-)

apps/server/src/cloud/ManagedEndpointRuntime.test.ts+82-6
...@@ -851,7 +851,7 @@ describe("CloudManagedEndpointRuntime", () => {...@@ -851,7 +851,7 @@ describe("CloudManagedEndpointRuntime", () => {
851 );851 );
852852
853 it.effect(853 it.effect(
854 "runs an older relay client while the pinned one installs, then restarts on it and prunes",854 "runs an older relay client while the pinned one installs, then hands off to it and prunes",
855 () =>855 () =>
856 Effect.gen(function* () {856 Effect.gen(function* () {
857 const olderClient = {857 const olderClient = {
...@@ -870,7 +870,7 @@ describe("CloudManagedEndpointRuntime", () => {...@@ -870,7 +870,7 @@ describe("CloudManagedEndpointRuntime", () => {
870 const releaseInstall = yield* Deferred.make<void>();870 const releaseInstall = yield* Deferred.make<void>();
871 const pruned = yield* Deferred.make<void>();871 const pruned = yield* Deferred.make<void>();
872 const spawned = yield* Queue.unbounded<string>();872 const spawned = yield* Queue.unbounded<string>();
873 const killed: Array<string> = [];873 const olderStopped = yield* Deferred.make<void>();
874 const output = yield* Queue.unbounded<Uint8Array>();874 const output = yield* Queue.unbounded<Uint8Array>();
875 const spawner = ChildProcessSpawner.make((command) =>875 const spawner = ChildProcessSpawner.make((command) =>
876 Effect.gen(function* () {876 Effect.gen(function* () {
...@@ -878,7 +878,11 @@ describe("CloudManagedEndpointRuntime", () => {...@@ -878,7 +878,11 @@ describe("CloudManagedEndpointRuntime", () => {
878 yield* Queue.offer(spawned, executable);878 yield* Queue.offer(spawned, executable);
879 const handle = makeHandle({879 const handle = makeHandle({
880 pid: executable === pinnedClient.executablePath ? 2 : 1,880 pid: executable === pinnedClient.executablePath ? 2 : 1,
881 onKill: () => killed.push(executable),881 onKill: () => {
882 if (executable === olderClient.executablePath) {
883 Deferred.doneUnsafe(olderStopped, Effect.void);
884 }
885 },
882 ...(executable === pinnedClient.executablePath886 ...(executable === pinnedClient.executablePath
883 ? { output: Stream.fromQueue(output) }887 ? { output: Stream.fromQueue(output) }
884 : {}),888 : {}),
...@@ -916,7 +920,8 @@ describe("CloudManagedEndpointRuntime", () => {...@@ -916,7 +920,8 @@ describe("CloudManagedEndpointRuntime", () => {
916920
917 yield* Deferred.succeed(releaseInstall, undefined);921 yield* Deferred.succeed(releaseInstall, undefined);
918 expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath);922 expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath);
919 expect(killed).toEqual([olderClient.executablePath]);923 // The older connector keeps serving until the pinned one registers.
924 expect(Option.isNone(yield* Deferred.poll(olderStopped))).toBe(true);
920925
921 yield* Queue.offer(926 yield* Queue.offer(
922 output,927 output,
...@@ -924,6 +929,7 @@ describe("CloudManagedEndpointRuntime", () => {...@@ -924,6 +929,7 @@ describe("CloudManagedEndpointRuntime", () => {
924 "2026-10-08T00:00:00Z INF Registered tunnel connection connIndex=0\n",929 "2026-10-08T00:00:00Z INF Registered tunnel connection connIndex=0\n",
925 ),930 ),
926 );931 );
932 yield* Deferred.await(olderStopped);
927 yield* Deferred.await(pruned);933 yield* Deferred.await(pruned);
928 }),934 }),
929 );935 );
...@@ -1118,7 +1124,7 @@ describe("CloudManagedEndpointRuntime", () => {...@@ -1118,7 +1124,7 @@ describe("CloudManagedEndpointRuntime", () => {
1118 }),1124 }),
1119 );1125 );
11201126
1121 it.effect("requests recovery when the updated relay client fails to start", () =>1127 it.effect("keeps the older connector when the updated relay client fails to start", () =>
1122 Effect.gen(function* () {1128 Effect.gen(function* () {
1123 const olderClient = {1129 const olderClient = {
1124 status: "available",1130 status: "available",
...@@ -1133,18 +1139,26 @@ describe("CloudManagedEndpointRuntime", () => {...@@ -1133,18 +1139,26 @@ describe("CloudManagedEndpointRuntime", () => {
1133 version: RelayClient.CLOUDFLARED_VERSION,1139 version: RelayClient.CLOUDFLARED_VERSION,
1134 } as const;1140 } as const;
1135 let installed = false;1141 let installed = false;
1142 let olderKilled = false;
1136 const releaseInstall = yield* Deferred.make<void>();1143 const releaseInstall = yield* Deferred.make<void>();
1144 const pinnedAttempted = yield* Deferred.make<void>();
1137 const spawner = ChildProcessSpawner.make((command) =>1145 const spawner = ChildProcessSpawner.make((command) =>
1138 Effect.gen(function* () {1146 Effect.gen(function* () {
1139 const executable = ChildProcess.isStandardCommand(command) ? command.command : "";1147 const executable = ChildProcess.isStandardCommand(command) ? command.command : "";
1140 if (executable === pinnedClient.executablePath) {1148 if (executable === pinnedClient.executablePath) {
1149 yield* Deferred.succeed(pinnedAttempted, undefined);
1141 return yield* PlatformError.systemError({1150 return yield* PlatformError.systemError({
1142 _tag: "PermissionDenied",1151 _tag: "PermissionDenied",
1143 module: "ChildProcess",1152 module: "ChildProcess",
1144 method: "spawn",1153 method: "spawn",
1145 });1154 });
1146 }1155 }
1147 const handle = makeHandle({ pid: 6, onKill: () => {} });1156 const handle = makeHandle({
1157 pid: 6,
1158 onKill: () => {
1159 olderKilled = true;
1160 },
1161 });
1148 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));1162 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
1149 return handle;1163 return handle;
1150 }),1164 }),
...@@ -1171,6 +1185,68 @@ describe("CloudManagedEndpointRuntime", () => {...@@ -1171,6 +1185,68 @@ describe("CloudManagedEndpointRuntime", () => {
1171 const config = { providerKind: "cloudflare_tunnel", connectorToken: "token" } as const;1185 const config = { providerKind: "cloudflare_tunnel", connectorToken: "token" } as const;
1172 expect(yield* runtime.applyConfig(config)).toMatchObject({ status: "running", pid: 6 });1186 expect(yield* runtime.applyConfig(config)).toMatchObject({ status: "running", pid: 6 });
11731187
1188 yield* Deferred.succeed(releaseInstall, undefined);
1189 yield* Deferred.await(pinnedAttempted);
1190 // The failed swap leaves the older connector active and running.
1191 expect(yield* runtime.applyConfig(config)).toMatchObject({ status: "running", pid: 6 });
1192 expect(olderKilled).toBe(false);
1193 }),
1194 );
1195
1196 it.effect("requests recovery when the older connector exited during a failed swap", () =>
1197 Effect.gen(function* () {
1198 const olderClient = {
1199 status: "available",
1200 executablePath: "/managed/2025.9.0/cloudflared",
1201 source: "managed",
1202 version: "2025.9.0",
1203 } as const;
1204 const pinnedClient = {
1205 status: "available",
1206 executablePath: "/managed/pinned/cloudflared",
1207 source: "managed",
1208 version: RelayClient.CLOUDFLARED_VERSION,
1209 } as const;
1210 let installed = false;
1211 const releaseInstall = yield* Deferred.make<void>();
1212 const spawner = ChildProcessSpawner.make((command) =>
1213 Effect.gen(function* () {
1214 const executable = ChildProcess.isStandardCommand(command) ? command.command : "";
1215 if (executable === pinnedClient.executablePath) {
1216 return yield* PlatformError.systemError({
1217 _tag: "PermissionDenied",
1218 module: "ChildProcess",
1219 method: "spawn",
1220 });
1221 }
1222 // The older connector has exited by the time the swap fails.
1223 const handle = makeHandle({ pid: 12, onKill: () => {}, isRunning: () => !installed });
1224 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
1225 return handle;
1226 }),
1227 );
1228 const runtime = yield* buildCloudManagedEndpointRuntime(
1229 spawner,
1230 Layer.succeed(
1231 RelayClient.RelayClient,
1232 RelayClient.RelayClient.of({
1233 resolve: Effect.sync(() => (installed ? pinnedClient : olderClient)),
1234 install: Deferred.await(releaseInstall).pipe(
1235 Effect.andThen(
1236 Effect.sync(() => {
1237 installed = true;
1238 return pinnedClient;
1239 }),
1240 ),
1241 ),
1242 installWithProgress: () => Effect.die("unused"),
1243 pruneManagedVersions: Effect.void,
1244 }),
1245 ),
1246 );
1247 const config = { providerKind: "cloudflare_tunnel", connectorToken: "token" } as const;
1248 expect(yield* runtime.applyConfig(config)).toMatchObject({ status: "running", pid: 12 });
1249
1174 yield* Deferred.succeed(releaseInstall, undefined);1250 yield* Deferred.succeed(releaseInstall, undefined);
1175 expect(Option.getOrThrow(yield* Stream.runHead(runtime.recoveryRequests))).toEqual(config);1251 expect(Option.getOrThrow(yield* Stream.runHead(runtime.recoveryRequests))).toEqual(config);
1176 }),1252 }),
apps/server/src/cloud/ManagedEndpointRuntime.ts+36-13
...@@ -2,6 +2,7 @@ import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay...@@ -2,6 +2,7 @@ import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay
2import * as RelayClient from "@t3tools/shared/relayClient";2import * as RelayClient from "@t3tools/shared/relayClient";
3import * as Clock from "effect/Clock";3import * as Clock from "effect/Clock";
4import * as Context from "effect/Context";4import * as Context from "effect/Context";
5import * as Deferred from "effect/Deferred";
5import * as Duration from "effect/Duration";6import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
7import * as Exit from "effect/Exit";8import * as Exit from "effect/Exit";
...@@ -60,7 +61,8 @@ interface ActiveConnector {...@@ -60,7 +61,8 @@ interface ActiveConnector {
60 readonly configKey: string;61 readonly configKey: string;
61 readonly config: RelayManagedEndpointRuntimeConfig;62 readonly config: RelayManagedEndpointRuntimeConfig;
62 readonly startedAtMillis: number;63 readonly startedAtMillis: number;
63 readonly connected: Ref.Ref<boolean>;64 /** Completes when the connector first registers a tunnel connection. */
65 readonly registered: Deferred.Deferred<void>;
64}66}
6567
66// A connector that exits before running this long is treated as part of a68// A connector that exits before running this long is treated as part of a
...@@ -252,9 +254,10 @@ export const make = Effect.gen(function* () {...@@ -252,9 +254,10 @@ export const make = Effect.gen(function* () {
252 Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })),254 Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })),
253 );255 );
254256
255 // Installs the pinned relay client in the background, then restarts the257 // Installs the pinned relay client in the background, then moves the connector
256 // connector on it. The running connector, if any, keeps serving until the new258 // to it. The new connector starts next to the old one on the same tunnel, and
257 // binary is installed and validated, so an update costs one brief reconnect.259 // the old one stops only once the new one registers, so clients never see the
260 // relay lose this host during the swap.
258 const installPinnedRelayClient = Effect.gen(function* () {261 const installPinnedRelayClient = Effect.gen(function* () {
259 if (yield* Ref.getAndSet(installInFlightRef, true)) return;262 if (yield* Ref.getAndSet(installInFlightRef, true)) return;
260 yield* Effect.logInfo("Installing the pinned relay client", {263 yield* Effect.logInfo("Installing the pinned relay client", {
...@@ -286,17 +289,37 @@ export const make = Effect.gen(function* () {...@@ -286,17 +289,37 @@ export const make = Effect.gen(function* () {
286 ) {289 ) {
287 return;290 return;
288 }291 }
289 yield* Effect.logInfo("Relay client installed; restarting the connector on it", {292 yield* Effect.logInfo("Relay client installed; moving the connector to it", {
290 version: installed.version,293 version: installed.version,
291 previousVersion: active?.executable.version,294 previousVersion: active?.executable.version,
292 });295 });
293 yield* stopActive;296 // Detach the old connector so the reconcile starts a new one beside it.
297 const previous = yield* Ref.getAndSet(activeRef, null);
294 const status = yield* reconcileConfig(desiredConfig);298 const status = yield* reconcileConfig(desiredConfig);
295 // The old connector is gone and no supervisor watches a failed spawn,299 const next = yield* Ref.get(activeRef);
296 // so ask for recovery like an exited connector would.300 if (!next) {
297 if (status.status === "failed") {
298 yield* Effect.logWarning("Relay client did not start after the update", status);301 yield* Effect.logWarning("Relay client did not start after the update", status);
299 yield* Queue.offer(recoveryRequests, desiredConfig);302 // A still-running old connector keeps serving and the retry loop
303 // tries the pin again. One that exited while detached was skipped
304 // by its supervisor, so ask for recovery like an exited connector.
305 const previousRunning = previous
306 ? yield* previous.child.isRunning.pipe(Effect.orElseSucceed(() => false))
307 : false;
308 if (previous && previousRunning) {
309 yield* Ref.set(activeRef, previous);
310 } else {
311 yield* stopConnector(previous);
312 yield* Queue.offer(recoveryRequests, desiredConfig);
313 }
314 return;
315 }
316 // Runs in the new connector's scope: the old one stops when the new
317 // one registers, or as soon as the new one is stopped or replaced.
318 if (previous) {
319 yield* Deferred.await(next.registered).pipe(
320 Effect.ensuring(stopConnector(previous)),
321 Effect.forkIn(next.scope),
322 );
300 }323 }
301 }),324 }),
302 ),325 ),
...@@ -357,7 +380,7 @@ export const make = Effect.gen(function* () {...@@ -357,7 +380,7 @@ export const make = Effect.gen(function* () {
357 const watchConnectorRegistration = (connector: ActiveConnector) =>380 const watchConnectorRegistration = (connector: ActiveConnector) =>
358 Effect.gen(function* () {381 Effect.gen(function* () {
359 yield* Effect.sleep(CONNECTOR_REGISTRATION_TIMEOUT);382 yield* Effect.sleep(CONNECTOR_REGISTRATION_TIMEOUT);
360 if (yield* Ref.get(connector.connected)) return true;383 if (yield* Deferred.isDone(connector.registered)) return true;
361 yield* Effect.logWarning(384 yield* Effect.logWarning(
362 "Relay client has not registered a tunnel connection; requesting recovery",385 "Relay client has not registered a tunnel connection; requesting recovery",
363 {386 {
...@@ -389,7 +412,7 @@ export const make = Effect.gen(function* () {...@@ -389,7 +412,7 @@ export const make = Effect.gen(function* () {
389 switch (classifyRelayClientOutput(line)) {412 switch (classifyRelayClientOutput(line)) {
390 case "connected":413 case "connected":
391 rejectedRegistrations = 0;414 rejectedRegistrations = 0;
392 return Ref.set(connector.connected, true).pipe(415 return Deferred.succeed(connector.registered, undefined).pipe(
393 Effect.andThen(416 Effect.andThen(
394 Effect.logInfo("Relay client tunnel connection registered", attributes),417 Effect.logInfo("Relay client tunnel connection registered", attributes),
395 ),418 ),
...@@ -539,7 +562,7 @@ export const make = Effect.gen(function* () {...@@ -539,7 +562,7 @@ export const make = Effect.gen(function* () {
539 configKey: nextConfigKey,562 configKey: nextConfigKey,
540 config,563 config,
541 startedAtMillis: yield* Clock.currentTimeMillis,564 startedAtMillis: yield* Clock.currentTimeMillis,
542 connected: yield* Ref.make(false),565 registered: yield* Deferred.make<void>(),
543 } satisfies ActiveConnector;566 } satisfies ActiveConnector;
544 yield* Ref.set(activeRef, connector);567 yield* Ref.set(activeRef, connector);
545 yield* Effect.forkIn(observeConnectorOutput(connector), connectorScope);568 yield* Effect.forkIn(observeConnectorOutput(connector), connectorScope);
docs/internals/t3-connect.md+5-4
...@@ -136,10 +136,11 @@ The host runs `cloudflared` pinned by `CLOUDFLARED_VERSION` in...@@ -136,10 +136,11 @@ The host runs `cloudflared` pinned by `CLOUDFLARED_VERSION` in
136[`relayClient.ts`](../../packages/shared/src/relayClient.ts), and bumping it there136[`relayClient.ts`](../../packages/shared/src/relayClient.ts), and bumping it there
137(version, URLs, and checksums) is the whole release step. A linked host that137(version, URLs, and checksums) is the whole release step. A linked host that
138starts a server with a new pin keeps its connector up on the newest older managed138starts a server with a new pin keeps its connector up on the newest older managed
139release, installs the pinned one in the background, then restarts only the139release, installs the pinned one in the background, then starts a second
140connector child on it. If that restart fails to spawn, the host requests recovery140connector on it for the same tunnel. The old connector stops only after the new
141as it would for an exited connector. A host with no relay client installs one141one registers, so the swap never takes the host off the relay. If the new one
142first. A `cloudflared` on `PATH` or an explicit `T3CODE_CLOUDFLARED_PATH` is the142fails to spawn, the old one keeps serving and the install retries later. A host
143with no relay client installs one first. A `cloudflared` on `PATH` or an explicit `T3CODE_CLOUDFLARED_PATH` is the
143user's choice and is never replaced, since linking asks before downloading.144user's choice and is never replaced, since linking asks before downloading.
144145
145Once the pinned release registers a connection, managed releases older than the146Once the pinned release registers a connection, managed releases older than the