| author | |
| committer | |
| log | cfa4f765ec05950a032b6c1cf9cdfff0c2391545 |
| tree | 9c8f7ceff55ffd3d5cff06098f899eb5d6bfb0e1 |
| parent | ca952677321abf9f3762ae4bb0129454e5a63955 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>2 files changed, 59 insertions(+), 2 deletions(-)
infra/relay/src/environments/ManagedEndpointProvider.test.ts+30| ... | @@ -1534,6 +1534,36 @@ describe("ManagedEndpointProvider", () => { | ... | @@ -1534,6 +1534,36 @@ describe("ManagedEndpointProvider", () => { |
| 1534 | }).pipe(Effect.provide(layer)); | 1534 | }).pipe(Effect.provide(layer)); |
| 1535 | }); | 1535 | }); |
| 1536 | 1536 | ||
| 1537 | it.effect("reports a tunnel with an attached connector as not released", () => { | ||
| 1538 | const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ | ||
| 1539 | ...makeTunnelClient(), | ||
| 1540 | delete: (tunnelId) => | ||
| 1541 | Effect.fail( | ||
| 1542 | new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ | ||
| 1543 | operation: "delete", | ||
| 1544 | tunnelId, | ||
| 1545 | cause: { | ||
| 1546 | _tag: "BadRequest", | ||
| 1547 | code: 1022, | ||
| 1548 | message: | ||
| 1549 | "This tunnel has active connections. Please stop all cloudflared replicas, or wait a few minutes for connections to close, then try again.", | ||
| 1550 | }, | ||
| 1551 | }), | ||
| 1552 | ), | ||
| 1553 | }); | ||
| 1554 | const layer = providerLayer(tunnelClient, makeDnsClient(), makeAllocations()); | ||
| 1555 | |||
| 1556 | return Effect.gen(function* () { | ||
| 1557 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 1558 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; | ||
| 1559 | yield* provider.provision({ | ||
| 1560 | ...key, | ||
| 1561 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | ||
| 1562 | }); | ||
| 1563 | expect(yield* provider.release(key)).toBe(false); | ||
| 1564 | }).pipe(Effect.provide(layer)); | ||
| 1565 | }); | ||
| 1566 | |||
| 1537 | it.effect("treats an absent allocation as already deprovisioned", () => { | 1567 | it.effect("treats an absent allocation as already deprovisioned", () => { |
| 1538 | const tunnelCalls: TunnelCall[] = []; | 1568 | const tunnelCalls: TunnelCall[] = []; |
| 1539 | const dnsCalls: DnsCall[] = []; | 1569 | const dnsCalls: DnsCall[] = []; |
infra/relay/src/environments/ManagedEndpointProvider.ts+29-2| ... | @@ -395,6 +395,25 @@ export function isManagedEndpointNotFound(cause: unknown): boolean { | ... | @@ -395,6 +395,25 @@ export function isManagedEndpointNotFound(cause: unknown): boolean { |
| 395 | return "cause" in cause && isManagedEndpointNotFound(cause.cause); | 395 | return "cause" in cause && isManagedEndpointNotFound(cause.cause); |
| 396 | } | 396 | } |
| 397 | 397 | ||
| 398 | /** | ||
| 399 | * Cloudflare refuses to delete a tunnel while a connector is still attached, | ||
| 400 | * either one that has not finished draining or another runtime still serving | ||
| 401 | * the tunnel. | ||
| 402 | */ | ||
| 403 | export function isManagedEndpointTunnelInUse(cause: unknown): boolean { | ||
| 404 | if (typeof cause !== "object" || cause === null) { | ||
| 405 | return false; | ||
| 406 | } | ||
| 407 | if ( | ||
| 408 | "message" in cause && | ||
| 409 | typeof cause.message === "string" && | ||
| 410 | cause.message.includes("has active connections") | ||
| 411 | ) { | ||
| 412 | return true; | ||
| 413 | } | ||
| 414 | return "cause" in cause && isManagedEndpointTunnelInUse(cause.cause); | ||
| 415 | } | ||
| 416 | |||
| 398 | type ManagedEndpointClientError = ManagedEndpointTunnelClientError | ManagedEndpointDnsClientError; | 417 | type ManagedEndpointClientError = ManagedEndpointTunnelClientError | ManagedEndpointDnsClientError; |
| 399 | 418 | ||
| 400 | const ignoreNotFound = <A>( | 419 | const ignoreNotFound = <A>( |
| ... | @@ -873,8 +892,16 @@ export const make = Effect.gen(function* () { | ... | @@ -873,8 +892,16 @@ export const make = Effect.gen(function* () { |
| 873 | if (finalGeneration === null) { | 892 | if (finalGeneration === null) { |
| 874 | return false; | 893 | return false; |
| 875 | } | 894 | } |
| 876 | yield* deleteTunnel; | 895 | // A connector still attached means the tunnel is not released. That |
| 877 | return true; | 896 | // is the same answer as losing the claim: the caller keeps its config, |
| 897 | // and the reaper deletes the tunnel once it has been down long enough. | ||
| 898 | return yield* deleteTunnel.pipe( | ||
| 899 | Effect.as(true), | ||
| 900 | Effect.catchIf( | ||
| 901 | (error) => isManagedEndpointTunnelInUse(error.cause), | ||
| 902 | () => Effect.succeed(false), | ||
| 903 | ), | ||
| 904 | ); | ||
| 878 | }), | 905 | }), |
| 879 | ) | 906 | ) |
| 880 | .pipe( | 907 | .pipe( |