diff --git a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts
index ebd418da7b48658f76acc130b786cc02f42f53b8..f78cecc0671824cca3a99d787fa657dcb625443e 100644
--- a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts
+++ b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts
@@ -2,6 +2,8 @@ import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contract
import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";
import { describe, expect, it } from "vite-plus/test";
+import { RELAY_TUNNEL_RELEASED_MESSAGE } from "@t3tools/client-runtime/relay";
+
import { availableCloudEnvironmentPresentation } from "./cloudEnvironmentPresentation";
function relayStatus(
@@ -111,4 +113,23 @@ describe("available cloud environment presentation", () => {
connectionErrorTraceId: "trace-status",
});
});
+
+ it("tells the user to update a host whose idle tunnel was removed", () => {
+ const status = {
+ ...relayStatus("offline", "Managed endpoint health request failed.", "trace-released"),
+ offlineReason: "tunnel_released",
+ } satisfies RelayEnvironmentStatusResponse;
+ expect(
+ availableCloudEnvironmentPresentation({
+ isStatusPending: false,
+ status,
+ statusError: null,
+ statusErrorTraceId: null,
+ }),
+ ).toMatchObject({
+ connectionState: "error",
+ statusText: RELAY_TUNNEL_RELEASED_MESSAGE,
+ connectionErrorTraceId: "trace-released",
+ });
+ });
});
diff --git a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts
index 3e958b6453d182a6ba55d660edd83e1d0ba68036..49e1e790c50ed511a57911dd5681f52239ed6919 100644
--- a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts
+++ b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts
@@ -3,6 +3,7 @@ import {
orchestrationProtocolCompatibilityError,
type EnvironmentConnectionPhase,
} from "@t3tools/client-runtime/connection";
+import { relayOfflineReasonMessage } from "@t3tools/client-runtime/relay";
export interface AvailableCloudEnvironmentPresentation {
readonly connectionError: string | null;
@@ -39,7 +40,8 @@ export function availableCloudEnvironmentPresentation(input: {
}
if (input.status?.status === "offline") {
- const connectionError = input.status.error ?? "Relay is offline.";
+ const connectionError =
+ relayOfflineReasonMessage(input.status) ?? input.status.error ?? "Relay is offline.";
return {
connectionError,
connectionErrorTraceId: input.status.traceId ?? null,
diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx
index d596866efc53f668034913f8c2e282e8e3702a12..f6ea5be62291482f1bc8668bff4980a30529bfa7 100644
--- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx
+++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx
@@ -1,4 +1,4 @@
-import type { Discovery } from "@t3tools/client-runtime/relay";
+import { RELAY_TUNNEL_RELEASED_MESSAGE, type Discovery } from "@t3tools/client-runtime/relay";
import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
import * as Option from "effect/Option";
@@ -152,6 +152,40 @@ afterEach(async () => {
vi.unstubAllGlobals();
});
+describe("cloud environment offline reasons", () => {
+ it("tells the user to update a computer whose idle tunnel was removed", async () => {
+ const base = linkedMachines.get(newMachineId)!;
+ const offlineEntry = (offlineReason?: "tunnel_released") => ({
+ ...base,
+ availability: "offline" as const,
+ status: Option.some({
+ environmentId: newMachineId,
+ endpoint: base.environment.endpoint,
+ status: "offline" as const,
+ checkedAt: "2026-09-15T00:00:00Z",
+ error: "Managed endpoint health request failed.",
+ ...(offlineReason ? { offlineReason } : {}),
+ }),
+ });
+ const rowText = () =>
+ renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children);
+
+ discovery.listEnvironments.mockResolvedValue(new Map([[newMachineId, offlineEntry()]]));
+ await mount();
+ expect(rowText()).toContain("T3 Connect · Not added · Relay offline");
+
+ await act(async () => {
+ publish({
+ environments: new Map([[newMachineId, offlineEntry("tunnel_released")]]),
+ refreshing: false,
+ offline: false,
+ error: Option.none(),
+ });
+ });
+ expect(rowText()).toContain(RELAY_TUNNEL_RELEASED_MESSAGE);
+ });
+});
+
describe("cloud onboarding discovery", () => {
it("signals that the section can expand after initial discovery settles", async () => {
let finishDiscovery!: (environments: DiscoveredEnvironments) => void;
diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx
index fe5eb7836239e9dba028163140cccbf342bdadc8..ddff0de446b6cfff259703c153b0cdb748c03f8a 100644
--- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx
+++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx
@@ -5,6 +5,7 @@ import {
RelayConnectionTarget,
orchestrationProtocolCompatibilityError,
} from "@t3tools/client-runtime/connection";
+import { relayOfflineReasonMessage } from "@t3tools/client-runtime/relay";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
@@ -335,7 +336,14 @@ export function CloudEnvironmentConnectRows({
// A connected machine's own config (with the user's icon pick) wins. Before
// that, the relay's health probe already carries the server's descriptor, so
// a machine can wear its detected glyph before this device ever connects.
- const descriptor = status === undefined ? undefined : Option.getOrNull(status)?.descriptor;
+ const relayStatus = status === undefined ? null : Option.getOrNull(status);
+ const descriptor = relayStatus?.descriptor;
+ // Why the relay reports this environment offline, when it knows more than
+ // "no answer". Shown for saved and unsaved rows alike.
+ const offlineReason =
+ availability === "offline" && relayStatus !== null
+ ? relayOfflineReasonMessage(relayStatus)
+ : null;
const machineKind = resolveEnvironmentMachineKind(
savedEnvironment?.serverConfig ??
(descriptor === undefined ? null : { environment: descriptor }),
@@ -361,16 +369,18 @@ export function CloudEnvironmentConnectRows({
const statusText =
unsupported && !savedEnvironment
? `T3 Connect · ${notAdded} · Client not supported`
- : savedConnection
- ? savedConnection.statusText
- : availability === "online"
- ? `T3 Connect · ${notAdded} · Relay online`
- : availability === "offline"
- ? `T3 Connect · ${notAdded} · Relay offline`
- : availability === "checking"
- ? `T3 Connect · ${notAdded} · Checking relay status…`
- : (Option.getOrNull(error)?.message ??
- `T3 Connect · ${notAdded} · Relay status unavailable`);
+ : offlineReason !== null
+ ? offlineReason
+ : savedConnection
+ ? savedConnection.statusText
+ : availability === "online"
+ ? `T3 Connect · ${notAdded} · Relay online`
+ : availability === "offline"
+ ? `T3 Connect · ${notAdded} · Relay offline`
+ : availability === "checking"
+ ? `T3 Connect · ${notAdded} · Checking relay status…`
+ : (Option.getOrNull(error)?.message ??
+ `T3 Connect · ${notAdded} · Relay status unavailable`);
if (selection) {
return (
{
origin: null,
updatedAt: "2026-05-25T00:00:00.000Z",
generation: 1,
+ tunnelReleasedAt: null,
}),
}),
),
@@ -550,9 +552,40 @@ describe("EnvironmentConnector", () => {
error: "Managed endpoint health request failed: Environment is unavailable.",
traceId: expect.any(String),
});
+ expect(result).not.toHaveProperty("offlineReason");
}).pipe(Effect.provide(layerConnectorTest(execute)));
});
+ it.effect("reports a released tunnel as the reason an environment is offline", () => {
+ const execute = (request: HttpClientRequest.HttpClientRequest) =>
+ Effect.succeed(
+ HttpClientResponse.fromWeb(request, new Response("tunnel not found", { status: 530 })),
+ );
+ const allocations = makeAllocations({
+ userId: "user_123",
+ environmentId: "env-connector-test",
+ hostname: "env.example.test",
+ tunnelId: "tunnel-id",
+ tunnelName: "tunnel-name",
+ dnsRecordId: "dns-record-id",
+ readyAt: "2026-05-25T00:00:00.000Z",
+ origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
+ updatedAt: "2026-05-25T00:00:00.000Z",
+ generation: 2,
+ tunnelReleasedAt: "2026-05-26T00:00:00.000Z",
+ });
+
+ return Effect.gen(function* () {
+ const connector = yield* EnvironmentConnector.EnvironmentConnector;
+ const result = yield* connector.status({
+ userId: "user_123",
+ environmentId: "env-connector-test",
+ });
+
+ expect(result).toMatchObject({ status: "offline", offlineReason: "tunnel_released" });
+ }).pipe(Effect.provide(layerConnectorTest(execute, { allocations })));
+ });
+
it.effect("rejects health responses with a mismatched top-level environment id", () => {
const execute = (request: HttpClientRequest.HttpClientRequest) =>
Effect.sync(() => {
diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts
index f2edf1bc722786c0f6b5dd19089bd4e8b99d943d..9d06b8b074a9843c79be6c0b7d9aa45cf28d97a0 100644
--- a/infra/relay/src/environments/EnvironmentConnector.ts
+++ b/infra/relay/src/environments/EnvironmentConnector.ts
@@ -163,6 +163,16 @@ const isEnvironmentHealthError = Schema.is(
]),
);
+/**
+ * Cleanup deleted this environment's tunnel and nothing has recorded a new
+ * one, so the host is unreachable until it updates and requests recovery.
+ */
+function offlineReasonFor(
+ allocation: ManagedEndpointAllocations.ManagedEndpointAllocation | null,
+): { readonly offlineReason?: "tunnel_released" } {
+ return allocation?.tunnelReleasedAt ? { offlineReason: "tunnel_released" } : {};
+}
+
function environmentHealthRequestFailureMessage(cause: unknown): string {
return isEnvironmentHealthError(cause)
? `Managed endpoint health request failed: ${cause.message}`
@@ -510,6 +520,7 @@ const make = Effect.gen(function* () {
status: "offline" as const,
checkedAt,
error: "Managed endpoint health request timed out.",
+ ...offlineReasonFor(allocation),
traceId,
};
}
@@ -532,6 +543,7 @@ const make = Effect.gen(function* () {
status: "offline" as const,
checkedAt,
error: environmentHealthRequestFailureMessage(responseOption.value.cause),
+ ...offlineReasonFor(allocation),
traceId,
};
}
diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.test.ts b/infra/relay/src/environments/ManagedEndpointAllocations.test.ts
index 105b10cc6a9e17d83816cb24ea4067bc69718951..f2d00b95c1a4e058b489c336fd211fab81f5bdca 100644
--- a/infra/relay/src/environments/ManagedEndpointAllocations.test.ts
+++ b/infra/relay/src/environments/ManagedEndpointAllocations.test.ts
@@ -19,6 +19,7 @@ describe("ManagedEndpointAllocations", () => {
readonly readyAt: unknown;
readonly recoveryEnabledAt: unknown;
readonly recoveryEnvironmentPublicKey: unknown;
+ readonly tunnelReleasedAt: unknown;
}
| undefined;
const fakeDb = {
@@ -30,6 +31,7 @@ describe("ManagedEndpointAllocations", () => {
readonly readyAt: unknown;
readonly recoveryEnabledAt: unknown;
readonly recoveryEnvironmentPublicKey: unknown;
+ readonly tunnelReleasedAt: unknown;
}) => {
updated = values;
return {
@@ -53,6 +55,8 @@ describe("ManagedEndpointAllocations", () => {
}),
).toBe(8);
expect(updated?.tunnelId).toBe("replacement-tunnel");
+ // Any recorded tunnel ends a released state, so status stops reporting it.
+ expect(updated?.tunnelReleasedAt).toBeNull();
const query = new PgDialect().sqlToQuery(updated?.readyAt as never);
expect(query.sql).toBe(
'case when "relay_managed_endpoint_allocations"."tunnel_id" = $1 then "relay_managed_endpoint_allocations"."ready_at" else null end',
@@ -250,6 +254,38 @@ describe("ManagedEndpointAllocations", () => {
}).pipe(Effect.provide(layerWithDb(fakeDb)));
});
+ it.effect.each([
+ { markReleased: true, released: true },
+ { markReleased: undefined, released: false },
+ ] as const)(
+ "marks the tunnel released only for the claim that deletes it ($markReleased)",
+ ({ markReleased, released }) => {
+ let updated: Record | undefined;
+ const fakeDb = {
+ update: () => ({
+ set: (values: Record) => {
+ updated = values;
+ return {
+ where: () => ({ returning: () => Effect.succeed([{ generation: 4 }]) }),
+ };
+ },
+ }),
+ } as unknown as RelayDb.RelayDb["Service"];
+
+ return Effect.gen(function* () {
+ const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
+ yield* allocations.claimRelease({
+ userId: "user-1",
+ environmentId: "environment-1",
+ tunnelId: "tunnel-1",
+ generation: 3,
+ ...(markReleased === undefined ? {} : { markReleased }),
+ });
+ expect(typeof updated?.tunnelReleasedAt === "string").toBe(released);
+ }).pipe(Effect.provide(layerWithDb(fakeDb)));
+ },
+ );
+
it.effect("returns a claim generation only when deprovision wins the allocation CAS", () => {
const fakeDb = {
update: (table: unknown) => {
diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts
index e2d8475a1b2ae40f2b9c55d3b449848db71fa631..b4a93836a0857386cb41e317e8248b753ce94fd4 100644
--- a/infra/relay/src/environments/ManagedEndpointAllocations.ts
+++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts
@@ -24,6 +24,8 @@ export interface ManagedEndpointAllocation {
readonly origin: RelayManagedEndpointOrigin | null;
readonly updatedAt: string;
readonly generation: number;
+ /** When cleanup deleted the recorded tunnel; null once a tunnel is recorded again. */
+ readonly tunnelReleasedAt: string | null;
}
export interface ManagedEndpointTunnelAllocation extends ManagedEndpointAllocation {
@@ -111,6 +113,8 @@ interface MarkManagedEndpointReadyInput extends ManagedEndpointAllocationKey {
interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey {
readonly tunnelId: string;
readonly generation: number;
+ /** Record that the tunnel is being deleted; set only by the claim that deletes it. */
+ readonly markReleased?: boolean;
}
interface EnableManagedEndpointRecoveryInput extends ManagedEndpointAllocationKey {
@@ -202,6 +206,7 @@ const allocationSelection = {
origin: relayManagedEndpointAllocations.origin,
updatedAt: relayManagedEndpointAllocations.updatedAt,
generation: relayManagedEndpointAllocations.generation,
+ tunnelReleasedAt: relayManagedEndpointAllocations.tunnelReleasedAt,
};
const whereAllocation = (input: ManagedEndpointAllocationKey) =>
@@ -326,6 +331,7 @@ export const make = Effect.gen(function* () {
// again before the reaper may treat it as recoverable.
recoveryEnabledAt: sql`case when ${relayManagedEndpointAllocations.tunnelId} = ${input.tunnelId} then ${relayManagedEndpointAllocations.recoveryEnabledAt} else null end`,
recoveryEnvironmentPublicKey: sql`case when ${relayManagedEndpointAllocations.tunnelId} = ${input.tunnelId} then ${relayManagedEndpointAllocations.recoveryEnvironmentPublicKey} else null end`,
+ tunnelReleasedAt: null,
updatedAt: DateTime.formatIso(yield* DateTime.now),
generation: sql`${relayManagedEndpointAllocations.generation} + 1`,
})
@@ -534,11 +540,13 @@ export const make = Effect.gen(function* () {
claimRelease: Effect.fn("relay.managed_endpoint_allocations.claim_release")(function* (
input: ClaimManagedEndpointReleaseInput,
) {
+ const now = DateTime.formatIso(yield* DateTime.now);
const claimed = yield* db
.update(relayManagedEndpointAllocations)
.set({
- updatedAt: DateTime.formatIso(yield* DateTime.now),
+ updatedAt: now,
generation: sql`${relayManagedEndpointAllocations.generation} + 1`,
+ ...(input.markReleased === true ? { tunnelReleasedAt: now } : {}),
})
.where(
and(
diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts
index af8a7a21ad95bc8a7a7fdd74a8fba6776c5faac2..29aaefa7b9ddb8d0a9766da5ae73d6c12fa20e0f 100644
--- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts
+++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts
@@ -221,6 +221,7 @@ function makeAllocations(calls: AllocationCall[] = []) {
origin: null,
updatedAt: `generation-${++generation}`,
generation: 0,
+ tunnelReleasedAt: null,
};
allocations.set(allocationKey(input), allocation);
return allocation;
@@ -968,6 +969,38 @@ describe("ManagedEndpointProvider", () => {
"updateRecord",
]);
expect(allocationCalls.map((call) => call.operation)).not.toContain("remove");
+ // An ordinary release, such as a host shutting down, must not tell the
+ // user to update: that host gets a new tunnel when it starts again.
+ const claims = allocationCalls.filter((call) => call.operation === "claimRelease");
+ expect(claims.map((call) => (call.input as { markReleased?: boolean }).markReleased)).toEqual(
+ [undefined, undefined],
+ );
+ }).pipe(Effect.provide(layer));
+ });
+
+ it.effect("marks the release on the claim that deletes the tunnel when asked", () => {
+ const allocationCalls: AllocationCall[] = [];
+ const layer = layerProvider(
+ makePersistentTunnelClient(),
+ makeDnsClient(),
+ makeAllocations(allocationCalls),
+ );
+
+ 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, markReleased: true })).toBe(true);
+
+ // The first claim only reserves the release; the second, taken with the
+ // row locked right before the delete, is the one that records it.
+ const claims = allocationCalls.filter((call) => call.operation === "claimRelease");
+ expect(claims.map((call) => (call.input as { markReleased?: boolean }).markReleased)).toEqual(
+ [undefined, true],
+ );
}).pipe(Effect.provide(layer));
});
@@ -1502,6 +1535,53 @@ describe("ManagedEndpointProvider", () => {
}).pipe(Effect.provide(layer));
});
+ it.effect.each([
+ { name: "the tunnel is gone", tunnelRemoved: true, released: true },
+ { name: "the tunnel still exists", tunnelRemoved: false, released: false },
+ ] as const)(
+ "resolves a delete whose response was lost by checking whether $name",
+ ({ tunnelRemoved, released }) => {
+ const persistent = makePersistentTunnelClient();
+ const lostResponse = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({
+ operation: "delete",
+ tunnelId: "tunnel-id",
+ cause: { _tag: "TimeoutError" },
+ });
+ // Cloudflare may or may not have applied the delete before the
+ // response was lost; the client only sees the timeout.
+ const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({
+ ...persistent,
+ delete: (tunnelId) =>
+ (tunnelRemoved ? persistent.delete(tunnelId) : Effect.void).pipe(
+ Effect.andThen(Effect.fail(lostResponse)),
+ ),
+ });
+ const allocationCalls: AllocationCall[] = [];
+ const layer = layerProvider(tunnelClient, makeDnsClient(), makeAllocations(allocationCalls));
+
+ 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 },
+ });
+ const result = yield* Effect.result(provider.release({ ...key, markReleased: true }));
+
+ if (released) {
+ // Succeeding commits the marked claim, so status can explain it.
+ expect(result).toMatchObject({ _tag: "Success", success: true });
+ } else {
+ // Failing rolls the claim back; the tunnel is still there to retry.
+ expect(result).toMatchObject({
+ _tag: "Failure",
+ failure: { stage: "delete-tunnel", cause: lostResponse },
+ });
+ }
+ }).pipe(Effect.provide(layer));
+ },
+ );
+
it.effect("surfaces non-not-found tunnel deletion failures when releasing", () => {
const failure = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({
operation: "delete",
diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts
index 2192b47e1fd32db8d2f116c4651f0120d2984321..aba8b426ab5f7c4ec4dabcab97f7a32b7e3e8d66 100644
--- a/infra/relay/src/environments/ManagedEndpointProvider.ts
+++ b/infra/relay/src/environments/ManagedEndpointProvider.ts
@@ -193,6 +193,11 @@ export class ManagedEndpointProvider extends Context.Service<
readonly expectedTunnelId?: string;
readonly expectedInactiveBefore?: string;
readonly expectedStatus?: "inactive" | "down";
+ /**
+ * Record that cleanup removed a legacy host's tunnel, so status tells the
+ * user to update. Other releases leave hosts that recover on their own.
+ */
+ readonly markReleased?: boolean;
}) => Effect.Effect;
}
>()("t3code-relay/environments/ManagedEndpointProvider") {}
@@ -808,6 +813,19 @@ export const make = Effect.gen(function* () {
if (claimedGeneration === null) {
return false;
}
+ // After a failed delete: succeed if the tunnel is gone, since the delete
+ // then took effect and only its response was lost; otherwise keep the
+ // delete's error.
+ const confirmTunnelGone = (
+ failure: ManagedEndpointDeprovisioningFailed,
+ ): Effect.Effect =>
+ tunnels.get(tunnelId).pipe(
+ Effect.andThen(Effect.fail(failure)),
+ Effect.catchTags({
+ ManagedEndpointTunnelClientError: (lookupFailure) =>
+ isManagedEndpointNotFound(lookupFailure.cause) ? Effect.void : Effect.fail(failure),
+ }),
+ );
const deleteTunnel = ignoreNotFound(tunnels.delete(tunnelId)).pipe(
Effect.mapError(
(cause) =>
@@ -877,6 +895,7 @@ export const make = Effect.gen(function* () {
environmentId: input.environmentId,
tunnelId,
generation: claimedGeneration,
+ ...(input.markReleased === true ? { markReleased: true } : {}),
})
.pipe(
Effect.mapError(
@@ -895,12 +914,21 @@ export const make = Effect.gen(function* () {
// 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.
+ // A delete whose response is lost (a timeout) may still have
+ // removed the tunnel. Ask Cloudflare before rolling back: if the
+ // tunnel is gone, the delete happened and the claim, including any
+ // released marker, must commit, since no later sweep can find
+ // this tunnel again to retry.
return yield* deleteTunnel.pipe(
Effect.as(true),
Effect.catchIf(
(error) => isManagedEndpointTunnelInUse(error.cause),
() => Effect.succeed(false),
),
+ Effect.catchTags({
+ ManagedEndpointDeprovisioningFailed: (failure) =>
+ confirmTunnelGone(failure).pipe(Effect.as(true)),
+ }),
);
}),
)
diff --git a/infra/relay/src/environments/ManagedEndpointReaper.test.ts b/infra/relay/src/environments/ManagedEndpointReaper.test.ts
index c71811a553d0fc5bdf11819fc302321d3ce6bcfe..4e4c6996d9fad7d8311e5cf88f632661e519b6aa 100644
--- a/infra/relay/src/environments/ManagedEndpointReaper.test.ts
+++ b/infra/relay/src/environments/ManagedEndpointReaper.test.ts
@@ -55,6 +55,7 @@ function allocation(input: {
origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
updatedAt: "2026-08-25T11:00:00.000Z",
generation: 1,
+ tunnelReleasedAt: null,
recoveryEnabled: input.recoveryEnabled,
};
}
@@ -296,6 +297,8 @@ describe("ManagedEndpointReaper", () => {
failed: 0,
});
expect(state.deleted).toEqual(["down-1", "inactive-1"]);
+ // A host that registered recovery gets a new tunnel on its own.
+ expect(state.releases.some((release) => release.markReleased === true)).toBe(false);
expect(state.releases.map((request) => request.expectedTunnelId)).toEqual([
"down-1",
"inactive-1",
@@ -582,6 +585,8 @@ describe("ManagedEndpointReaper", () => {
deleted: 2,
});
expect([...state.deleted].sort()).toEqual(["legacy-never", "legacy-old"]);
+ // A legacy host needs an update to recover, so its user is told why.
+ expect(state.releases.every((release) => release.markReleased === true)).toBe(true);
// The release re-checks each tunnel against the 7-day cutoff, not
// the 5-minute cutoff used for recoverable tunnels.
for (const release of state.releases) {
diff --git a/infra/relay/src/environments/ManagedEndpointReaper.ts b/infra/relay/src/environments/ManagedEndpointReaper.ts
index f1c9ccc84e717f3c8c465d8728e0bd4996a57bba..eea1f98145dff9d63da08b99551482f21573d780 100644
--- a/infra/relay/src/environments/ManagedEndpointReaper.ts
+++ b/infra/relay/src/environments/ManagedEndpointReaper.ts
@@ -386,6 +386,8 @@ export const make = Effect.gen(function* () {
expectedTunnelId: candidate.tunnel.id,
expectedInactiveBefore: candidate.inactiveBefore,
expectedStatus: candidate.status,
+ // Only a legacy host needs an update to recover; tell its user so.
+ ...(candidate.legacy ? { markReleased: true } : {}),
})
.pipe(Effect.result);
if (result._tag === "Failure") {
diff --git a/infra/relay/src/hooks/HeldHooks.test.ts b/infra/relay/src/hooks/HeldHooks.test.ts
index 02e5c2c240e6feec7ff9fb9bda2a4e4dae184445..8ad291c269cb09d7871ff2be3477e026b7dad081 100644
--- a/infra/relay/src/hooks/HeldHooks.test.ts
+++ b/infra/relay/src/hooks/HeldHooks.test.ts
@@ -31,6 +31,7 @@ const allocationFor = (
tunnelName: `t3coderelay-managedendpoint-dev-${key}`,
dnsRecordId: "dns-record-id",
readyAt: ready ? "2026-05-25T00:00:00.000Z" : null,
+ tunnelReleasedAt: null,
origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
updatedAt: "2026-05-25T00:00:00.000Z",
generation: 1,
diff --git a/infra/relay/src/hooks/HookForwarder.test.ts b/infra/relay/src/hooks/HookForwarder.test.ts
index 427a73275af7e557352e3c944c64cebcee5449bd..b00b4f9d750d8182d89f0a5378c553cc642ad136 100644
--- a/infra/relay/src/hooks/HookForwarder.test.ts
+++ b/infra/relay/src/hooks/HookForwarder.test.ts
@@ -66,6 +66,7 @@ const readyAllocation: ManagedEndpointAllocations.ManagedEndpointAllocation = {
tunnelName: `t3coderelay-managedendpoint-dev-${endpointKey}`,
dnsRecordId: "dns-record-id",
readyAt: "2026-05-25T00:00:00.000Z",
+ tunnelReleasedAt: null,
origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
updatedAt: "2026-05-25T00:00:00.000Z",
generation: 1,
diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts
index b49bc09cefea94713b95988d696db55f73d574a3..b0e047bd829ccd6426a966998d95e795d54e8cf3 100644
--- a/infra/relay/src/http/Api.test.ts
+++ b/infra/relay/src/http/Api.test.ts
@@ -822,6 +822,7 @@ describe("relay managed tunnel recovery", () => {
origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
updatedAt: "replacement-generation",
generation: 3,
+ tunnelReleasedAt: null,
} satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget;
return Effect.gen(function* () {
@@ -924,6 +925,7 @@ describe("relay environment unlink", () => {
tunnelName: `t3coderelay-managedendpoint-dev-${endpointKey}`,
dnsRecordId: "dns-1",
readyAt: "2026-07-28T00:00:00.000Z",
+ tunnelReleasedAt: null,
origin: null,
updatedAt: "2026-07-28T00:00:00.000Z",
generation: 1,
@@ -962,6 +964,7 @@ describe("relay environment unlink", () => {
origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
updatedAt: "generation-before-unlink",
generation: 1,
+ tunnelReleasedAt: null,
} satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget;
return Effect.gen(function* () {
@@ -1108,6 +1111,7 @@ describe("relay environment unlink", () => {
origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
updatedAt: "original-generation",
generation: 1,
+ tunnelReleasedAt: null,
} satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget;
return Effect.gen(function* () {
diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts
index e24bb1a5c4c072e859ab916099631501b54cb74f..eb6357bfa5d3ad6624b0d0f8bc903d533501a33a 100644
--- a/infra/relay/src/persistence/schema.ts
+++ b/infra/relay/src/persistence/schema.ts
@@ -99,6 +99,8 @@ export const relayManagedEndpointAllocations = pgTable(
readyAt: varchar("ready_at", { length: 64 }),
recoveryEnabledAt: varchar("recovery_enabled_at", { length: 64 }),
recoveryEnvironmentPublicKey: text("recovery_environment_public_key"),
+ // Set when cleanup deletes the recorded tunnel; cleared when a tunnel is recorded again.
+ tunnelReleasedAt: varchar("tunnel_released_at", { length: 64 }),
origin: jsonb("origin").$type(),
generation: integer("generation").notNull().default(0),
createdAt: varchar("created_at", { length: 64 }).notNull(),
diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts
index b9a53e5ce8b8002a114578329b8c9db166fa3fd6..1e578cf4534507e471ad392ae8675240d5bd7d55 100644
--- a/packages/client-runtime/src/relay/errorPresentation.ts
+++ b/packages/client-runtime/src/relay/errorPresentation.ts
@@ -1,5 +1,5 @@
import type { DpopFailureReason } from "@t3tools/contracts";
-import type { RelayProtectedError } from "@t3tools/contracts/relay";
+import type { RelayEnvironmentStatusResponse, RelayProtectedError } from "@t3tools/contracts/relay";
export const DPOP_CLOCK_HINT =
"Hint: Check that automatic date and time is enabled on both devices, then try again.";
@@ -64,3 +64,21 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string {
return `Relay encountered an internal error (${error.reason}).`;
}
}
+
+// A host with a current build gets a new tunnel on its own within minutes of
+// coming back, which clears this reason. While it is still reported, the host
+// is either still off or running a build too old to do that.
+export const RELAY_TUNNEL_RELEASED_MESSAGE =
+ "Offline for a while, so its T3 Connect tunnel was removed. Start T3 Code on that computer and update it to the latest version to reconnect.";
+
+/** User-facing text for an offline status, or null when the relay gave no known reason. */
+export function relayOfflineReasonMessage(
+ status: Pick,
+): string | null {
+ switch (status.offlineReason) {
+ case "tunnel_released":
+ return RELAY_TUNNEL_RELEASED_MESSAGE;
+ case undefined:
+ return null;
+ }
+}
diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts
index f5cb83b4f8dc444fecdd17a3eaa0f0145d250e11..916893c54fbea8e7e2531f0469a4ae5554d77d2b 100644
--- a/packages/contracts/src/relay.ts
+++ b/packages/contracts/src/relay.ts
@@ -823,6 +823,14 @@ export type RelayEnvironmentConnectResponse = typeof RelayEnvironmentConnectResp
export const RelayEnvironmentStatusValue = Schema.Literals(["online", "offline"]);
export type RelayEnvironmentStatusValue = typeof RelayEnvironmentStatusValue.Type;
+/**
+ * Why an environment is offline, when the relay knows more than "the host
+ * did not answer". `tunnel_released`: the relay deleted the environment's
+ * idle tunnel, and the host needs a current T3 Code build to get a new one.
+ */
+const RelayEnvironmentOfflineReason = Schema.Literals(["tunnel_released"]);
+type RelayEnvironmentOfflineReason = typeof RelayEnvironmentOfflineReason.Type;
+
export const RelayEnvironmentStatusResponse = Schema.Struct({
environmentId: EnvironmentId,
endpoint: RelayManagedEndpoint,
@@ -830,6 +838,7 @@ export const RelayEnvironmentStatusResponse = Schema.Struct({
checkedAt: TrimmedNonEmptyString,
descriptor: Schema.optional(ExecutionEnvironmentDescriptor),
error: Schema.optional(TrimmedNonEmptyString),
+ offlineReason: Schema.optional(RelayEnvironmentOfflineReason),
traceId: Schema.optional(TrimmedNonEmptyString),
});
export type RelayEnvironmentStatusResponse = typeof RelayEnvironmentStatusResponse.Type;