diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index 94675125ded6..38248f480540 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -1534,6 +1534,36 @@ describe("ManagedEndpointProvider", () => { }).pipe(Effect.provide(layer)); }); + it.effect("reports a tunnel with an attached connector as not released", () => { + const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ + ...makeTunnelClient(), + delete: (tunnelId) => + Effect.fail( + new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ + operation: "delete", + tunnelId, + cause: { + _tag: "BadRequest", + code: 1022, + message: + "This tunnel has active connections. Please stop all cloudflared replicas, or wait a few minutes for connections to close, then try again.", + }, + }), + ), + }); + const layer = providerLayer(tunnelClient, makeDnsClient(), makeAllocations()); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; + yield* provider.provision({ + ...key, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + expect(yield* provider.release(key)).toBe(false); + }).pipe(Effect.provide(layer)); + }); + it.effect("treats an absent allocation as already deprovisioned", () => { const tunnelCalls: TunnelCall[] = []; const dnsCalls: DnsCall[] = []; diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index 10b6ae2d88a4..2192b47e1fd3 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -395,6 +395,25 @@ export function isManagedEndpointNotFound(cause: unknown): boolean { return "cause" in cause && isManagedEndpointNotFound(cause.cause); } +/** + * Cloudflare refuses to delete a tunnel while a connector is still attached, + * either one that has not finished draining or another runtime still serving + * the tunnel. + */ +export function isManagedEndpointTunnelInUse(cause: unknown): boolean { + if (typeof cause !== "object" || cause === null) { + return false; + } + if ( + "message" in cause && + typeof cause.message === "string" && + cause.message.includes("has active connections") + ) { + return true; + } + return "cause" in cause && isManagedEndpointTunnelInUse(cause.cause); +} + type ManagedEndpointClientError = ManagedEndpointTunnelClientError | ManagedEndpointDnsClientError; const ignoreNotFound = ( @@ -873,8 +892,16 @@ export const make = Effect.gen(function* () { if (finalGeneration === null) { return false; } - yield* deleteTunnel; - return true; + // A connector still attached means the tunnel is not released. That + // is the same answer as losing the claim: the caller keeps its config, + // and the reaper deletes the tunnel once it has been down long enough. + return yield* deleteTunnel.pipe( + Effect.as(true), + Effect.catchIf( + (error) => isManagedEndpointTunnelInUse(error.cause), + () => Effect.succeed(false), + ), + ); }), ) .pipe(