Skip to content

fix(connect): remove tunnels after hosts go offline - #8153

Open
t3dotgg wants to merge 2 commits into
mainfrom
t3code/expire-cloudflare-tunnels
Open

fix(connect): remove tunnels after hosts go offline#8153
t3dotgg wants to merge 2 commits into
mainfrom
t3code/expire-cloudflare-tunnels

Conversation

@t3dotgg

@t3dotgg t3dotgg commented Aug 25, 2026

Copy link
Copy Markdown
Member

Cloudflare tunnels stayed provisioned after an environment crashed or went offline. Environments linked through web or mobile could not recreate a deleted tunnel.

The relay now removes tunnels that stay disconnected for at least five minutes. Updated hosts recreate tunnels under the same URL, while older hosts remain protected until they register recovery support.

Verified with 146 focused tests, 129 server integration tests, and typechecks for the relay, server, shared client runtime, web app, and mobile app.

Built by GPT-5.6 Sol with the Codex harness.


Note

Medium Risk
Changes managed tunnel provisioning, release races, and credential-backed recovery across server and relay; incorrect locking or stale release claims could delete live tunnels or apply wrong config.

Overview
Linked environments can automatically get a replacement Cloudflare tunnel after a crash or long offline period, without re-pairing, while keeping the same public URL.

On the T3 server, persisted link state and tunnel config updates run under a withLinkStateLock semaphore. Saving relay config can requestRecovery; when cloudflared exits, the managed runtime publishes a recovery signal. Startup (and that stream) runs recoverManagedCloudTunnel, which calls the relay with the stored environment credential and applies the returned runtime config only if local persisted config still matches.

On the relay, POST /v1/environments/:environmentId/tunnel provisions a new tunnel for an authenticated environment, verifies the endpoint URL is unchanged, and sets recovery_enabled_at on the allocation. A ManagedEndpointReaper cron (every 5 minutes) deletes down/inactive stage tunnels past a 5-minute grace period—orphans directly, recoverable allocations via release with expected tunnel id, status, and inactive timestamp checks so reconnects or newer provisions are not torn down. Legacy allocations without recovery support are skipped until they complete a recovery request.

Contracts and docs describe the lifecycle; claimRelease now returns a claim generation timestamp instead of a bare boolean.

Reviewed by Cursor Bugbot for commit 26c5820. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Remove managed tunnels after hosts go offline and add recovery on reconnect

  • Adds a ManagedEndpointReaper sweep job that scans Cloudflare tunnels with down/inactive status past a 5-minute grace period and deletes orphans or releases recoverable tunnels via provider.release with tunnel-id/inactivity guards
  • Introduces a relay recovery endpoint POST /v1/environments/:environmentId/tunnel (Api.ts) that provisions a replacement tunnel for a linked environment, plus server-side recoverManagedCloudTunnel that subscribes to recoveryRequests and retries with exponential backoff
  • Adds a recoveryEnabledAt column to relay_managed_endpoint_allocations (migration.sql) and enableRecovery/listByTunnelNames methods on ManagedEndpointAllocations\n- Serializes cloud link-state mutations under withLinkStateLock (a semaphore) in applyCloudRelayConfig, unlink, and recovery flows to prevent races
  • Behavioral Change: ManagedEndpointProvider.deprovision and release now return boolean instead of void; claimRelease returns a claim timestamp string | null instead of boolean. Callers like unlinkEnvironmentRecord retry deprovision when the first attempt returns false and the link is already removed. The relay cron job now runs reaper sweep concurrently with existing pruning.

Macroscope summarized 26c5820.

@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b7fbacb6-ba39-4a73-9205-521cbf0505d9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 25, 2026
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 13.3 KiB 13.3 KiB −32 B (−0.2%) 15.1 KiB
Codex Thread snapshot wire 6.9 KiB 6.9 KiB −5 B (−0.1%) 7.3 KiB
Codex Live turn WebSocket wire 6.5 KiB 6.4 KiB −27 B (−0.4%) 7.8 KiB
Codex Live turn WebSocket decoded 55.6 KiB 55.6 KiB 0 B (0.0%) 66.4 KiB
Codex Live turn messages 11 11 0 (0.0%) 21
Claude Total thread wire 13.3 KiB 13.3 KiB −5 B (−0.0%) 15.1 KiB
Claude Thread snapshot wire 6.9 KiB 6.9 KiB −5 B (−0.1%) 7.3 KiB
Claude Live turn WebSocket wire 6.4 KiB 6.4 KiB 0 B (0.0%) 7.8 KiB
Claude Live turn WebSocket decoded 56.4 KiB 56.4 KiB 0 B (0.0%) 66.4 KiB
Claude Live turn messages 11 11 0 (0.0%) 21

Baseline: c034f51 · PR result: 26c5820 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 109.4 KiB
  • Claude decoded thread snapshot: 110.1 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

Comment thread apps/server/src/server.ts
Comment thread infra/relay/src/environments/ManagedEndpointReaper.ts Outdated
Comment thread apps/server/src/server.ts
Comment thread infra/relay/src/environments/ManagedEndpointReaper.ts Outdated
Comment thread infra/relay/src/environments/ManagedEndpointAllocations.ts
Comment thread infra/relay/src/environments/ManagedEndpointReaper.ts
Comment thread apps/server/src/cloud/http.ts Outdated
Comment thread infra/relay/src/http/Api.ts
Comment thread infra/relay/src/environments/ManagedEndpointReaper.ts Outdated
Comment thread infra/relay/src/worker.ts
@macroscopeapp

macroscopeapp Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This change adds a new managed-tunnel recovery workflow, persistent recovery state, startup retries, and automated Cloudflare tunnel deletion, with significant lifecycle and authorization-sensitive side effects. Unresolved findings also identify concurrency hazards around tunnel deletion and recovery retries, requiring human assessment.

Not approved because:

  • 4 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effect service conventions: four findings, all in newly added error-handling code (three Effect.catchTag usages and one startup Effect.catchCause). Details inline.

Posted via Macroscope — Effect Service Conventions

Comment on lines +98 to +102
Effect.catchTag("ManagedEndpointTunnelClientError", (error) =>
ManagedEndpointProvider.isManagedEndpointNotFound(error.cause)
? Effect.succeed(true)
: Effect.fail(error),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here: prefer Effect.catchTags({ ... }) over Effect.catchTag.

Suggested change
Effect.catchTag("ManagedEndpointTunnelClientError", (error) =>
ManagedEndpointProvider.isManagedEndpointNotFound(error.cause)
? Effect.succeed(true)
: Effect.fail(error),
),
Effect.catchTags({
ManagedEndpointTunnelClientError: (error) =>
ManagedEndpointProvider.isManagedEndpointNotFound(error.cause)
? Effect.succeed(true)
: Effect.fail(error),
}),

Posted via Macroscope — Effect Service Conventions

Comment thread apps/server/src/server.ts
Comment on lines +641 to +643
Effect.catchCause((cause) =>
Effect.logWarning("Failed to recover the T3 Connect managed tunnel", { cause }),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effect.catchCause also absorbs interruption, so shutdown-time interruption of this startup/recovery effect (it is also driven by the forked recoveryRequests loop) is downgraded to a warning and reported as success. Suggest handling only the typed error channel with Effect.catch, matching the adjacent reconcileDesiredCloudLink block.

Suggested change
Effect.catchCause((cause) =>
Effect.logWarning("Failed to recover the T3 Connect managed tunnel", { cause }),
),
Effect.catch((cause) =>
Effect.logWarning("Failed to recover the T3 Connect managed tunnel", { cause }),
),

Posted via Macroscope — Effect Service Conventions

Comment on lines +81 to +85
Effect.catchTag("ManagedEndpointTunnelClientError", (error) =>
ManagedEndpointProvider.isManagedEndpointNotFound(error.cause)
? Effect.succeed(Option.none())
: Effect.fail(error),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Convention: catch statically known tagged failures with Effect.catchTags({ ... }), even for a single tag — Effect.catchTag should not be used in new code.

Suggested change
Effect.catchTag("ManagedEndpointTunnelClientError", (error) =>
ManagedEndpointProvider.isManagedEndpointNotFound(error.cause)
? Effect.succeed(Option.none())
: Effect.fail(error),
),
Effect.catchTags({
ManagedEndpointTunnelClientError: (error) =>
ManagedEndpointProvider.isManagedEndpointNotFound(error.cause)
? Effect.succeed(Option.none())
: Effect.fail(error),
}),

Posted via Macroscope — Effect Service Conventions

Comment on lines +637 to +648
Effect.catchTag("ManagedEndpointTunnelClientError", (cause) =>
isManagedEndpointNotFound(cause.cause)
? Effect.succeed(Option.none())
: Effect.fail(
new ManagedEndpointDeprovisioningFailed({
...input,
stage: "load-tunnel",
tunnelId,
cause,
}),
),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New tagged-failure handling should use Effect.catchTags({ ... }) (this module already uses it elsewhere) rather than Effect.catchTag.

Suggested change
Effect.catchTag("ManagedEndpointTunnelClientError", (cause) =>
isManagedEndpointNotFound(cause.cause)
? Effect.succeed(Option.none())
: Effect.fail(
new ManagedEndpointDeprovisioningFailed({
...input,
stage: "load-tunnel",
tunnelId,
cause,
}),
),
),
Effect.catchTags({
ManagedEndpointTunnelClientError: (cause) =>
isManagedEndpointNotFound(cause.cause)
? Effect.succeed(Option.none())
: Effect.fail(
new ManagedEndpointDeprovisioningFailed({
...input,
stage: "load-tunnel",
tunnelId,
cause,
}),
),
}),

Posted via Macroscope — Effect Service Conventions

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 26c5820. Configure here.

) {
return false;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Release deletes after stale checks

High Severity

release refreshes allocation generation and Cloudflare status after claimRelease, but still deletes without a final generation claim. recordTunnel updates tunnelId/updatedAt with no CAS on the claimed generation, so a concurrent provision can adopt the same tunnel after the refresh and before tunnels.delete, then receive credentials for a tunnel the reaper has just removed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 26c5820. Configure here.

Comment thread apps/server/src/server.ts
Effect.retry({
while: (error) =>
error._tag !== "EnvironmentHttpBadRequestError" &&
error._tag !== "EnvironmentCloudEndpointUnavailableError",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recovery aborts on start failure

Medium Severity

The new recovery retry while filter stops on EnvironmentCloudEndpointUnavailableError, which recoverManagedCloudTunnel throws when applyConfig returns a non-running status after the relay has already provisioned. Transient connector start failures (missing binary, spawn glitch) therefore end recovery immediately instead of retrying for up to ten minutes, and with no connector running nothing republishes a recovery request.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 26c5820. Configure here.

break;
}
const allocation = recordedByTunnelName.get(tunnel.name);
if (allocation !== undefined && allocation.tunnelId !== tunnel.id) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium environments/ManagedEndpointReaper.ts:169

The reaper permanently skips Cloudflare tunnels whose same-name allocation has tunnelId === null, leaving tunnels created before a crash or persistence failure undeleted and still billed. The mismatch check should only skip allocations with a recorded, different tunnel ID so this case reaches reclamation.

Suggested change
if (allocation !== undefined && allocation.tunnelId !== tunnel.id) {
if (allocation !== undefined && allocation.tunnelId !== null && allocation.tunnelId !== tunnel.id) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/environments/ManagedEndpointReaper.ts around line 169:

The reaper permanently skips Cloudflare tunnels whose same-name allocation has `tunnelId === null`, leaving tunnels created before a crash or persistence failure undeleted and still billed. The mismatch check should only skip allocations with a recorded, different tunnel ID so this case reaches reclamation.

Evidence trail:
Commit 26c5820: infra/relay/src/environments/ManagedEndpointReaper.ts:159-175; infra/relay/src/environments/ManagedEndpointProvider.ts:754-814; infra/relay/src/environments/ManagedEndpointAllocations.ts:214-225,269-289. Cloudflare billing context: https://blog.cloudflare.com/tunnel-for-everyone/

target: deprovisionTarget,
});
if (!deprovisioned && (yield* links.getForUser(input)) === null) {
yield* managedEndpointProvider.deprovision(input);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High http/Api.ts:465

The fallback deprovision(input) can delete a newly linked environment's tunnel and DNS record. After the first call returns false, a concurrent relink can replace the allocation before links.getForUser(input) and the fallback call, which reloads and deprovisions that newer allocation; pass the original deprovisionTarget to the retry.

Suggested change
yield* managedEndpointProvider.deprovision(input);
yield* managedEndpointProvider.deprovision({
...input,
target: deprovisionTarget,
});
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/http/Api.ts around line 465:

The fallback `deprovision(input)` can delete a newly linked environment's tunnel and DNS record. After the first call returns `false`, a concurrent relink can replace the allocation before `links.getForUser(input)` and the fallback call, which reloads and deprovisions that newer allocation; pass the original `deprovisionTarget` to the retry.

Evidence trail:
Commit 26c5820. Files: infra/relay/src/http/Api.ts:438-465; infra/relay/src/environments/EnvironmentLinker.ts:311-332; infra/relay/src/environments/ManagedEndpointProvider.ts:470-520, 532-563; infra/relay/src/environments/ManagedEndpointAllocations.ts:442-469. Git: `git show 26c5820 -- infra/relay/src/http/Api.ts`

endpointRuntimeStatus,
});
}
return yield* dependencies.endpointRuntime.withLinkStateLock(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High cloud/http.ts:459

reconcileDesiredCloudLinkWith can restore a link after unlink has successfully removed it, leaving CLOUD_LINKED_USER_ID and the runtime config present and restarting the connector for the revoked link. The reconcile flow performs its remote request and setCliDesiredCloudLink(true, mode) before applyCloudRelayConfig acquires this lock, so validateLinkedCloudUser accepts the now-missing user; synchronize the entire reconcile/unlink sequence, not only the config application.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/cloud/http.ts around line 459:

`reconcileDesiredCloudLinkWith` can restore a link after `unlink` has successfully removed it, leaving `CLOUD_LINKED_USER_ID` and the runtime config present and restarting the connector for the revoked link. The reconcile flow performs its remote request and `setCliDesiredCloudLink(true, mode)` before `applyCloudRelayConfig` acquires this lock, so `validateLinkedCloudUser` accepts the now-missing user; synchronize the entire reconcile/unlink sequence, not only the config application.

Evidence trail:
Reviewed commit 26c5820: apps/server/src/cloud/http.ts:221-245 (`validateLinkedCloudUser` accepts a missing user); 454-505 (`applyCloudRelayConfig` locks only the config application, validates, persists `CLOUD_LINKED_USER_ID` at 484, and applies runtime config at 467); 552-633 (`reconcileDesiredCloudLinkWith` performs relay requests and calls `setCliDesiredCloudLink(true, mode)` at 621 before `applyCloudRelayConfig`); 886-905 (`cloudUnlinkHandler` removes the link and runtime secrets under the same lock). apps/server/src/cloud/ManagedEndpointRuntime.ts:306-317 (runtime application and link-state lock implementation).

claimRelease: Effect.fn("relay.managed_endpoint_allocations.claim_release")(function* (
input: ClaimManagedEndpointReleaseInput,
) {
const claimedAt = DateTime.formatIso(yield* DateTime.now);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High environments/ManagedEndpointAllocations.ts:412

claimRelease can delete a newly recorded, live tunnel: if recordTunnel runs in the same millisecond after the claim, its updatedAt equals claimedAt, so the generation check incorrectly succeeds. Use a genuinely unique generation (or an atomic claim/version) instead of the millisecond-precision wall-clock string.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @infra/relay/src/environments/ManagedEndpointAllocations.ts around line 412:

`claimRelease` can delete a newly recorded, live tunnel: if `recordTunnel` runs in the same millisecond after the claim, its `updatedAt` equals `claimedAt`, so the generation check incorrectly succeeds. Use a genuinely unique generation (or an atomic claim/version) instead of the millisecond-precision wall-clock string.

Evidence trail:
Commit 26c5820: infra/relay/src/environments/ManagedEndpointAllocations.ts:269-289, 409-440; infra/relay/src/environments/ManagedEndpointProvider.ts:587-631, 674-691, 754-814; infra/relay/src/persistence/schema.ts:86-105. Effect DateTime documentation: https://www.effect.website/docs/v4/data-types/datetime. Verification commands: `git show 26c5820 -- infra/relay/src/environments/ManagedEndpointAllocations.ts infra/relay/src/environments/ManagedEndpointProvider.ts`; `git grep -n -C 4 'recordTunnel\|claimRelease\|claimedAt' 26c5820 -- infra/relay/src`.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant