authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 18:10:51-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 18:10:51-07:00
logc4481b9b07d9108d3aa93494a0ec5dd28ed8fd80
tree71128b03438be5a14822d029309d1148cb720268
parentf8ed2a039b7deaa324beda29b44cc74c846311f7
signature Signed by PGP key B5690EEEBB952194

fix(relay): a host restarting onto a deleted tunnel gets a new one (#16649)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 53 insertions(+), 2 deletions(-)

infra/relay/src/environments/ManagedEndpointProvider.test.ts+27-2
...@@ -1118,7 +1118,7 @@ describe("ManagedEndpointProvider", () => {...@@ -1118,7 +1118,7 @@ describe("ManagedEndpointProvider", () => {
1118 }).pipe(Effect.provide(layer));1118 }).pipe(Effect.provide(layer));
1119 });1119 });
11201120
1121 it.effect("does no Cloudflare work when the registered origin is unchanged", () => {1121 it.effect("only confirms the tunnel exists when the registered origin is unchanged", () => {
1122 const tunnelCalls: TunnelCall[] = [];1122 const tunnelCalls: TunnelCall[] = [];
1123 const layer = layerProvider(makePersistentTunnelClient(tunnelCalls));1123 const layer = layerProvider(makePersistentTunnelClient(tunnelCalls));
11241124
...@@ -1137,7 +1137,31 @@ describe("ManagedEndpointProvider", () => {...@@ -1137,7 +1137,31 @@ describe("ManagedEndpointProvider", () => {
1137 endpoint: provisioned.endpoint,1137 endpoint: provisioned.endpoint,
1138 }),1138 }),
1139 ).toBe("ready");1139 ).toBe("ready");
1140 expect(tunnelCalls).toEqual([]);1140 expect(tunnelCalls).toEqual([{ operation: "get", input: "tunnel-id" }]);
1141 }).pipe(Effect.provide(layer));
1142 });
1143
1144 it.effect("asks for recovery when the recorded tunnel was deleted", () => {
1145 const tunnelCalls: TunnelCall[] = [];
1146 const layer = layerProvider(makePersistentTunnelClient(tunnelCalls));
1147
1148 return Effect.gen(function* () {
1149 const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
1150 const key = { userId: "user_ABC", environmentId: "env_ABC" } as const;
1151 const origin = { localHttpHost: "127.0.0.1", localHttpPort: 3773 } as const;
1152 const provisioned = yield* provider.provision({ ...key, origin });
1153 // A shutdown release deletes the tunnel but keeps the recorded id; the
1154 // host was killed before it dropped its stored config.
1155 expect(yield* provider.release(key)).toBe(true);
1156
1157 expect(
1158 yield* provider.reconcileOrigin({
1159 ...key,
1160 tunnelId: provisioned.runtime.tunnelId!,
1161 origin,
1162 endpoint: provisioned.endpoint,
1163 }),
1164 ).toBe("recovery_required");
1141 }).pipe(Effect.provide(layer));1165 }).pipe(Effect.provide(layer));
1142 });1166 });
11431167
...@@ -1163,6 +1187,7 @@ describe("ManagedEndpointProvider", () => {...@@ -1163,6 +1187,7 @@ describe("ManagedEndpointProvider", () => {
1163 }),1187 }),
1164 ).toBe("ready");1188 ).toBe("ready");
1165 expect(tunnelCalls).toEqual([1189 expect(tunnelCalls).toEqual([
1190 { operation: "get", input: "tunnel-id" },
1166 {1191 {
1167 operation: "putConfiguration",1192 operation: "putConfiguration",
1168 input: {1193 input: {
infra/relay/src/environments/ManagedEndpointProvider.ts+26
...@@ -55,6 +55,7 @@ const ManagedEndpointProvisioningStage = Schema.Literals([...@@ -55,6 +55,7 @@ const ManagedEndpointProvisioningStage = Schema.Literals([
55 "mark-allocation-ready",55 "mark-allocation-ready",
56 "load-allocation",56 "load-allocation",
57 "verify-endpoint",57 "verify-endpoint",
58 "verify-tunnel",
58 "sync-origin",59 "sync-origin",
59]);60]);
6061
...@@ -208,6 +209,7 @@ export interface ManagedEndpointTunnel {...@@ -208,6 +209,7 @@ export interface ManagedEndpointTunnel {
208 readonly status?: string | null;209 readonly status?: string | null;
209 readonly createdAt?: string | null;210 readonly createdAt?: string | null;
210 readonly connsInactiveAt?: string | null;211 readonly connsInactiveAt?: string | null;
212 readonly deletedAt?: string | null;
211}213}
212214
213export interface ManagedEndpointTunnelListRequest {215export interface ManagedEndpointTunnelListRequest {
...@@ -581,6 +583,30 @@ export const make = Effect.gen(function* () {...@@ -581,6 +583,30 @@ export const make = Effect.gen(function* () {
581 hostname: allocation.hostname,583 hostname: allocation.hostname,
582 });584 });
583 }585 }
586 // A release keeps the recorded tunnel id, so the record alone cannot
587 // tell a live tunnel from one deleted by a shutdown whose host was
588 // killed before it dropped its config. Ask Cloudflare, or the host
589 // starts a connector that can never connect.
590 const recorded = yield* tunnels.get(input.tunnelId).pipe(
591 Effect.asSome,
592 Effect.catchTags({
593 ManagedEndpointTunnelClientError: (cause) =>
594 isManagedEndpointNotFound(cause.cause)
595 ? Effect.succeedNone
596 : Effect.fail(
597 new ManagedEndpointProvisioningFailed({
598 userId: input.userId,
599 environmentId: input.environmentId,
600 stage: "verify-tunnel",
601 tunnelId: input.tunnelId,
602 cause,
603 }),
604 ),
605 }),
606 );
607 if (Option.isNone(recorded) || recorded.value.deletedAt) {
608 return "recovery_required";
609 }
584 if (610 if (
585 allocation.origin?.localHttpHost === input.origin.localHttpHost &&611 allocation.origin?.localHttpHost === input.origin.localHttpHost &&
586 allocation.origin.localHttpPort === input.origin.localHttpPort612 allocation.origin.localHttpPort === input.origin.localHttpPort