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", () => {
11181118 }).pipe(Effect.provide(layer));
11191119 });
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", () => {
11221122 const tunnelCalls: TunnelCall[] = [];
11231123 const layer = layerProvider(makePersistentTunnelClient(tunnelCalls));
11241124
......@@ -1137,7 +1137,31 @@ describe("ManagedEndpointProvider", () => {
11371137 endpoint: provisioned.endpoint,
11381138 }),
11391139 ).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");
11411165 }).pipe(Effect.provide(layer));
11421166 });
11431167
......@@ -1163,6 +1187,7 @@ describe("ManagedEndpointProvider", () => {
11631187 }),
11641188 ).toBe("ready");
11651189 expect(tunnelCalls).toEqual([
1190 { operation: "get", input: "tunnel-id" },
11661191 {
11671192 operation: "putConfiguration",
11681193 input: {
infra/relay/src/environments/ManagedEndpointProvider.ts+26
......@@ -55,6 +55,7 @@ const ManagedEndpointProvisioningStage = Schema.Literals([
5555 "mark-allocation-ready",
5656 "load-allocation",
5757 "verify-endpoint",
58 "verify-tunnel",
5859 "sync-origin",
5960]);
6061
......@@ -208,6 +209,7 @@ export interface ManagedEndpointTunnel {
208209 readonly status?: string | null;
209210 readonly createdAt?: string | null;
210211 readonly connsInactiveAt?: string | null;
212 readonly deletedAt?: string | null;
211213}
212214
213215export interface ManagedEndpointTunnelListRequest {
......@@ -581,6 +583,30 @@ export const make = Effect.gen(function* () {
581583 hostname: allocation.hostname,
582584 });
583585 }
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 }
584610 if (
585611 allocation.origin?.localHttpHost === input.origin.localHttpHost &&
586612 allocation.origin.localHttpPort === input.origin.localHttpPort