Skip to content

Commit 13db1c7

Browse files
jayleatonclaude
andcommitted
fix(client-runtime): foreground retries every 15 s and phones keep a connection log
A phone that lost its path (server-side client-silent reset) could back off for minutes while the user was looking at it. Foregrounded clients now cap backoff at 15 s. Each environment keeps its last 50 connection events, viewable and copyable in mobile Settings, and failures ride the next activity report so the server log shows what the phone saw. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0e8b173 commit 13db1c7

14 files changed

Lines changed: 315 additions & 18 deletions

File tree

‎apps/mobile/src/connection/background-activity.ts‎

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
1+
import {
2+
connectionTroubleSince,
3+
EnvironmentRegistry,
4+
EnvironmentSupervisor,
5+
} from "@t3tools/client-runtime/connection";
26
import { EnvironmentRpcSubscriptionObserver, request } from "@t3tools/client-runtime/rpc";
37
import {
48
type BackgroundScope,
@@ -57,6 +61,8 @@ export const layerReporter = Layer.effectDiscard(
5761
const reportRequests = yield* Queue.sliding<void>(1);
5862
const requestReport = () => Queue.offerUnsafe(reportRequests, undefined);
5963
let appState = AppState.currentState;
64+
// Newest failure each environment has already reported, so each one reaches the server once.
65+
const troubleReportedUntil = new Map<string, number>();
6066

6167
const report = Effect.gen(function* () {
6268
const observedAtMs = yield* Clock.currentTimeMillis;
@@ -68,20 +74,32 @@ export const layerReporter = Layer.effectDiscard(
6874
registry
6975
.run(
7076
environmentId,
71-
request(WS_METHODS.serverReportClientActivity, {
72-
environmentId: environmentId as EnvironmentId,
73-
clientId,
74-
clientKind: "mobile",
75-
visible: active,
76-
focused: active,
77-
recentlyInteracted: active,
78-
appState: normalizeAppState(appState),
79-
scopes: [
80-
...BASELINE_SCOPES,
81-
...retainedMobileBackgroundScopes(environmentId as EnvironmentId),
82-
],
83-
ttlMs: LEASE_TTL_MS,
84-
observedAt: DateTime.makeUnsafe(observedAtMs),
77+
Effect.gen(function* () {
78+
const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
79+
const log =
80+
supervisor.log === undefined ? [] : yield* SubscriptionRef.get(supervisor.log);
81+
const trouble = connectionTroubleSince(
82+
log,
83+
troubleReportedUntil.get(environmentId) ?? 0,
84+
);
85+
yield* request(WS_METHODS.serverReportClientActivity, {
86+
environmentId: environmentId as EnvironmentId,
87+
clientId,
88+
clientKind: "mobile",
89+
visible: active,
90+
focused: active,
91+
recentlyInteracted: active,
92+
appState: normalizeAppState(appState),
93+
scopes: [
94+
...BASELINE_SCOPES,
95+
...retainedMobileBackgroundScopes(environmentId as EnvironmentId),
96+
],
97+
ttlMs: LEASE_TTL_MS,
98+
observedAt: DateTime.makeUnsafe(observedAtMs),
99+
...(trouble.length === 0 ? {} : { connectionTrouble: trouble }),
100+
});
101+
const newest = trouble.at(-1);
102+
if (newest !== undefined) troubleReportedUntil.set(environmentId, newest.at);
85103
}),
86104
)
87105
// Not `ignore`: tearing down an environment mid-request interrupts its report,

‎apps/mobile/src/connection/platform.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ const layerWakeups = Wakeups.layer({
137137
],
138138
{ concurrency: "unbounded" },
139139
),
140+
isForeground: Effect.sync(() => AppState.currentState === "active"),
140141
});
141142

142143
const layerCapabilities = Layer.effectContext(
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useAtomValue } from "@effect/atom-react";
2+
import { formatConnectionLog } from "@t3tools/client-runtime/connection";
3+
import type { EnvironmentId } from "@t3tools/contracts";
4+
import * as Clipboard from "expo-clipboard";
5+
import { AsyncResult } from "effect/reactivity";
6+
import { View } from "react-native";
7+
8+
import { AppText as Text } from "../../components/ui/AppText";
9+
import { ListActionRow } from "../../components/ui/ListActionRow";
10+
import { environmentCatalog } from "../../connection/catalog";
11+
import { SettingsSection } from "./components/SettingsSection";
12+
13+
const SHOWN_EVENTS = 10;
14+
15+
/** The last connection attempts on this device, so a failure explains itself when shared. */
16+
export function ConnectionLogSection(props: { readonly environmentId: EnvironmentId }) {
17+
const log = AsyncResult.getOrElse(
18+
useAtomValue(environmentCatalog.connectionLogAtom(props.environmentId)),
19+
() => [],
20+
);
21+
if (log.length === 0) return null;
22+
return (
23+
<SettingsSection title="Connection log">
24+
<View className="p-4">
25+
<Text selectable className="font-mono text-xs text-foreground-muted">
26+
{formatConnectionLog(log.slice(-SHOWN_EVENTS).reverse())}
27+
</Text>
28+
</View>
29+
<ListActionRow
30+
icon="doc.on.doc"
31+
label="Copy connection log"
32+
description={`${log.length} recent events`}
33+
onPress={() => {
34+
void Clipboard.setStringAsync(formatConnectionLog(log)).catch(() => undefined);
35+
}}
36+
/>
37+
</SettingsSection>
38+
);
39+
}

‎apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { environmentSession, useEnvironmentScope, readEnvironmentScope } from ".
2626
import { useAtomCommand } from "../../state/use-atom-command";
2727
import { useRemoteConnections } from "../../state/use-remote-environment-registry";
2828
import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
29+
import { ConnectionLogSection } from "./ConnectionLogSection";
2930
import { EnvironmentRoutesSection } from "./EnvironmentRoutesSection";
3031
import { OutdatedServerUpdateSection } from "./OutdatedServerUpdateSection";
3132
import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow";
@@ -191,6 +192,7 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
191192
onUpdate={connections.onUpdateEnvironment}
192193
/>
193194
</SettingsSection>
195+
<ConnectionLogSection environmentId={environmentId} />
194196
<EnvironmentRoutesSection
195197
environmentId={environmentId}
196198
connected={connected}

‎apps/server/src/background/BackgroundPolicy.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,13 @@ export const make = Effect.fn("background.policy.make")(function* () {
215215
const reportClientActivity: BackgroundPolicy["Service"]["reportClientActivity"] = Effect.fn(
216216
"BackgroundPolicy.reportClientActivity",
217217
)(function* (sessionId, rpcClientId, input) {
218+
if (input.connectionTrouble !== undefined && input.connectionTrouble.length > 0) {
219+
yield* Effect.logInfo("client reported connection trouble", {
220+
clientId: input.clientId,
221+
clientKind: input.clientKind,
222+
events: input.connectionTrouble,
223+
});
224+
}
218225
const ttlMs = Math.min(Math.max(input.ttlMs ?? DEFAULT_LEASE_TTL_MS, 1_000), MAX_LEASE_TTL_MS);
219226
const now = yield* DateTime.now;
220227
const expiresAt = DateTime.add(now, { milliseconds: ttlMs });

‎apps/web/src/connection/platform.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ const layerWakeups = Wakeups.layer({
149149
],
150150
{ concurrency: "unbounded" },
151151
),
152+
isForeground: Effect.sync(() => document.visibilityState === "visible"),
152153
});
153154

154155
export function currentClientMetadata() {
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { ConnectionTroubleEvent } from "@t3tools/contracts";
2+
import * as DateTime from "effect/DateTime";
3+
4+
import type { NetworkStatus, SupervisorConnectionState } from "./model.ts";
5+
6+
export const CONNECTION_LOG_LIMIT = 50;
7+
const DETAIL_LIMIT = 160;
8+
const TROUBLE_REPORT_LIMIT = 10;
9+
10+
export interface ConnectionLogEvent {
11+
readonly at: number;
12+
readonly kind: "attempt" | "connected" | "failed" | "lost" | "waiting";
13+
readonly attempt: number;
14+
readonly route: string | null;
15+
readonly stage: SupervisorConnectionState["stage"];
16+
readonly reason: string | null;
17+
readonly detail: string | null;
18+
readonly network: NetworkStatus;
19+
readonly foreground: boolean | null;
20+
}
21+
22+
/** Names the transition worth keeping, or null for progress within the same attempt. */
23+
export function connectionLogKind(
24+
previous: SupervisorConnectionState,
25+
next: SupervisorConnectionState,
26+
): ConnectionLogEvent["kind"] | null {
27+
if (next.phase === previous.phase && next.attempt === previous.attempt) return null;
28+
switch (next.phase) {
29+
case "connecting":
30+
return previous.phase === "connecting" && next.attempt === previous.attempt
31+
? null
32+
: "attempt";
33+
case "connected":
34+
return "connected";
35+
case "backoff":
36+
case "blocked":
37+
return previous.phase === "connected" ? "lost" : "failed";
38+
case "offline":
39+
return "waiting";
40+
case "available":
41+
return null;
42+
}
43+
}
44+
45+
export function appendConnectionLog(
46+
log: ReadonlyArray<ConnectionLogEvent>,
47+
event: ConnectionLogEvent,
48+
): ReadonlyArray<ConnectionLogEvent> {
49+
const next = [...log, { ...event, detail: truncate(event.detail) }];
50+
return next.length > CONNECTION_LOG_LIMIT ? next.slice(-CONNECTION_LOG_LIMIT) : next;
51+
}
52+
53+
/** Failures and losses after `since`, newest last, small enough to ride an activity report. */
54+
export function connectionTroubleSince(
55+
log: ReadonlyArray<ConnectionLogEvent>,
56+
since: number,
57+
): ReadonlyArray<ConnectionTroubleEvent> {
58+
return log
59+
.filter((event) => event.at > since && (event.kind === "failed" || event.kind === "lost"))
60+
.slice(-TROUBLE_REPORT_LIMIT)
61+
.map((event) => ({
62+
at: event.at,
63+
kind: event.kind as ConnectionTroubleEvent["kind"],
64+
attempt: event.attempt,
65+
...(event.route === null ? {} : { route: event.route }),
66+
...(event.reason === null ? {} : { reason: event.reason }),
67+
...(event.detail === null ? {} : { detail: event.detail }),
68+
network: event.network,
69+
...(event.foreground === null ? {} : { foreground: event.foreground }),
70+
}));
71+
}
72+
73+
export function formatConnectionLog(log: ReadonlyArray<ConnectionLogEvent>): string {
74+
return log
75+
.map((event) =>
76+
[
77+
DateTime.formatIso(DateTime.makeUnsafe(event.at)),
78+
event.kind,
79+
`#${event.attempt}`,
80+
event.route,
81+
event.stage,
82+
event.reason,
83+
event.detail,
84+
`net=${event.network}`,
85+
event.foreground === null ? null : event.foreground ? "fg" : "bg",
86+
]
87+
.filter((part) => part !== null)
88+
.join(" "),
89+
)
90+
.join("\n");
91+
}
92+
93+
function truncate(value: string | null): string | null {
94+
return value === null || value.length <= DETAIL_LIMIT
95+
? value
96+
: `${value.slice(0, DETAIL_LIMIT - 1)}…`;
97+
}

‎packages/client-runtime/src/connection/driver.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,10 @@ export const make = Effect.gen(function* () {
160160
const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
161161
const httpClient = yield* HttpClient.HttpClient;
162162

163-
// The descriptor is public, so this sends no credential to whatever
164-
// answers at a saved LAN address on a different network.
163+
// The descriptor is public, so it sends no credential to a stranger at a saved LAN address.
164+
// Nothing here forces a new connection on iOS: NSURLSession ignores `Connection: close` and
165+
// pools per origin. The WebSocket opens its own TCP connection every attempt, so a route whose
166+
// check went silent is still dialled.
165167
const checkDescriptor = (httpBaseUrl: string, environmentId: string) =>
166168
fetchRemoteEnvironmentDescriptor({
167169
httpBaseUrl,

‎packages/client-runtime/src/connection/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from "./hostFreeze.ts";
99
export * as Connection from "./layer.ts";
1010
export * from "./model.ts";
1111
export * as ConnectionOnboarding from "./onboarding.ts";
12+
export * from "./connectionLog.ts";
1213
export * from "./presentation.ts";
1314
export * as ProfileStore from "./profileStore.ts";
1415
export * from "./routes.ts";

‎packages/client-runtime/src/connection/supervisor.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from "./model.ts";
3636
import * as RpcSession from "../rpc/session.ts";
3737
import { presentConnectionState } from "./presentation.ts";
38+
import { CONNECTION_LOG_LIMIT } from "./connectionLog.ts";
3839
import * as EnvironmentSupervisor from "./supervisor.ts";
3940
import * as ConnectionWakeups from "./wakeups.ts";
4041
import { connectionRouteId, entryWithRoutes, isLearned, mergeLearnedRoutes } from "./routes.ts";
@@ -150,6 +151,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
150151
readonly checkHost?: (prepared: PreparedConnection) => Effect.Effect<ConnectionDriver.RouteCheck>;
151152
readonly preflight?: (route: ConnectionRoute) => Effect.Effect<ConnectionDriver.RoutePreflight>;
152153
readonly inboundFrames?: Effect.Effect<number>;
154+
readonly foreground?: Ref.Ref<boolean>;
153155
}) {
154156
const networkStatus = yield* SubscriptionRef.make<NetworkStatus>(
155157
options?.networkStatus ?? "online",
@@ -238,6 +240,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
238240
Stream.drop(1),
239241
Stream.map((event) => event.reason),
240242
),
243+
...(options?.foreground === undefined ? {} : { isForeground: Ref.get(options.foreground) }),
241244
}),
242245
),
243246
Layer.succeed(
@@ -569,6 +572,44 @@ describe("EnvironmentSupervisor", () => {
569572
}).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
570573
);
571574

575+
it.effect("retries at least every 15 seconds while foregrounded and logs each attempt", () =>
576+
Effect.gen(function* () {
577+
const foreground = yield* Ref.make(true);
578+
const harness = yield* makeHarness({
579+
foreground,
580+
prepare: () => Effect.fail(transient("The host did not answer.")),
581+
});
582+
const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
583+
initiallyDesired: true,
584+
}).pipe(Effect.provide(harness.dependencies));
585+
yield* awaitState(supervisor.state, (state) => state.phase === "backoff");
586+
587+
// Uncapped, ten failures back off for minutes; foregrounded, each waits 15 seconds at most.
588+
for (let tick = 0; tick < 40; tick += 1) {
589+
yield* TestClock.adjust("15 seconds");
590+
}
591+
const foregroundAttempts = yield* Ref.get(harness.prepareCount);
592+
expect(foregroundAttempts).toBeGreaterThanOrEqual(30);
593+
594+
yield* Ref.set(foreground, false);
595+
for (let tick = 0; tick < 40; tick += 1) {
596+
yield* TestClock.adjust("15 seconds");
597+
}
598+
expect((yield* Ref.get(harness.prepareCount)) - foregroundAttempts).toBeLessThan(10);
599+
600+
const log = yield* SubscriptionRef.get(supervisor.log!);
601+
expect(log).toHaveLength(CONNECTION_LOG_LIMIT);
602+
expect(log.at(-1)).toMatchObject({
603+
kind: "failed",
604+
reason: "transport",
605+
detail: "The host did not answer.",
606+
network: "online",
607+
foreground: false,
608+
});
609+
expect(log.filter((event) => event.kind === "attempt").length).toBeGreaterThan(0);
610+
}).pipe(Effect.provide(TestClock.layer())),
611+
);
612+
572613
it.effect("does not report a session lost while suspended as a failed connection", () =>
573614
Effect.gen(function* () {
574615
const allowReconnect = yield* Deferred.make<void>();

0 commit comments

Comments
 (0)