Skip to content

Commit 0e8b173

Browse files
authored
Merge pull request #191 from jayleaton/fix/mobile-reconnect-loop-20261104
fix(client-runtime): connections stay put and offline machines stop saying reconnecting
2 parents 61dcaf1 + 0521909 commit 0e8b173

17 files changed

Lines changed: 321 additions & 35 deletions

‎apps/mobile/src/features/connection/CloudEnvironmentRows.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,9 @@ function ConnectedCloudEnvironmentRow(props: {
214214
{...(enabled || unsupported
215215
? diagnosis
216216
? { statusText: diagnosis.headline }
217-
: {}
217+
: presentation
218+
? { statusText: environmentStatusText(presentation.connection) }
219+
: {}
218220
: { statusText: "Off" })}
219221
value={enabled}
220222
/>

‎apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import * as Dialog from "../../components/ui/Dialog";
22
import { ConnectionTraceId } from "./ConnectionTraceId";
33
import { SymbolView } from "../../components/ui/AppSymbol";
4-
import { hasSavedBearerRoute, hostFreezeNotice } from "@t3tools/client-runtime/connection";
4+
import {
5+
type EnvironmentConnectionPresentation,
6+
hasSavedBearerRoute,
7+
hostFreezeNotice,
8+
} from "@t3tools/client-runtime/connection";
59
import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
610
import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
711
import { useAtomValue } from "@effect/atom-react";
@@ -25,15 +29,20 @@ import { ConnectionFormField } from "./ConnectionFormField";
2529
import { ConnectionStatusDot } from "./ConnectionStatusDot";
2630
import { environmentStatusText } from "./environmentStatusText";
2731

28-
function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
32+
function connectionStatusLabel(
33+
environment: ConnectedEnvironmentSummary,
34+
connection: EnvironmentConnectionPresentation | undefined,
35+
): string | null {
2936
if (!environment.isEnabled && environment.connectionState !== "unsupported") {
3037
return "Off";
3138
}
32-
return environmentStatusText({
33-
phase: environment.connectionState,
34-
error: environment.connectionError,
35-
traceId: environment.connectionErrorTraceId,
36-
});
39+
return environmentStatusText(
40+
connection ?? {
41+
phase: environment.connectionState,
42+
error: environment.connectionError,
43+
traceId: environment.connectionErrorTraceId,
44+
},
45+
);
3746
}
3847

3948
export function ConnectionEnvironmentRow(props: {
@@ -64,7 +73,7 @@ export function ConnectionEnvironmentRow(props: {
6473
presentation,
6574
);
6675
const enabled = props.environment.isEnabled && !unsupported;
67-
const statusLabel = connectionStatusLabel(props.environment);
76+
const statusLabel = connectionStatusLabel(props.environment, presentation?.connection);
6877
const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null;
6978
const hasConnectionFailure = enabled && props.environment.connectionError !== null;
7079
const freeze = enabled

‎apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,23 @@ import { View } from "react-native";
1010

1111
import { AppText as Text } from "../../components/ui/AppText";
1212

13-
function noticeTitle(phase: EnvironmentConnectionPhase, environmentLabel: string): string {
14-
switch (phase) {
13+
function noticeTitle(
14+
connection: EnvironmentConnectionPresentation,
15+
environmentLabel: string,
16+
): string {
17+
switch (connection.phase) {
1518
case "offline":
16-
return "You are offline";
19+
return `${environmentLabel} is offline`;
1720
case "connecting":
1821
return `Connecting to ${environmentLabel}...`;
1922
case "reconnecting":
2023
return `Reconnecting to ${environmentLabel}...`;
2124
case "unsupported":
2225
return "Client not supported";
2326
case "error":
24-
return `${environmentLabel} is unavailable`;
27+
return connection.needsPairing === true
28+
? `${environmentLabel} needs pairing again`
29+
: `${environmentLabel} is unavailable`;
2530
case "available":
2631
return `${environmentLabel} is disconnected`;
2732
case "connected":
@@ -40,7 +45,7 @@ function noticeDetail(
4045

4146
switch (phase) {
4247
case "offline":
43-
return `Cached data remains available. The ${resourceName} will load when your connection returns.`;
48+
return `Cached data remains available. The ${resourceName} will load when the environment is back.`;
4449
case "connecting":
4550
case "reconnecting":
4651
return `The ${resourceName} will load as soon as the environment is ready.`;
@@ -78,7 +83,7 @@ export function EnvironmentConnectionNotice(props: {
7883
)}
7984

8085
<Text className="text-center text-lg font-t3-bold text-foreground">
81-
{noticeTitle(props.connection.phase, props.environmentLabel)}
86+
{noticeTitle(props.connection, props.environmentLabel)}
8287
</Text>
8388
<Text className="text-center text-sm leading-normal text-foreground-muted">
8489
{noticeDetail(props.connection.phase, props.resourceName, props.connection.error)}
Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import {
22
connectionStatusText,
3+
lastSeenLabel,
34
type EnvironmentConnectionPresentation,
45
} from "@t3tools/client-runtime/connection";
56

67
export function environmentStatusText(connection: EnvironmentConnectionPresentation): string {
7-
return connection.phase === "unsupported" && connection.error !== null
8-
? connection.error
9-
: connectionStatusText(connection);
8+
if (connection.phase === "unsupported" && connection.error !== null) return connection.error;
9+
const text = connectionStatusText(connection);
10+
return connection.phase === "offline" && connection.lastConnectedAt !== undefined
11+
? `${text} · ${lastSeenLabel(connection.lastConnectedAt, Date.now())}`
12+
: text;
1013
}

‎apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import {
1818
type WorkspaceConnectionStatusPresentation,
1919
} from "./workspace-connection-status";
2020

21-
const STATUS_SHOW_DELAY_MS = 800;
21+
// Long enough that the reconnect on every app open, which usually lands within two seconds,
22+
// never shows.
23+
const STATUS_SHOW_DELAY_MS = 2_000;
2224
const FADE_IN_MS = 250;
2325

2426
type ConnectionStatus = WorkspaceConnectionStatusPresentation & {

‎apps/mobile/src/features/threads/ThreadDetailScreen.tsx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,8 @@ const NO_RUN_STOP_ATOM = Atom.make<RunStopPresentation | null>(null).pipe(
349349

350350
// Connection failures survive retries and app suspension.
351351
const RECONNECT_ERROR_DELAY_MS = 5_000;
352+
// The reconnect on every app open usually lands within this, so it never shows.
353+
const RECONNECT_STATUS_DELAY_MS = 2_000;
352354

353355
// Anchors, the submitted message, follow state and pending scrolls belong to
354356
// one thread, so the body remounts when the thread changes.
@@ -539,6 +541,11 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
539541
retryError,
540542
RECONNECT_ERROR_DELAY_MS,
541543
);
544+
const shownRetryState = useDelayedStatus(
545+
`${props.environmentId}:retrying`,
546+
connectionRetrying ? props.connectionStateLabel : null,
547+
RECONNECT_STATUS_DELAY_MS,
548+
);
542549
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, {
543550
reportFailure: false,
544551
});
@@ -674,7 +681,10 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
674681
const floatingStatus = ((): FloatingWorkingStatus | null => {
675682
const connectionStatus = connectionFloatingStatus({
676683
connectionError: connectionRetrying ? retryConnectionError : props.connectionError,
677-
connectionState: props.connectionStateLabel,
684+
// Until the delay passes, a retry shows nothing rather than a reconnect banner.
685+
connectionState: connectionRetrying
686+
? (shownRetryState ?? "connected")
687+
: props.connectionStateLabel,
678688
environmentLabel: props.environmentLabel,
679689
onReconnect: props.onReconnectEnvironment,
680690
});

‎apps/mobile/src/features/threads/floating-working-status.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export function connectionFloatingStatus(input: {
7373
onPress: input.onReconnect,
7474
};
7575
case "offline":
76-
return unavailable("You are offline");
76+
return unavailable(`${environmentLabel} is offline`);
7777
case "unsupported":
7878
return unavailable("Client not supported");
7979
case "error":

‎apps/web/src/components/settings/SavedEnvironmentRows.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
hasSavedBearerRoute,
1515
connectionStatusText,
1616
environmentMcpUrl,
17+
lastSeenLabel,
1718
} from "@t3tools/client-runtime/connection";
1819
import * as Option from "effect/Option";
1920

@@ -93,12 +94,19 @@ function savedBackendStatus(environment: EnvironmentPresentation): {
9394
case "unsupported":
9495
return { text: "Client not supported", tone: "muted" };
9596
case "error":
97+
if (connection.needsPairing === true) return { text: "Needs pairing again", tone: "error" };
9698
return {
9799
text: connection.error ? `Connection failed: ${connection.error}` : "Connection failed",
98100
tone: "error",
99101
};
100102
case "offline":
101-
return { text: "Offline", tone: "muted" };
103+
return {
104+
text:
105+
connection.lastConnectedAt === undefined
106+
? "Offline"
107+
: `Offline · ${lastSeenLabel(connection.lastConnectedAt, Date.now())}`,
108+
tone: "muted",
109+
};
102110
case "available":
103111
return { text: "Not connected", tone: "muted" };
104112
}

‎apps/web/src/state/shell.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export const allReachableShellsLiveAtom = Atom.make((get) => {
144144
const settledOffline =
145145
connection.phase === "offline" ||
146146
connection.phase === "blocked" ||
147+
connection.unreachable === true ||
147148
(connection.phase === "backoff" && connection.attempt > 2);
148149
if (!settledOffline) return false;
149150
}

‎docs/internals/remote.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ connect, including a blocked one such as a signed-out T3 Connect, moves on to
3939
the next; only an incompatible server stops the walk, because it is the same
4040
server on every route. While connected over a later route the
4141
[supervisor](../../packages/client-runtime/src/connection/supervisor.ts)
42-
preflights the earlier ones and replaces the session when one would connect.
42+
preflights earlier routes of a better kind (loopback, LAN, tailnet or VPN,
43+
public, SSH, relay, best first) and replaces the session when one would
44+
connect. An earlier route of the same kind is not worth a reconnect: MagicDNS
45+
through `tailscale serve` and the raw tailnet address reach the same host, and
46+
mobile reconnects over whichever answers first after every resume, so following
47+
list order would replace the socket on nearly every foreground.
4348
Preflight includes authorization so a route that answers but rejects this
4449
client never costs a working session; a route that still fails afterwards is
4550
held back for a cooldown so a flaky network cannot bounce the connection.

0 commit comments

Comments
 (0)