Skip to content

Commit 798be96

Browse files
authored
Merge pull request #193 from jayleaton/fix/mobile-30s-drop-20261104
fix(client-runtime): foreground retries every 15 s and phones keep a connection log
2 parents e2f3eaf + 13db1c7 commit 798be96

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
@@ -40,6 +40,7 @@ import {
4040
} from "./model.ts";
4141
import * as RpcSession from "../rpc/session.ts";
4242
import { presentConnectionState } from "./presentation.ts";
43+
import { CONNECTION_LOG_LIMIT } from "./connectionLog.ts";
4344
import * as EnvironmentSupervisor from "./supervisor.ts";
4445
import * as ConnectionWakeups from "./wakeups.ts";
4546
import { connectionRouteId, entryWithRoutes, isLearned, mergeLearnedRoutes } from "./routes.ts";
@@ -155,6 +156,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
155156
readonly checkHost?: (prepared: PreparedConnection) => Effect.Effect<ConnectionDriver.RouteCheck>;
156157
readonly preflight?: (route: ConnectionRoute) => Effect.Effect<ConnectionDriver.RoutePreflight>;
157158
readonly inboundFrames?: Effect.Effect<number>;
159+
readonly foreground?: Ref.Ref<boolean>;
158160
}) {
159161
const networkStatus = yield* SubscriptionRef.make<NetworkStatus>(
160162
options?.networkStatus ?? "online",
@@ -243,6 +245,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
243245
Stream.drop(1),
244246
Stream.map((event) => event.reason),
245247
),
248+
...(options?.foreground === undefined ? {} : { isForeground: Ref.get(options.foreground) }),
246249
}),
247250
),
248251
Layer.succeed(
@@ -623,6 +626,44 @@ describe("EnvironmentSupervisor", () => {
623626
}).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
624627
);
625628

629+
it.effect("retries at least every 15 seconds while foregrounded and logs each attempt", () =>
630+
Effect.gen(function* () {
631+
const foreground = yield* Ref.make(true);
632+
const harness = yield* makeHarness({
633+
foreground,
634+
prepare: () => Effect.fail(transient("The host did not answer.")),
635+
});
636+
const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
637+
initiallyDesired: true,
638+
}).pipe(Effect.provide(harness.dependencies));
639+
yield* awaitState(supervisor.state, (state) => state.phase === "backoff");
640+
641+
// Uncapped, ten failures back off for minutes; foregrounded, each waits 15 seconds at most.
642+
for (let tick = 0; tick < 40; tick += 1) {
643+
yield* TestClock.adjust("15 seconds");
644+
}
645+
const foregroundAttempts = yield* Ref.get(harness.prepareCount);
646+
expect(foregroundAttempts).toBeGreaterThanOrEqual(30);
647+
648+
yield* Ref.set(foreground, false);
649+
for (let tick = 0; tick < 40; tick += 1) {
650+
yield* TestClock.adjust("15 seconds");
651+
}
652+
expect((yield* Ref.get(harness.prepareCount)) - foregroundAttempts).toBeLessThan(10);
653+
654+
const log = yield* SubscriptionRef.get(supervisor.log!);
655+
expect(log).toHaveLength(CONNECTION_LOG_LIMIT);
656+
expect(log.at(-1)).toMatchObject({
657+
kind: "failed",
658+
reason: "transport",
659+
detail: "The host did not answer.",
660+
network: "online",
661+
foreground: false,
662+
});
663+
expect(log.filter((event) => event.kind === "attempt").length).toBeGreaterThan(0);
664+
}).pipe(Effect.provide(TestClock.layer())),
665+
);
666+
626667
it.effect("does not report a session lost while suspended as a failed connection", () =>
627668
Effect.gen(function* () {
628669
const allowReconnect = yield* Deferred.make<void>();

0 commit comments

Comments
 (0)