authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 13:09:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 13:09:30-07:00
log2a93885bac5798a79d55069a0b5dc3e53c6176bc
tree15099ecfa1c864be4a4c991ed33c37884ce5e5cc
parentcdd331b6c3f78bf24b039b09937b837d2d71a4e7
signature Signed by PGP key B5690EEEBB952194

fix(connect): relay client updates itself and skips incompatible cloudflared (#17275)

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

6 files changed, 1132 insertions(+), 59 deletions(-)

apps/server/src/cli/connect.test.ts+3
...@@ -71,6 +71,7 @@ it.effect("does not install the relay client when the user declines the managed...@@ -71,6 +71,7 @@ it.effect("does not install the relay client when the user declines the managed
71 installCalls += 1;71 installCalls += 1;
72 return managedExecutable;72 return managedExecutable;
73 }),73 }),
74 pruneManagedVersions: Effect.void,
74 },75 },
75 () => Effect.succeed(false),76 () => Effect.succeed(false),
76 () => Effect.void,77 () => Effect.void,
...@@ -104,6 +105,7 @@ it.effect("installs the relay client after the user accepts the managed download...@@ -104,6 +105,7 @@ it.effect("installs the relay client after the user accepts the managed download
104 }),105 }),
105 ),106 ),
106 ),107 ),
108 pruneManagedVersions: Effect.void,
107 },109 },
108 () => Effect.succeed(true),110 () => Effect.succeed(true),
109 (event) =>111 (event) =>
...@@ -128,6 +130,7 @@ it.effect("reuses an available relay client executable without prompting", () =>...@@ -128,6 +130,7 @@ it.effect("reuses an available relay client executable without prompting", () =>
128 resolve: Effect.succeed(managedExecutable),130 resolve: Effect.succeed(managedExecutable),
129 install: Effect.die("unexpected install"),131 install: Effect.die("unexpected install"),
130 installWithProgress: () => Effect.die("unexpected install"),132 installWithProgress: () => Effect.die("unexpected install"),
133 pruneManagedVersions: Effect.void,
131 },134 },
132 () =>135 () =>
133 Effect.sync(() => {136 Effect.sync(() => {
apps/server/src/cloud/ManagedEndpointRuntime.test.ts+555
...@@ -29,6 +29,7 @@ const layerRelayClientAvailable = Layer.succeed(...@@ -29,6 +29,7 @@ const layerRelayClientAvailable = Layer.succeed(
29 }),29 }),
30 install: Effect.die("unused"),30 install: Effect.die("unused"),
31 installWithProgress: () => Effect.die("unused"),31 installWithProgress: () => Effect.die("unused"),
32 pruneManagedVersions: Effect.void,
32 }),33 }),
33);34);
3435
...@@ -829,6 +830,7 @@ describe("CloudManagedEndpointRuntime", () => {...@@ -829,6 +830,7 @@ describe("CloudManagedEndpointRuntime", () => {
829 }),830 }),
830 install: Effect.die("unused"),831 install: Effect.die("unused"),
831 installWithProgress: () => Effect.die("unused"),832 installWithProgress: () => Effect.die("unused"),
833 pruneManagedVersions: Effect.void,
832 }),834 }),
833 ),835 ),
834 );836 );
...@@ -847,4 +849,557 @@ describe("CloudManagedEndpointRuntime", () => {...@@ -847,4 +849,557 @@ describe("CloudManagedEndpointRuntime", () => {
847 expect(spawn).not.toHaveBeenCalled();849 expect(spawn).not.toHaveBeenCalled();
848 }),850 }),
849 );851 );
852
853 it.effect(
854 "runs an older relay client while the pinned one installs, then restarts on it and prunes",
855 () =>
856 Effect.gen(function* () {
857 const olderClient = {
858 status: "available",
859 executablePath: "/managed/2025.9.0/cloudflared",
860 source: "managed",
861 version: "2025.9.0",
862 } as const;
863 const pinnedClient = {
864 status: "available",
865 executablePath: "/managed/pinned/cloudflared",
866 source: "managed",
867 version: RelayClient.CLOUDFLARED_VERSION,
868 } as const;
869 let installed = false;
870 const releaseInstall = yield* Deferred.make<void>();
871 const pruned = yield* Deferred.make<void>();
872 const spawned = yield* Queue.unbounded<string>();
873 const killed: Array<string> = [];
874 const output = yield* Queue.unbounded<Uint8Array>();
875 const spawner = ChildProcessSpawner.make((command) =>
876 Effect.gen(function* () {
877 const executable = ChildProcess.isStandardCommand(command) ? command.command : "";
878 yield* Queue.offer(spawned, executable);
879 const handle = makeHandle({
880 pid: executable === pinnedClient.executablePath ? 2 : 1,
881 onKill: () => killed.push(executable),
882 ...(executable === pinnedClient.executablePath
883 ? { output: Stream.fromQueue(output) }
884 : {}),
885 });
886 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
887 return handle;
888 }),
889 );
890 const runtime = yield* buildCloudManagedEndpointRuntime(
891 spawner,
892 Layer.succeed(
893 RelayClient.RelayClient,
894 RelayClient.RelayClient.of({
895 resolve: Effect.sync(() => (installed ? pinnedClient : olderClient)),
896 install: Deferred.await(releaseInstall).pipe(
897 Effect.andThen(
898 Effect.sync(() => {
899 installed = true;
900 return pinnedClient;
901 }),
902 ),
903 ),
904 installWithProgress: () => Effect.die("unused"),
905 pruneManagedVersions: Deferred.succeed(pruned, undefined).pipe(Effect.asVoid),
906 }),
907 ),
908 );
909
910 const status = yield* runtime.applyConfig({
911 providerKind: "cloudflare_tunnel",
912 connectorToken: "token",
913 });
914 expect(status).toMatchObject({ status: "running", pid: 1 });
915 expect(yield* Queue.take(spawned)).toBe(olderClient.executablePath);
916
917 yield* Deferred.succeed(releaseInstall, undefined);
918 expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath);
919 expect(killed).toEqual([olderClient.executablePath]);
920
921 yield* Queue.offer(
922 output,
923 new TextEncoder().encode(
924 "2026-10-08T00:00:00Z INF Registered tunnel connection connIndex=0\n",
925 ),
926 );
927 yield* Deferred.await(pruned);
928 }),
929 );
930
931 it.effect("installs a missing relay client in the background and then starts it", () =>
932 Effect.gen(function* () {
933 const pinnedClient = {
934 status: "available",
935 executablePath: "/managed/pinned/cloudflared",
936 source: "managed",
937 version: RelayClient.CLOUDFLARED_VERSION,
938 } as const;
939 let installed = false;
940 const spawned = yield* Deferred.make<string>();
941 const spawner = ChildProcessSpawner.make((command) =>
942 Effect.gen(function* () {
943 yield* Deferred.succeed(
944 spawned,
945 ChildProcess.isStandardCommand(command) ? command.command : "",
946 );
947 const handle = makeHandle({ pid: 3, onKill: () => {} });
948 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
949 return handle;
950 }),
951 );
952 const runtime = yield* buildCloudManagedEndpointRuntime(
953 spawner,
954 Layer.succeed(
955 RelayClient.RelayClient,
956 RelayClient.RelayClient.of({
957 resolve: Effect.sync(() =>
958 installed
959 ? pinnedClient
960 : { status: "missing" as const, version: RelayClient.CLOUDFLARED_VERSION },
961 ),
962 install: Effect.sync(() => {
963 installed = true;
964 return pinnedClient;
965 }),
966 installWithProgress: () => Effect.die("unused"),
967 pruneManagedVersions: Effect.void,
968 }),
969 ),
970 );
971
972 const status = yield* runtime.applyConfig({
973 providerKind: "cloudflare_tunnel",
974 connectorToken: "token",
975 });
976 // The first attempt reports the gap; the install then starts the connector itself.
977 expect(status).toMatchObject({ status: "failed", failure: "not-installed" });
978 expect(yield* Deferred.await(spawned)).toBe(pinnedClient.executablePath);
979 }),
980 );
981
982 it.effect("leaves a PATH relay client alone", () =>
983 Effect.gen(function* () {
984 const install = vi.fn(() => Effect.die("unexpected install"));
985 const spawner = ChildProcessSpawner.make(() =>
986 Effect.gen(function* () {
987 const handle = makeHandle({ pid: 8, onKill: () => {} });
988 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
989 return handle;
990 }),
991 );
992 const runtime = yield* buildCloudManagedEndpointRuntime(
993 spawner,
994 Layer.succeed(
995 RelayClient.RelayClient,
996 RelayClient.RelayClient.of({
997 resolve: Effect.succeed({
998 status: "available",
999 executablePath: "/opt/homebrew/bin/cloudflared",
1000 source: "path",
1001 version: "2026.10.0",
1002 }),
1003 install: Effect.suspend(install),
1004 installWithProgress: () => Effect.die("unused"),
1005 pruneManagedVersions: Effect.void,
1006 }),
1007 ),
1008 );
1009 expect(
1010 yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }),
1011 ).toMatchObject({ status: "running", pid: 8 });
1012 yield* TestClock.adjust(Duration.minutes(30));
1013 expect(install).not.toHaveBeenCalled();
1014 }),
1015 );
1016
1017 it.effect("leaves an override relay client alone", () =>
1018 Effect.gen(function* () {
1019 const install = vi.fn(() => Effect.die("unexpected install"));
1020 const spawner = ChildProcessSpawner.make(() =>
1021 Effect.gen(function* () {
1022 const handle = makeHandle({ pid: 4, onKill: () => {} });
1023 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
1024 return handle;
1025 }),
1026 );
1027 const runtime = yield* buildCloudManagedEndpointRuntime(
1028 spawner,
1029 Layer.succeed(
1030 RelayClient.RelayClient,
1031 RelayClient.RelayClient.of({
1032 resolve: Effect.succeed({
1033 status: "available",
1034 executablePath: "/opt/cloudflared",
1035 source: "override",
1036 version: "2025.10.0",
1037 }),
1038 install: Effect.suspend(install),
1039 installWithProgress: () => Effect.die("unused"),
1040 pruneManagedVersions: Effect.void,
1041 }),
1042 ),
1043 );
1044 expect(
1045 yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }),
1046 ).toMatchObject({ status: "running", pid: 4 });
1047 yield* Effect.yieldNow;
1048 expect(install).not.toHaveBeenCalled();
1049 }),
1050 );
1051
1052 it.effect("retries a failed background install while the older connector keeps running", () =>
1053 Effect.gen(function* () {
1054 const olderClient = {
1055 status: "available",
1056 executablePath: "/managed/2025.9.0/cloudflared",
1057 source: "managed",
1058 version: "2025.9.0",
1059 } as const;
1060 const pinnedClient = {
1061 status: "available",
1062 executablePath: "/managed/pinned/cloudflared",
1063 source: "managed",
1064 version: RelayClient.CLOUDFLARED_VERSION,
1065 } as const;
1066 let installed = false;
1067 let installAttempts = 0;
1068 const spawned = yield* Queue.unbounded<string>();
1069 const spawner = ChildProcessSpawner.make((command) =>
1070 Effect.gen(function* () {
1071 yield* Queue.offer(
1072 spawned,
1073 ChildProcess.isStandardCommand(command) ? command.command : "",
1074 );
1075 const handle = makeHandle({ pid: 5, onKill: () => {} });
1076 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
1077 return handle;
1078 }),
1079 );
1080 const runtime = yield* buildCloudManagedEndpointRuntime(
1081 spawner,
1082 Layer.succeed(
1083 RelayClient.RelayClient,
1084 RelayClient.RelayClient.of({
1085 resolve: Effect.sync(() => (installed ? pinnedClient : olderClient)),
1086 install: Effect.suspend(() => {
1087 installAttempts += 1;
1088 if (installAttempts === 1) {
1089 // A real download fails some time after it starts.
1090 return Effect.sleep(Duration.seconds(30)).pipe(
1091 Effect.andThen(
1092 Effect.fail(
1093 new RelayClient.RelayClientInstallError({
1094 reason: "download_failed",
1095 message: "offline",
1096 }),
1097 ),
1098 ),
1099 );
1100 }
1101 installed = true;
1102 return Effect.succeed(pinnedClient);
1103 }),
1104 installWithProgress: () => Effect.die("unused"),
1105 pruneManagedVersions: Effect.void,
1106 }),
1107 ),
1108 );
1109
1110 yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" });
1111 expect(yield* Queue.take(spawned)).toBe(olderClient.executablePath);
1112 yield* TestClock.adjust(Duration.seconds(30));
1113 expect(installAttempts).toBe(1);
1114
1115 yield* TestClock.adjust(Duration.minutes(10));
1116 expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath);
1117 expect(installAttempts).toBe(2);
1118 }),
1119 );
1120
1121 it.effect("requests recovery when the updated relay client fails to start", () =>
1122 Effect.gen(function* () {
1123 const olderClient = {
1124 status: "available",
1125 executablePath: "/managed/2025.9.0/cloudflared",
1126 source: "managed",
1127 version: "2025.9.0",
1128 } as const;
1129 const pinnedClient = {
1130 status: "available",
1131 executablePath: "/managed/pinned/cloudflared",
1132 source: "managed",
1133 version: RelayClient.CLOUDFLARED_VERSION,
1134 } as const;
1135 let installed = false;
1136 const releaseInstall = yield* Deferred.make<void>();
1137 const spawner = ChildProcessSpawner.make((command) =>
1138 Effect.gen(function* () {
1139 const executable = ChildProcess.isStandardCommand(command) ? command.command : "";
1140 if (executable === pinnedClient.executablePath) {
1141 return yield* PlatformError.systemError({
1142 _tag: "PermissionDenied",
1143 module: "ChildProcess",
1144 method: "spawn",
1145 });
1146 }
1147 const handle = makeHandle({ pid: 6, onKill: () => {} });
1148 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
1149 return handle;
1150 }),
1151 );
1152 const runtime = yield* buildCloudManagedEndpointRuntime(
1153 spawner,
1154 Layer.succeed(
1155 RelayClient.RelayClient,
1156 RelayClient.RelayClient.of({
1157 resolve: Effect.sync(() => (installed ? pinnedClient : olderClient)),
1158 install: Deferred.await(releaseInstall).pipe(
1159 Effect.andThen(
1160 Effect.sync(() => {
1161 installed = true;
1162 return pinnedClient;
1163 }),
1164 ),
1165 ),
1166 installWithProgress: () => Effect.die("unused"),
1167 pruneManagedVersions: Effect.void,
1168 }),
1169 ),
1170 );
1171 const config = { providerKind: "cloudflare_tunnel", connectorToken: "token" } as const;
1172 expect(yield* runtime.applyConfig(config)).toMatchObject({ status: "running", pid: 6 });
1173
1174 yield* Deferred.succeed(releaseInstall, undefined);
1175 expect(Option.getOrThrow(yield* Stream.runHead(runtime.recoveryRequests))).toEqual(config);
1176 }),
1177 );
1178
1179 it.effect("restarts a self-updated pinned-folder connector once the pin is reinstalled", () =>
1180 Effect.gen(function* () {
1181 const executablePath = "/managed/pinned/cloudflared";
1182 let version = "2026.9.3";
1183 const releaseInstall = yield* Deferred.make<void>();
1184 const spawned = yield* Queue.unbounded<string>();
1185 const spawner = ChildProcessSpawner.make(() =>
1186 Effect.gen(function* () {
1187 yield* Queue.offer(spawned, version);
1188 const handle = makeHandle({ pid: 7, onKill: () => {} });
1189 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
1190 return handle;
1191 }),
1192 );
1193 const current = () =>
1194 ({ status: "available", executablePath, source: "managed", version }) as const;
1195 const runtime = yield* buildCloudManagedEndpointRuntime(
1196 spawner,
1197 Layer.succeed(
1198 RelayClient.RelayClient,
1199 RelayClient.RelayClient.of({
1200 resolve: Effect.sync(current),
1201 install: Deferred.await(releaseInstall).pipe(
1202 Effect.andThen(
1203 Effect.sync(() => {
1204 version = RelayClient.CLOUDFLARED_VERSION;
1205 return current();
1206 }),
1207 ),
1208 ),
1209 installWithProgress: () => Effect.die("unused"),
1210 pruneManagedVersions: Effect.void,
1211 }),
1212 ),
1213 );
1214
1215 yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" });
1216 expect(yield* Queue.take(spawned)).toBe("2026.9.3");
1217 yield* Deferred.succeed(releaseInstall, undefined);
1218 expect(yield* Queue.take(spawned)).toBe(RelayClient.CLOUDFLARED_VERSION);
1219 }),
1220 );
1221
1222 it.effect("retries a failed install when no relay client is installed", () =>
1223 Effect.gen(function* () {
1224 const pinnedClient = {
1225 status: "available",
1226 executablePath: "/managed/pinned/cloudflared",
1227 source: "managed",
1228 version: RelayClient.CLOUDFLARED_VERSION,
1229 } as const;
1230 let installed = false;
1231 let installAttempts = 0;
1232 const spawned = yield* Deferred.make<string>();
1233 const spawner = ChildProcessSpawner.make((command) =>
1234 Effect.gen(function* () {
1235 yield* Deferred.succeed(
1236 spawned,
1237 ChildProcess.isStandardCommand(command) ? command.command : "",
1238 );
1239 const handle = makeHandle({ pid: 9, onKill: () => {} });
1240 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
1241 return handle;
1242 }),
1243 );
1244 const runtime = yield* buildCloudManagedEndpointRuntime(
1245 spawner,
1246 Layer.succeed(
1247 RelayClient.RelayClient,
1248 RelayClient.RelayClient.of({
1249 resolve: Effect.sync(() =>
1250 installed
1251 ? pinnedClient
1252 : { status: "missing" as const, version: RelayClient.CLOUDFLARED_VERSION },
1253 ),
1254 install: Effect.suspend(() => {
1255 installAttempts += 1;
1256 if (installAttempts === 1) {
1257 return Effect.fail(
1258 new RelayClient.RelayClientInstallError({
1259 reason: "download_failed",
1260 message: "offline",
1261 }),
1262 );
1263 }
1264 installed = true;
1265 return Effect.succeed(pinnedClient);
1266 }),
1267 installWithProgress: () => Effect.die("unused"),
1268 pruneManagedVersions: Effect.void,
1269 }),
1270 ),
1271 );
1272
1273 expect(
1274 yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }),
1275 ).toMatchObject({ status: "failed", failure: "not-installed" });
1276 yield* Effect.yieldNow;
1277 expect(installAttempts).toBe(1);
1278
1279 yield* TestClock.adjust(Duration.minutes(10));
1280 expect(yield* Deferred.await(spawned)).toBe(pinnedClient.executablePath);
1281 expect(installAttempts).toBe(2);
1282 }),
1283 );
1284
1285 it.effect("restarts on the pin when the first post-install probe failed", () =>
1286 Effect.gen(function* () {
1287 const olderClient = {
1288 status: "available",
1289 executablePath: "/managed/2025.9.0/cloudflared",
1290 source: "managed",
1291 version: "2025.9.0",
1292 } as const;
1293 const pinnedClient = {
1294 status: "available",
1295 executablePath: "/managed/pinned/cloudflared",
1296 source: "managed",
1297 version: RelayClient.CLOUDFLARED_VERSION,
1298 } as const;
1299 let installed = false;
1300 // The first resolve after the install misses the new binary, as when a
1301 // scanner briefly holds it; later resolves see it.
1302 let missedProbes = 0;
1303 const spawned = yield* Queue.unbounded<string>();
1304 const spawner = ChildProcessSpawner.make((command) =>
1305 Effect.gen(function* () {
1306 yield* Queue.offer(
1307 spawned,
1308 ChildProcess.isStandardCommand(command) ? command.command : "",
1309 );
1310 const handle = makeHandle({ pid: 10, onKill: () => {} });
1311 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
1312 return handle;
1313 }),
1314 );
1315 const runtime = yield* buildCloudManagedEndpointRuntime(
1316 spawner,
1317 Layer.succeed(
1318 RelayClient.RelayClient,
1319 RelayClient.RelayClient.of({
1320 resolve: Effect.sync(() => {
1321 if (installed && missedProbes === 0) {
1322 missedProbes += 1;
1323 return olderClient;
1324 }
1325 return installed ? pinnedClient : olderClient;
1326 }),
1327 install: Effect.sync(() => {
1328 installed = true;
1329 return pinnedClient;
1330 }),
1331 installWithProgress: () => Effect.die("unused"),
1332 pruneManagedVersions: Effect.void,
1333 }),
1334 ),
1335 );
1336
1337 yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" });
1338 expect(yield* Queue.take(spawned)).toBe(olderClient.executablePath);
1339 yield* Effect.yieldNow;
1340 // The missed probe kept the older connector running.
1341 expect(Option.isNone(yield* Queue.poll(spawned))).toBe(true);
1342
1343 yield* TestClock.adjust(Duration.minutes(10));
1344 expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath);
1345 }),
1346 );
1347
1348 it.effect("starts the connector when the pin landed but its first probe failed", () =>
1349 Effect.gen(function* () {
1350 const pinnedClient = {
1351 status: "available",
1352 executablePath: "/managed/pinned/cloudflared",
1353 source: "managed",
1354 version: RelayClient.CLOUDFLARED_VERSION,
1355 } as const;
1356 const missing = { status: "missing" as const, version: RelayClient.CLOUDFLARED_VERSION };
1357 let installed = false;
1358 // The resolve right after the install misses the new binary.
1359 let missedProbes = 0;
1360 const spawned = yield* Deferred.make<string>();
1361 const spawner = ChildProcessSpawner.make((command) =>
1362 Effect.gen(function* () {
1363 yield* Deferred.succeed(
1364 spawned,
1365 ChildProcess.isStandardCommand(command) ? command.command : "",
1366 );
1367 const handle = makeHandle({ pid: 11, onKill: () => {} });
1368 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
1369 return handle;
1370 }),
1371 );
1372 const runtime = yield* buildCloudManagedEndpointRuntime(
1373 spawner,
1374 Layer.succeed(
1375 RelayClient.RelayClient,
1376 RelayClient.RelayClient.of({
1377 resolve: Effect.sync(() => {
1378 if (!installed) return missing;
1379 if (missedProbes === 0) {
1380 missedProbes += 1;
1381 return missing;
1382 }
1383 return pinnedClient;
1384 }),
1385 install: Effect.sync(() => {
1386 installed = true;
1387 return pinnedClient;
1388 }),
1389 installWithProgress: () => Effect.die("unused"),
1390 pruneManagedVersions: Effect.void,
1391 }),
1392 ),
1393 );
1394
1395 expect(
1396 yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }),
1397 ).toMatchObject({ status: "failed", failure: "not-installed" });
1398 yield* Effect.yieldNow;
1399 expect(Option.isNone(yield* Deferred.poll(spawned))).toBe(true);
1400
1401 yield* TestClock.adjust(Duration.minutes(10));
1402 expect(yield* Deferred.await(spawned)).toBe(pinnedClient.executablePath);
1403 }),
1404 );
850});1405});
apps/server/src/cloud/ManagedEndpointRuntime.ts+140
...@@ -55,6 +55,7 @@ export class CloudManagedEndpointRuntime extends Context.Service<...@@ -55,6 +55,7 @@ export class CloudManagedEndpointRuntime extends Context.Service<
5555
56interface ActiveConnector {56interface ActiveConnector {
57 readonly child: ChildProcessSpawner.ChildProcessHandle;57 readonly child: ChildProcessSpawner.ChildProcessHandle;
58 readonly executable: RelayClient.AvailableRelayClient;
58 readonly scope: Scope.Closeable;59 readonly scope: Scope.Closeable;
59 readonly configKey: string;60 readonly configKey: string;
60 readonly config: RelayManagedEndpointRuntimeConfig;61 readonly config: RelayManagedEndpointRuntimeConfig;
...@@ -77,6 +78,24 @@ const TUNNEL_AUTHORIZATION_FAILURES_BEFORE_RECOVERY = 4;...@@ -77,6 +78,24 @@ const TUNNEL_AUTHORIZATION_FAILURES_BEFORE_RECOVERY = 4;
77// Ask for recovery after this long, and again at this interval while it stays78// Ask for recovery after this long, and again at this interval while it stays
78// unconnected; the relay hands back the same tunnel when it is still live.79// unconnected; the relay hands back the same tunnel when it is still live.
79const CONNECTOR_REGISTRATION_TIMEOUT = Duration.minutes(3);80const CONNECTOR_REGISTRATION_TIMEOUT = Duration.minutes(3);
81// A failed background install of the pinned relay client is retried no sooner
82// than this, so a crash-looping connector or an offline host does not redownload
83// on every reconcile.
84const RELAY_CLIENT_INSTALL_RETRY_INTERVAL_MS = 10 * 60_000;
85
86/**
87 * A linked host converges on the pinned managed release when it has no relay
88 * client or runs another managed release. A PATH binary or an explicit override
89 * is the user's choice, and the CLI asks before downloading, so both stay put.
90 */
91function needsPinnedRelayClient(executable: RelayClient.RelayClientStatus): boolean {
92 return (
93 executable.status === "missing" ||
94 (executable.status === "available" &&
95 executable.source === "managed" &&
96 !RelayClient.isPinnedManagedRelayClient(executable))
97 );
98}
8099
81export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" {100export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" {
82 if (/\bRegistered tunnel connection\b/iu.test(line)) {101 if (/\bRegistered tunnel connection\b/iu.test(line)) {
...@@ -148,6 +167,11 @@ export const make = Effect.gen(function* () {...@@ -148,6 +167,11 @@ export const make = Effect.gen(function* () {
148 const reconcileSemaphore = yield* Semaphore.make(1);167 const reconcileSemaphore = yield* Semaphore.make(1);
149 const restartDelayRef = yield* Ref.make(0);168 const restartDelayRef = yield* Ref.make(0);
150 const linkStateSemaphore = yield* Semaphore.make(1);169 const linkStateSemaphore = yield* Semaphore.make(1);
170 const runtimeScope = yield* Effect.scope;
171 const installInFlightRef = yield* Ref.make(false);
172 const lastInstallFailureAtRef = yield* Ref.make<number | null>(null);
173 const prunedRef = yield* Ref.make(false);
174 const retryLoopRunningRef = yield* Ref.make(false);
151 let reconcileConfig: CloudManagedEndpointRuntime["Service"]["applyConfig"];175 let reconcileConfig: CloudManagedEndpointRuntime["Service"]["applyConfig"];
152176
153 const stopActive = Effect.gen(function* () {177 const stopActive = Effect.gen(function* () {
...@@ -228,6 +252,106 @@ export const make = Effect.gen(function* () {...@@ -228,6 +252,106 @@ export const make = Effect.gen(function* () {
228 Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })),252 Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })),
229 );253 );
230254
255 // Installs the pinned relay client in the background, then restarts the
256 // connector on it. The running connector, if any, keeps serving until the new
257 // binary is installed and validated, so an update costs one brief reconnect.
258 const installPinnedRelayClient = Effect.gen(function* () {
259 if (yield* Ref.getAndSet(installInFlightRef, true)) return;
260 yield* Effect.logInfo("Installing the pinned relay client", {
261 version: RelayClient.CLOUDFLARED_VERSION,
262 });
263 yield* relayClient.install.pipe(
264 Effect.tap(() => Ref.set(lastInstallFailureAtRef, null)),
265 Effect.flatMap((installed) =>
266 reconcileSemaphore.withPermits(1)(
267 Effect.gen(function* () {
268 const desiredConfig = yield* Ref.get(desiredConfigRef);
269 if (!desiredConfig || desiredConfig.providerKind !== "cloudflare_tunnel") return;
270 const active = yield* Ref.get(activeRef);
271 // A self-updated binary in the pinned folder shares the installed path,
272 // so the version decides whether the running connector is current.
273 if (
274 active?.executable.executablePath === installed.executablePath &&
275 active.executable.version === installed.version
276 ) {
277 return;
278 }
279 // A fresh binary can fail its probe while a scanner holds it; restarting
280 // then would land on the same older binary, so leave it to the retry.
281 const resolved = yield* relayClient.resolve;
282 if (
283 resolved.status !== "available" ||
284 resolved.executablePath !== installed.executablePath ||
285 resolved.version !== installed.version
286 ) {
287 return;
288 }
289 yield* Effect.logInfo("Relay client installed; restarting the connector on it", {
290 version: installed.version,
291 previousVersion: active?.executable.version,
292 });
293 yield* stopActive;
294 const status = yield* reconcileConfig(desiredConfig);
295 // The old connector is gone and no supervisor watches a failed spawn,
296 // so ask for recovery like an exited connector would.
297 if (status.status === "failed") {
298 yield* Effect.logWarning("Relay client did not start after the update", status);
299 yield* Queue.offer(recoveryRequests, desiredConfig);
300 }
301 }),
302 ),
303 ),
304 Effect.catchTags({
305 RelayClientInstallError: (error) =>
306 Clock.currentTimeMillis.pipe(
307 Effect.flatMap((failedAt) => Ref.set(lastInstallFailureAtRef, failedAt)),
308 Effect.andThen(
309 Effect.logWarning("Could not install the pinned relay client", {
310 reason: error.reason,
311 message: error.message,
312 cause: error.cause,
313 }),
314 ),
315 ),
316 }),
317 Effect.ensuring(Ref.set(installInFlightRef, false)),
318 );
319 });
320
321 // Reconciles call this; a recent failure skips the attempt so a crash-looping
322 // connector or an offline host does not redownload on every restart.
323 const ensurePinnedRelayClient = Effect.gen(function* () {
324 const now = yield* Clock.currentTimeMillis;
325 const lastFailureAt = yield* Ref.get(lastInstallFailureAtRef);
326 if (lastFailureAt !== null && now - lastFailureAt < RELAY_CLIENT_INSTALL_RETRY_INTERVAL_MS) {
327 return;
328 }
329 yield* installPinnedRelayClient;
330 }).pipe(Effect.forkIn(runtimeScope), Effect.asVoid);
331
332 // Until the host runs the pinned release, retry the install every interval.
333 // applyConfig returns early while an older connector is healthy, and some
334 // activations never retry a missing client, so one runtime loop covers both.
335 // It paces itself, so it skips the failure gate above.
336 const retryPinnedRelayClientInstall = Effect.gen(function* () {
337 while (true) {
338 yield* Effect.sleep(Duration.millis(RELAY_CLIENT_INSTALL_RETRY_INTERVAL_MS));
339 const desiredConfig = yield* Ref.get(desiredConfigRef);
340 if (!desiredConfig || desiredConfig.providerKind !== "cloudflare_tunnel") return;
341 // Stop only once a connector runs on the pin. The pin can be on disk while
342 // an older connector still runs, or while none runs because a post-install
343 // probe failed; another install call then finds it and starts the connector.
344 const active = yield* Ref.get(activeRef);
345 if (active && !needsPinnedRelayClient(active.executable)) return;
346 yield* installPinnedRelayClient;
347 }
348 }).pipe(Effect.ensuring(Ref.set(retryLoopRunningRef, false)));
349
350 const startPinnedRelayClientRetries = Effect.gen(function* () {
351 if (yield* Ref.getAndSet(retryLoopRunningRef, true)) return;
352 yield* Effect.forkIn(retryPinnedRelayClientInstall, runtimeScope);
353 });
354
231 // Requests recovery while the connector has not registered a connection,355 // Requests recovery while the connector has not registered a connection,
232 // once per timeout, until it connects or is replaced.356 // once per timeout, until it connects or is replaced.
233 const watchConnectorRegistration = (connector: ActiveConnector) =>357 const watchConnectorRegistration = (connector: ActiveConnector) =>
...@@ -270,6 +394,15 @@ export const make = Effect.gen(function* () {...@@ -270,6 +394,15 @@ export const make = Effect.gen(function* () {
270 Effect.logInfo("Relay client tunnel connection registered", attributes),394 Effect.logInfo("Relay client tunnel connection registered", attributes),
271 ),395 ),
272 Effect.andThen(Queue.offer(tunnelConnections, undefined)),396 Effect.andThen(Queue.offer(tunnelConnections, undefined)),
397 Effect.andThen(
398 RelayClient.isPinnedManagedRelayClient(connector.executable)
399 ? Ref.getAndSet(prunedRef, true).pipe(
400 Effect.flatMap((pruned) =>
401 pruned ? Effect.void : relayClient.pruneManagedVersions,
402 ),
403 )
404 : Effect.void,
405 ),
273 Effect.asVoid,406 Effect.asVoid,
274 );407 );
275 case "warning":408 case "warning":
...@@ -328,6 +461,10 @@ export const make = Effect.gen(function* () {...@@ -328,6 +461,10 @@ export const make = Effect.gen(function* () {
328 yield* stopActive;461 yield* stopActive;
329462
330 const executable = yield* relayClient.resolve;463 const executable = yield* relayClient.resolve;
464 if (needsPinnedRelayClient(executable)) {
465 yield* ensurePinnedRelayClient;
466 yield* startPinnedRelayClientRetries;
467 }
331 if (executable.status !== "available") {468 if (executable.status !== "available") {
332 return {469 return {
333 status: "failed",470 status: "failed",
...@@ -365,6 +502,8 @@ export const make = Effect.gen(function* () {...@@ -365,6 +502,8 @@ export const make = Effect.gen(function* () {
365 Effect.tap((child) =>502 Effect.tap((child) =>
366 Effect.logInfo("Relay client process started; waiting for tunnel connection", {503 Effect.logInfo("Relay client process started; waiting for tunnel connection", {
367 pid: Number(child.pid),504 pid: Number(child.pid),
505 source: executable.source,
506 version: executable.version,
368 tunnelId: config.tunnelId,507 tunnelId: config.tunnelId,
369 tunnelName: config.tunnelName,508 tunnelName: config.tunnelName,
370 }),509 }),
...@@ -395,6 +534,7 @@ export const make = Effect.gen(function* () {...@@ -395,6 +534,7 @@ export const make = Effect.gen(function* () {
395 if (!("status" in child)) {534 if (!("status" in child)) {
396 const connector = {535 const connector = {
397 child,536 child,
537 executable,
398 scope: connectorScope,538 scope: connectorScope,
399 configKey: nextConfigKey,539 configKey: nextConfigKey,
400 config,540 config,
docs/internals/t3-connect.md+24
...@@ -130,6 +130,30 @@ and clients keep their bindings. Every mutation on an allocation bumps its...@@ -130,6 +130,30 @@ and clients keep their bindings. Every mutation on an allocation bumps its
130`generation`, and deletion locks the row at the generation it claimed, so a130`generation`, and deletion locks the row at the generation it claimed, so a
131host that reconnects mid-sweep wins.131host that reconnects mid-sweep wins.
132132
133## The relay client follows the server's pin
134
135The host runs `cloudflared` pinned by `CLOUDFLARED_VERSION` in
136[`relayClient.ts`](../../packages/shared/src/relayClient.ts), and bumping it there
137(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 managed
139release, installs the pinned one in the background, then restarts only the
140connector child on it. If that restart fails to spawn, the host requests recovery
141as it would for an exited connector. A host with no relay client installs one
142first. A `cloudflared` on `PATH` or an explicit `T3CODE_CLOUDFLARED_PATH` is the
143user's choice and is never replaced, since linking asks before downloading.
144
145Once the pinned release registers a connection, managed releases older than the
146pin are deleted except the newest of them. That one, and any newer release, can
147belong to another server sharing the same T3 home, such as a rollback after a
148failed update, which could not run again without it.
149
150The version comes from running `cloudflared version`, not from the folder name.
151Connectors from before `--no-autoupdate` replaced themselves in place, so a
152managed folder can hold another release. Such a binary still runs as a fallback
153while the pin installs. Every binary needs `CLOUDFLARED_MIN_VERSION`, the oldest
154release that accepts every flag the connector is started with. Raise it whenever
155a new flag is added.
156
133## OAuth traps157## OAuth traps
134158
135Interactive clients and the headless CLI use the same Clerk application but159Interactive clients and the headless CLI use the same Clerk application but
packages/shared/src/relayClient.test.ts+213-10
...@@ -25,7 +25,8 @@ const layerHostRuntime = (env: Record<string, string> = {}) =>...@@ -25,7 +25,8 @@ const layerHostRuntime = (env: Record<string, string> = {}) =>
25 ConfigProvider.layer(ConfigProvider.fromEnv({ env })),25 ConfigProvider.layer(ConfigProvider.fromEnv({ env })),
26 );26 );
2727
28function makeHandle(exitCode = 0) {28function makeHandle(exitCode = 0, output = "") {
29 const stdout = output ? Stream.make(new TextEncoder().encode(output)) : Stream.empty;
29 return ChildProcessSpawner.makeHandle({30 return ChildProcessSpawner.makeHandle({
30 pid: ChildProcessSpawner.ProcessId(100),31 pid: ChildProcessSpawner.ProcessId(100),
31 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),32 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
...@@ -33,9 +34,9 @@ function makeHandle(exitCode = 0) {...@@ -33,9 +34,9 @@ function makeHandle(exitCode = 0) {
33 kill: () => Effect.void,34 kill: () => Effect.void,
34 unref: Effect.succeed(Effect.void),35 unref: Effect.succeed(Effect.void),
35 stdin: Sink.drain,36 stdin: Sink.drain,
36 stdout: Stream.empty,37 stdout,
37 stderr: Stream.empty,38 stderr: Stream.empty,
38 all: Stream.empty,39 all: stdout,
39 getInputFd: () => Sink.drain,40 getInputFd: () => Sink.drain,
40 getOutputFd: () => Stream.empty,41 getOutputFd: () => Stream.empty,
41 });42 });
...@@ -51,20 +52,45 @@ const layerHttpClient = (bytes: Uint8Array) =>...@@ -51,20 +52,45 @@ const layerHttpClient = (bytes: Uint8Array) =>
51 ),52 ),
52 );53 );
5354
54const layerSpawner = (commands: Array<string>) =>55// Answers `cloudflared version` with the version recorded for that path, which
56// defaults to the pinned release; tests change it to simulate other binaries.
57const layerSpawner = (commands: Array<string>, versions: Record<string, string> = {}) =>
55 Layer.succeed(58 Layer.succeed(
56 ChildProcessSpawner.ChildProcessSpawner,59 ChildProcessSpawner.ChildProcessSpawner,
57 ChildProcessSpawner.make((command) =>60 ChildProcessSpawner.make((command) =>
58 Effect.sync(() => {61 Effect.sync(() => {
59 commands.push(ChildProcess.isStandardCommand(command) ? command.command : "piped-command");62 if (!ChildProcess.isStandardCommand(command)) {
60 // The pinned Windows executable rejects --version but accepts the version subcommand.63 commands.push("piped-command");
61 return makeHandle(64 return makeHandle();
62 ChildProcess.isStandardCommand(command) && command.args.includes("--version") ? 1 : 0,65 }
63 );66 commands.push(command.command);
67 if (command.args[0] === "version") {
68 const version = versions[command.command] ?? RelayClient.CLOUDFLARED_VERSION;
69 return makeHandle(0, `cloudflared version ${version} (built 2026-01-01-00:00 UTC)\n`);
70 }
71 return makeHandle();
64 }),72 }),
65 ),73 ),
66 );74 );
6775
76const writeExecutable = (filePath: string, contents = "cloudflared") =>
77 Effect.gen(function* () {
78 const fileSystem = yield* FileSystem.FileSystem;
79 yield* fileSystem.makeDirectory(filePath.slice(0, filePath.lastIndexOf("/")), {
80 recursive: true,
81 });
82 yield* fileSystem.writeFileString(filePath, contents);
83 yield* fileSystem.chmod(filePath, 0o755);
84 });
85
86type RelayClientTestServices =
87 | Layer.Success<ReturnType<typeof layerHostRuntime> | typeof NodeServices.layer>
88 | HttpClient.HttpClient
89 | ChildProcessSpawner.ChildProcessSpawner;
90
91const managedPathFor = (baseDir: string, version: string) =>
92 `${baseDir}/tools/cloudflared/${version}/linux-x64/cloudflared`;
93
68describe("RelayClient", () => {94describe("RelayClient", () => {
69 it.effect.skipIf(windowsHost)(95 it.effect.skipIf(windowsHost)(
70 "resolves explicit overrides before managed and PATH executables",96 "resolves explicit overrides before managed and PATH executables",
...@@ -220,7 +246,9 @@ describe("RelayClient", () => {...@@ -220,7 +246,9 @@ describe("RelayClient", () => {
220 concurrency: "unbounded",246 concurrency: "unbounded",
221 });247 });
222 expect(second).toEqual(first);248 expect(second).toEqual(first);
223 expect(commands).toHaveLength(1);249 // The first install validates the download once; the second, after waiting
250 // its turn, probes the activated binary and reuses it instead of downloading.
251 expect(commands).toHaveLength(2);
224 }).pipe(252 }).pipe(
225 Effect.scoped,253 Effect.scoped,
226 Effect.provide(254 Effect.provide(
...@@ -278,4 +306,179 @@ describe("RelayClient", () => {...@@ -278,4 +306,179 @@ describe("RelayClient", () => {
278 );306 );
279 },307 },
280 );308 );
309
310 describe("version selection", () => {
311 const run = <A, E>(
312 body: (baseDir: string) => Effect.Effect<A, E, RelayClientTestServices>,
313 options: { readonly versions?: Record<string, string> } = {},
314 ) =>
315 Effect.gen(function* () {
316 const fileSystem = yield* FileSystem.FileSystem;
317 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
318 prefix: "t3-cloudflared-test-",
319 });
320 return yield* body(baseDir);
321 }).pipe(
322 Effect.scoped,
323 Effect.provide(
324 Layer.mergeAll(
325 NodeServices.layer,
326 layerHttpClient(new Uint8Array()),
327 layerSpawner([], options.versions),
328 layerHostRuntime({ PATH: "" }),
329 ),
330 ),
331 );
332
333 it.effect.skipIf(windowsHost)("skips a PATH binary older than the minimum version", () => {
334 const versions: Record<string, string> = {};
335 return run(
336 (baseDir) =>
337 Effect.gen(function* () {
338 const oldPath = `${baseDir}/old/cloudflared`;
339 const newPath = `${baseDir}/new/cloudflared`;
340 yield* writeExecutable(oldPath);
341 yield* writeExecutable(newPath);
342 versions[oldPath] = "2023.8.2";
343 versions[newPath] = RelayClient.CLOUDFLARED_MIN_VERSION;
344 const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir });
345 const env = { PATH: `${baseDir}/old:${baseDir}/new` };
346 expect(
347 yield* manager.resolve.pipe(
348 Effect.provideService(
349 ConfigProvider.ConfigProvider,
350 ConfigProvider.fromEnv({ env }),
351 ),
352 ),
353 ).toEqual({
354 status: "available",
355 executablePath: newPath,
356 source: "path",
357 version: RelayClient.CLOUDFLARED_MIN_VERSION,
358 });
359 }),
360 { versions },
361 );
362 });
363
364 it.effect.skipIf(windowsHost)("reports an outdated override as missing", () => {
365 const versions: Record<string, string> = {};
366 return run(
367 (baseDir) =>
368 Effect.gen(function* () {
369 const overridePath = `${baseDir}/override/cloudflared`;
370 yield* writeExecutable(overridePath);
371 versions[overridePath] = "2025.6.0";
372 const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir });
373 const env = { PATH: "", T3CODE_CLOUDFLARED_PATH: overridePath };
374 const withEnv = Effect.provideService(
375 ConfigProvider.ConfigProvider,
376 ConfigProvider.fromEnv({ env }),
377 );
378 expect(yield* manager.resolve.pipe(withEnv)).toEqual({
379 status: "missing",
380 version: RelayClient.CLOUDFLARED_VERSION,
381 });
382 const error = yield* manager.install.pipe(withEnv, Effect.flip);
383 expect(error.reason).toBe("override_missing");
384 }),
385 { versions },
386 );
387 });
388
389 it.effect.skipIf(windowsHost)(
390 "prefers the pinned release, then the newest older managed release, then PATH",
391 () => {
392 const versions: Record<string, string> = {};
393 return run(
394 (baseDir) =>
395 Effect.gen(function* () {
396 const fileSystem = yield* FileSystem.FileSystem;
397 const pathBinary = `${baseDir}/bin/cloudflared`;
398 const older = managedPathFor(baseDir, "2025.9.0");
399 const newer = managedPathFor(baseDir, "2026.1.0");
400 const pinned = managedPathFor(baseDir, RelayClient.CLOUDFLARED_VERSION);
401 // A newer server sharing this base dir owns this folder.
402 const newerServer = managedPathFor(baseDir, "2099.1.0");
403 versions[newerServer] = "2099.1.0";
404 yield* writeExecutable(newerServer);
405 versions[pathBinary] = "2026.9.3";
406 versions[older] = "2025.9.0";
407 versions[newer] = "2026.1.0";
408 yield* writeExecutable(pathBinary);
409 yield* writeExecutable(older);
410 yield* writeExecutable(newer);
411 const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir });
412 const resolveWith = manager.resolve.pipe(
413 Effect.provideService(
414 ConfigProvider.ConfigProvider,
415 ConfigProvider.fromEnv({ env: { PATH: `${baseDir}/bin` } }),
416 ),
417 );
418
419 expect(yield* resolveWith).toMatchObject({ source: "managed", version: "2026.1.0" });
420
421 yield* writeExecutable(pinned);
422 expect(yield* resolveWith).toMatchObject({
423 executablePath: pinned,
424 source: "managed",
425 version: RelayClient.CLOUDFLARED_VERSION,
426 });
427
428 yield* manager.pruneManagedVersions;
429 expect(
430 (yield* fileSystem.readDirectory(`${baseDir}/tools/cloudflared`)).sort(),
431 ).toEqual(["2026.1.0", RelayClient.CLOUDFLARED_VERSION, "2099.1.0"]);
432
433 // The kept older release still outranks PATH; PATH is the last resort.
434 yield* fileSystem.remove(pinned);
435 expect(yield* resolveWith).toMatchObject({ source: "managed", version: "2026.1.0" });
436 yield* fileSystem.remove(`${baseDir}/tools/cloudflared/2026.1.0`, {
437 recursive: true,
438 });
439 expect(yield* resolveWith).toMatchObject({ source: "path", version: "2026.9.3" });
440 }),
441 { versions },
442 );
443 },
444 );
445
446 it.effect.skipIf(windowsHost)(
447 "keeps a self-updated managed binary as a fallback but still installs the pin",
448 () => {
449 const versions: Record<string, string> = {};
450 return run(
451 (baseDir) =>
452 Effect.gen(function* () {
453 const pinned = managedPathFor(baseDir, RelayClient.CLOUDFLARED_VERSION);
454 versions[pinned] = "2026.9.3";
455 yield* writeExecutable(pinned);
456 const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir });
457 const resolved = yield* manager.resolve;
458 expect(resolved).toEqual({
459 status: "available",
460 executablePath: pinned,
461 source: "managed",
462 version: "2026.9.3",
463 });
464 expect(
465 resolved.status === "available" && RelayClient.isPinnedManagedRelayClient(resolved),
466 ).toBe(false);
467 }),
468 { versions },
469 );
470 },
471 );
472 });
473
474 it("orders and parses cloudflared versions", () => {
475 expect(
476 RelayClient.parseCloudflaredVersionOutput(
477 "cloudflared version 2026.5.2 (built 2026-05-20-10:00 UTC)",
478 ),
479 ).toBe("2026.5.2");
480 expect(RelayClient.parseCloudflaredVersionOutput("Incorrect Usage")).toBeNull();
481 expect(RelayClient.compareCloudflaredVersions("2025.10.0", "2025.6.1")).toBeGreaterThan(0);
482 expect(RelayClient.compareCloudflaredVersions("2023.8.2", "2025.6.1")).toBeLessThan(0);
483 });
281});484});
packages/shared/src/relayClient.ts+197-49
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as Cache from "effect/Cache";
1import * as Clock from "effect/Clock";2import * as Clock from "effect/Clock";
2import type {3import type {
3 RelayClientInstallProgressEvent,4 RelayClientInstallProgressEvent,
...@@ -7,7 +8,9 @@ import * as Config from "effect/Config";...@@ -7,7 +8,9 @@ import * as Config from "effect/Config";
7import * as Context from "effect/Context";8import * as Context from "effect/Context";
8import * as Crypto from "effect/Crypto";9import * as Crypto from "effect/Crypto";
9import * as Data from "effect/Data";10import * as Data from "effect/Data";
11import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
11import * as Hex from "effect/encoding/Hex";14import * as Hex from "effect/encoding/Hex";
12import * as FileSystem from "effect/FileSystem";15import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";16import * as Layer from "effect/Layer";
...@@ -20,6 +23,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process";...@@ -20,6 +23,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process";
20import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts";23import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts";
2124
22export const CLOUDFLARED_VERSION = "2026.5.2";25export const CLOUDFLARED_VERSION = "2026.5.2";
26// The oldest release that accepts every flag the connector is started with
27// (`--output` arrived in 2025.6.1). Override, PATH, and older managed binaries
28// below it are skipped, since they exit immediately on the unknown flag.
29export const CLOUDFLARED_MIN_VERSION = "2025.6.1";
23const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH";30const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH";
2431
25export type RelayClientExecutableSource = "override" | "managed" | "path";32export type RelayClientExecutableSource = "override" | "managed" | "path";
...@@ -96,11 +103,18 @@ const CLOUDFLARED_RELEASE_ASSETS: Readonly<...@@ -96,11 +103,18 @@ const CLOUDFLARED_RELEASE_ASSETS: Readonly<
96 sha256: "20b9638f685333d623798e733effbad2487093f15ba592f6c7752360ff3b7ab7",103 sha256: "20b9638f685333d623798e733effbad2487093f15ba592f6c7752360ff3b7ab7",
97 archive: "binary",104 archive: "binary",
98 },105 },
106 // Cloudflare publishes no Windows ARM64 build; Windows 11 on ARM runs x64 under emulation.
107 "win32-arm64": {
108 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-windows-amd64.exe",
109 sha256: "20b9638f685333d623798e733effbad2487093f15ba592f6c7752360ff3b7ab7",
110 archive: "binary",
111 },
99};112};
100113
101const INSTALL_LOCK_RETRY_COUNT = 100;114const INSTALL_LOCK_RETRY_COUNT = 100;
102const INSTALL_LOCK_RETRY_DELAY = "100 millis";115const INSTALL_LOCK_RETRY_DELAY = "100 millis";
103const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000;116const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000;
117const VERSION_PROBE_TIMEOUT = "10 seconds";
104118
105const trimmedString = (name: string) =>119const trimmedString = (name: string) =>
106 Config.String(name).pipe(120 Config.String(name).pipe(
...@@ -124,11 +138,25 @@ export interface CloudflaredRelayClientOptions {...@@ -124,11 +138,25 @@ export interface CloudflaredRelayClientOptions {
124}138}
125139
126export interface RelayClientShape {140export interface RelayClientShape {
141 /**
142 * Finds the relay client to run, without downloading anything: the override,
143 * else the pinned managed folder, else the newest older managed folder, else
144 * `cloudflared` on PATH. Every candidate must report a compatible version, and
145 * the status carries the version the binary reports.
146 */
127 readonly resolve: Effect.Effect<RelayClientStatus>;147 readonly resolve: Effect.Effect<RelayClientStatus>;
148 /** Installs the pinned managed release unless it, or a valid override, is already present. */
128 readonly install: Effect.Effect<AvailableRelayClient, RelayClientInstallError>;149 readonly install: Effect.Effect<AvailableRelayClient, RelayClientInstallError>;
129 readonly installWithProgress: (150 readonly installWithProgress: (
130 report: (event: RelayClientInstallProgressEvent) => Effect.Effect<void>,151 report: (event: RelayClientInstallProgressEvent) => Effect.Effect<void>,
131 ) => Effect.Effect<AvailableRelayClient, RelayClientInstallError>;152 ) => Effect.Effect<AvailableRelayClient, RelayClientInstallError>;
153 /** Removes managed releases older than the pin, except the newest one. Call once the pin has connected. */
154 readonly pruneManagedVersions: Effect.Effect<void>;
155}
156
157/** True when this is the pinned managed release, which needs no update. */
158export function isPinnedManagedRelayClient(client: AvailableRelayClient): boolean {
159 return client.source === "managed" && client.version === CLOUDFLARED_VERSION;
132}160}
133161
134export class RelayClient extends Context.Service<RelayClient, RelayClientShape>()(162export class RelayClient extends Context.Service<RelayClient, RelayClientShape>()(
...@@ -146,6 +174,33 @@ function resolveReleaseAsset(...@@ -146,6 +174,33 @@ function resolveReleaseAsset(
146 return CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`] ?? null;174 return CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`] ?? null;
147}175}
148176
177const VERSION_PATTERN = /\b(\d{4})\.(\d{1,2})\.(\d+)\b/u;
178
179function parseVersion(value: string): readonly [number, number, number] | null {
180 const match = VERSION_PATTERN.exec(value);
181 return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
182}
183
184/** Orders `YYYY.M.P` cloudflared versions; unparseable versions sort first. */
185export function compareCloudflaredVersions(left: string, right: string): number {
186 const a = parseVersion(left) ?? [0, 0, 0];
187 const b = parseVersion(right) ?? [0, 0, 0];
188 return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
189}
190
191/** Reads the version from `cloudflared version` output, e.g. `cloudflared version 2026.5.2 (built ...)`. */
192export function parseCloudflaredVersionOutput(output: string): string | null {
193 const version = parseVersion(output);
194 return version ? version.join(".") : null;
195}
196
197// A changed file is a new key, so a replaced binary is probed again.
198class VersionProbeKey extends Data.Class<{
199 readonly executablePath: string;
200 readonly size: number;
201 readonly mtimeMillis: number;
202}> {}
203
149function isAlreadyExists(error: PlatformError.PlatformError): boolean {204function isAlreadyExists(error: PlatformError.PlatformError): boolean {
150 return error.reason._tag === "AlreadyExists";205 return error.reason._tag === "AlreadyExists";
151}206}
...@@ -190,14 +245,10 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function...@@ -190,14 +245,10 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function
190 const arch = yield* HostProcessArchitecture;245 const arch = yield* HostProcessArchitecture;
191 const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch);246 const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch);
192 const loadCloudflaredConfig = Effect.suspend(() => CloudflaredConfig).pipe(Effect.orDie);247 const loadCloudflaredConfig = Effect.suspend(() => CloudflaredConfig).pipe(Effect.orDie);
193 const managedPath = path.join(248 const managedRoot = path.join(options.baseDir, "tools", "cloudflared");
194 options.baseDir,249 const managedPathFor = (version: string) =>
195 "tools",250 path.join(managedRoot, version, `${platform}-${arch}`, executableFileName(platform));
196 "cloudflared",251 const managedPath = managedPathFor(CLOUDFLARED_VERSION);
197 CLOUDFLARED_VERSION,
198 `${platform}-${arch}`,
199 executableFileName(platform),
200 );
201252
202 const isExecutableFile = Effect.fn("cloudflared.isExecutableFile")(function* (253 const isExecutableFile = Effect.fn("cloudflared.isExecutableFile")(function* (
203 executablePath: string,254 executablePath: string,
...@@ -207,6 +258,78 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function...@@ -207,6 +258,78 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function
207 return platform === "win32" || (info.value.mode & 0o111) !== 0;258 return platform === "win32" || (info.value.mode & 0o111) !== 0;
208 });259 });
209260
261 // `cloudflared version` costs a process spawn, so answers are cached per file
262 // revision. Before `--no-autoupdate`, a managed binary could replace itself in
263 // place, so the version is read from the binary, never from its folder name.
264 // A failed or timed-out probe is not cached, so a slow spawn under an AV scan
265 // does not hide a good binary until it changes on disk.
266 const versionCache = yield* Cache.makeWith(
267 (key: VersionProbeKey) =>
268 spawner
269 .string(
270 ChildProcess.make(key.executablePath, ["version"], { stdin: "ignore", stderr: "ignore" }),
271 )
272 .pipe(
273 Effect.map(parseCloudflaredVersionOutput),
274 Effect.timeoutOption(VERSION_PROBE_TIMEOUT),
275 Effect.map(Option.getOrNull),
276 Effect.orElseSucceed(() => null),
277 ),
278 {
279 capacity: 16,
280 timeToLive: (exit) =>
281 Exit.isSuccess(exit) && exit.value !== null ? Duration.infinity : Duration.zero,
282 },
283 );
284 const probeVersion = Effect.fn("cloudflared.probeVersion")(function* (executablePath: string) {
285 const info = yield* fileSystem.stat(executablePath).pipe(Effect.option);
286 if (Option.isNone(info) || info.value.type !== "File") return null;
287 if (platform !== "win32" && (info.value.mode & 0o111) === 0) return null;
288 return yield* Cache.get(
289 versionCache,
290 new VersionProbeKey({
291 executablePath,
292 size: Number(info.value.size),
293 mtimeMillis: Option.getOrUndefined(info.value.mtime)?.getTime() ?? 0,
294 }),
295 );
296 });
297
298 const compatibleCandidate = Effect.fn("cloudflared.compatibleCandidate")(function* (
299 executablePath: string,
300 source: RelayClientExecutableSource,
301 expectedVersion?: string,
302 ) {
303 const version = yield* probeVersion(executablePath);
304 if (version === null || compareCloudflaredVersions(version, CLOUDFLARED_MIN_VERSION) < 0) {
305 return null;
306 }
307 if (expectedVersion !== undefined && version !== expectedVersion) {
308 yield* Effect.logWarning("Ignoring a managed relay client that reports another version", {
309 executablePath,
310 expectedVersion,
311 version,
312 });
313 return null;
314 }
315 return { status: "available", executablePath, source, version } satisfies AvailableRelayClient;
316 });
317
318 // Managed releases older than the pin, newest first. Newer folders belong to
319 // a newer server sharing this base dir and are never used or pruned here.
320 const olderManagedVersions = Effect.gen(function* () {
321 const entries = yield* fileSystem
322 .readDirectory(managedRoot)
323 .pipe(Effect.orElseSucceed(() => []));
324 return entries
325 .filter(
326 (entry) =>
327 parseVersion(entry) !== null &&
328 compareCloudflaredVersions(entry, CLOUDFLARED_VERSION) < 0,
329 )
330 .sort((left, right) => compareCloudflaredVersions(right, left));
331 });
332
210 const resolvePathExecutable = Effect.gen(function* () {333 const resolvePathExecutable = Effect.gen(function* () {
211 const config = yield* loadCloudflaredConfig;334 const config = yield* loadCloudflaredConfig;
212 const pathValue = Option.getOrUndefined(config.path);335 const pathValue = Option.getOrUndefined(config.path);
...@@ -216,49 +339,63 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function...@@ -216,49 +339,63 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function
216 const trimmed = directory.trim().replace(/^"|"$/gu, "");339 const trimmed = directory.trim().replace(/^"|"$/gu, "");
217 if (trimmed.length === 0) continue;340 if (trimmed.length === 0) continue;
218 const candidate = path.join(trimmed, executableFileName(platform));341 const candidate = path.join(trimmed, executableFileName(platform));
219 if (yield* isExecutableFile(candidate)) return candidate;342 if (!(yield* isExecutableFile(candidate))) continue;
343 const available = yield* compatibleCandidate(candidate, "path");
344 if (available) return available;
345 yield* Effect.logWarning("Skipping an incompatible relay client on PATH", {
346 executablePath: candidate,
347 minimumVersion: CLOUDFLARED_MIN_VERSION,
348 });
220 }349 }
221 return null;350 return null;
222 });351 });
223352
353 const missingStatus: RelayClientStatus = { status: "missing", version: CLOUDFLARED_VERSION };
354 const unsupportedStatus: RelayClientStatus = {
355 status: "unsupported",
356 platform,
357 arch,
358 version: CLOUDFLARED_VERSION,
359 };
224 const resolve: RelayClientShape["resolve"] = Effect.gen(function* () {360 const resolve: RelayClientShape["resolve"] = Effect.gen(function* () {
225 const config = yield* loadCloudflaredConfig;361 const config = yield* loadCloudflaredConfig;
226 if (Option.isSome(config.executableOverride)) {362 if (Option.isSome(config.executableOverride)) {
227 return (yield* isExecutableFile(config.executableOverride.value))363 const override = yield* compatibleCandidate(config.executableOverride.value, "override");
228 ? {364 if (override) return override;
229 status: "available",365 yield* Effect.logWarning(
230 executablePath: config.executableOverride.value,366 `${CLOUDFLARED_PATH_ENV_NAME} must point to cloudflared ${CLOUDFLARED_MIN_VERSION} or newer`,
231 source: "override",367 { executablePath: config.executableOverride.value },
232 version: CLOUDFLARED_VERSION,368 );
233 }369 return missingStatus;
234 : { status: "missing", version: CLOUDFLARED_VERSION };
235 }370 }
236 if (yield* isExecutableFile(managedPath)) {371 // A managed binary reports the version it actually runs. One that replaced
237 return {372 // itself in place still serves as a fallback; the runtime sees it is not the
238 status: "available",373 // pinned release and installs that.
239 executablePath: managedPath,374 const pinned = yield* compatibleCandidate(managedPath, "managed");
240 source: "managed",375 if (pinned) return pinned;
241 version: CLOUDFLARED_VERSION,376 for (const version of yield* olderManagedVersions) {
242 };377 const older = yield* compatibleCandidate(managedPathFor(version), "managed");
378 if (older) return older;
243 }379 }
244 const pathExecutable = yield* resolvePathExecutable;380 const pathExecutable = yield* resolvePathExecutable;
245 if (pathExecutable) {381 if (pathExecutable) return pathExecutable;
246 return {382 return releaseAsset ? missingStatus : unsupportedStatus;
247 status: "available",383 }).pipe(Effect.withSpan("cloudflared.resolve"));
248 executablePath: pathExecutable,384
249 source: "path",385 const pruneManagedVersions: RelayClientShape["pruneManagedVersions"] = Effect.gen(function* () {
250 version: CLOUDFLARED_VERSION,386 // The newest older release stays: an older server sharing this base dir, or a
251 };387 // rollback after a failed update, may still need it and cannot redownload it.
388 for (const version of (yield* olderManagedVersions).slice(1)) {
389 // A connector from another server sharing this base dir may still run an
390 // older release; Windows refuses to delete it, so the next prune retries.
391 yield* fileSystem.remove(path.join(managedRoot, version), { recursive: true }).pipe(
392 Effect.tap(() => Effect.logInfo("Removed an older managed relay client", { version })),
393 Effect.catch((cause) =>
394 Effect.logDebug("Could not remove an older managed relay client", { version, cause }),
395 ),
396 );
252 }397 }
253 return releaseAsset398 }).pipe(Effect.withSpan("cloudflared.pruneManagedVersions"));
254 ? { status: "missing", version: CLOUDFLARED_VERSION }
255 : {
256 status: "unsupported",
257 platform,
258 arch,
259 version: CLOUDFLARED_VERSION,
260 };
261 });
262399
263 const runCommand = Effect.fn("cloudflared.runCommand")(function* (400 const runCommand = Effect.fn("cloudflared.runCommand")(function* (
264 command: string,401 command: string,
...@@ -354,15 +491,17 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function...@@ -354,15 +491,17 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function
354 report: (stage: RelayClientInstallProgressStage) => Effect.Effect<void>,491 report: (stage: RelayClientInstallProgressStage) => Effect.Effect<void>,
355 ) {492 ) {
356 yield* report("checking");493 yield* report("checking");
357 const existing = yield* resolve;
358 if (existing.status === "available") return existing;
359 const config = yield* loadCloudflaredConfig;494 const config = yield* loadCloudflaredConfig;
360 if (Option.isSome(config.executableOverride)) {495 if (Option.isSome(config.executableOverride)) {
496 const override = yield* compatibleCandidate(config.executableOverride.value, "override");
497 if (override) return override;
361 return yield* new RelayClientInstallError({498 return yield* new RelayClientInstallError({
362 reason: "override_missing",499 reason: "override_missing",
363 message: `${CLOUDFLARED_PATH_ENV_NAME} does not point to an executable file.`,500 message: `${CLOUDFLARED_PATH_ENV_NAME} must point to cloudflared ${CLOUDFLARED_MIN_VERSION} or newer.`,
364 });501 });
365 }502 }
503 const existing = yield* compatibleCandidate(managedPath, "managed", CLOUDFLARED_VERSION);
504 if (existing) return existing;
366 if (!releaseAsset) {505 if (!releaseAsset) {
367 return yield* new RelayClientInstallError({506 return yield* new RelayClientInstallError({
368 reason: "unsupported_platform",507 reason: "unsupported_platform",
...@@ -391,8 +530,8 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function...@@ -391,8 +530,8 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function
391 }),530 }),
392 );531 );
393 return yield* Effect.gen(function* () {532 return yield* Effect.gen(function* () {
394 const afterLock = yield* resolve;533 const afterLock = yield* compatibleCandidate(managedPath, "managed", CLOUDFLARED_VERSION);
395 if (afterLock.status === "available") return afterLock;534 if (afterLock) return afterLock;
396535
397 const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({536 const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({
398 directory: managedDirectory,537 directory: managedDirectory,
...@@ -420,9 +559,18 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function...@@ -420,9 +559,18 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function
420 .pipe(wrapInstallFailure("write_failed", "Could not make the relay client executable."));559 .pipe(wrapInstallFailure("write_failed", "Could not make the relay client executable."));
421 }560 }
422 yield* report("validating");561 yield* report("validating");
423 yield* runCommand(executablePath, ["version"]).pipe(562 // Requiring the pinned version here keeps a mislabelled asset from being
424 wrapInstallFailure("validation_failed", "The downloaded relay client binary did not run."),563 // activated, ignored by resolve, and downloaded again on every reconcile.
425 );564 const installedVersion = yield* probeVersion(executablePath);
565 if (installedVersion !== CLOUDFLARED_VERSION) {
566 return yield* new RelayClientInstallError({
567 reason: "validation_failed",
568 message:
569 installedVersion === null
570 ? "The downloaded relay client binary did not run."
571 : `The downloaded relay client reports version ${installedVersion}, not ${CLOUDFLARED_VERSION}.`,
572 });
573 }
426574
427 const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`;575 const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`;
428 yield* report("activating");576 yield* report("activating");
...@@ -468,7 +616,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function...@@ -468,7 +616,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function
468 );616 );
469 const install = installWithProgress(() => Effect.void);617 const install = installWithProgress(() => Effect.void);
470618
471 return RelayClient.of({ resolve, install, installWithProgress });619 return RelayClient.of({ resolve, install, installWithProgress, pruneManagedVersions });
472});620});
473621
474export const layerCloudflared = (options: CloudflaredRelayClientOptions) =>622export const layerCloudflared = (options: CloudflaredRelayClientOptions) =>