Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 126 additions & 1 deletion apps/mobile/src/connection/background-activity.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,139 @@
import { EnvironmentId, WS_METHODS } from "@t3tools/contracts";
import {
AVAILABLE_CONNECTION_STATE,
type ConnectionCatalogEntry,
EnvironmentRegistry,
EnvironmentSupervisor,
type NetworkStatus,
type PreparedConnection,
RelayConnectionTarget,
type SupervisorConnectionState,
} from "@t3tools/client-runtime/connection";
import type { RpcSession, WsRpcProtocolClient } from "@t3tools/client-runtime/rpc";
import { type ClientActivityReportInput, EnvironmentId, WS_METHODS } from "@t3tools/contracts";
import { describe, expect, it } from "@effect/vitest";
import * as Clock from "effect/Clock";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
import * as TestClock from "effect/testing/TestClock";
import { vi } from "vite-plus/test";

import { MobileStorage } from "../persistence/mobile-storage";
import { mobileBackgroundActivityReporterLayer } from "./background-activity";
import {
onRetainedMobileBackgroundScopesChange,
observeMobileBackgroundActivitySubscription,
retainedMobileBackgroundScopes,
} from "./background-activity-scopes";

vi.mock("react-native", () => ({
AppState: { currentState: "active", addEventListener: () => ({ remove: () => {} }) },
}));
vi.mock("expo-secure-store", () => ({}));

describe("mobile background activity", () => {
it.effect("reports activity after reconnect when the initial report had no session", () =>
Effect.gen(function* () {
const environmentId = EnvironmentId.make("reconnecting-environment");
const target = new RelayConnectionTarget({ environmentId, label: "Test environment" });
const reports = yield* Queue.unbounded<ClientActivityReportInput>();
const attempts = yield* Queue.unbounded<void>();
const debounceArmed = yield* Queue.sliding<void>(1);
const clock = yield* Clock.Clock;
const reporterClock = {
...clock,
sleep: Effect.fn("TestMobileActivity.sleep")(function* (duration: Duration.Duration) {
if (Duration.toMillis(duration) !== 250) return yield* clock.sleep(duration);
const timer = yield* clock
.sleep(duration)
.pipe(Effect.forkChild({ startImmediately: true }));
yield* Queue.offer(debounceArmed, undefined);
yield* Fiber.join(timer);
}),
};
const state = yield* SubscriptionRef.make<SupervisorConnectionState>({
...AVAILABLE_CONNECTION_STATE,
desired: true,
network: "online",
phase: "connecting",
});
const session = yield* SubscriptionRef.make<Option.Option<RpcSession>>(Option.none());
const supervisor = EnvironmentSupervisor.of({
target,
state,
session,
prepared: yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(Option.none()),
connect: Effect.void,
disconnect: Effect.void,
retryNow: Effect.void,
});
const registryLayer = Layer.mock(EnvironmentRegistry, {
entries: yield* SubscriptionRef.make<ReadonlyMap<EnvironmentId, ConnectionCatalogEntry>>(
new Map([[environmentId, { target, profile: Option.none() }]]),
),
networkStatus: yield* SubscriptionRef.make<NetworkStatus>("online"),
run: (_environmentId, effect) =>
Effect.provideService(effect, EnvironmentSupervisor, supervisor).pipe(
Effect.ensuring(Queue.offer(attempts, undefined)),
),
followStream: (_environmentId, stream) =>
Stream.provideService(stream, EnvironmentSupervisor, supervisor),
});
yield* Layer.build(
mobileBackgroundActivityReporterLayer.pipe(
Layer.provide(registryLayer),
Layer.provide(
Layer.mock(MobileStorage, {
loadOrCreateAgentAwarenessDeviceId: Effect.succeed("test-device"),
}),
),
),
).pipe(Effect.provideService(Clock.Clock, reporterClock));

yield* Queue.take(debounceArmed);
yield* TestClock.adjust("250 millis");
yield* Queue.take(attempts);
yield* Queue.clear(debounceArmed);
expect(yield* Queue.size(reports)).toBe(0);

const client = {
[WS_METHODS.serverReportClientActivity]: (input: ClientActivityReportInput) =>
Queue.offer(reports, input).pipe(Effect.asVoid),
} satisfies Pick<WsRpcProtocolClient, typeof WS_METHODS.serverReportClientActivity>;
yield* SubscriptionRef.set(
session,
Option.some({
client: client as unknown as WsRpcProtocolClient,
initialConfig: Effect.die("Activity reports do not read server config."),
subscribeServerConfig: () => Stream.empty,
ready: Effect.void,
probe: Effect.void,
closed: Effect.never,
}),
);
yield* SubscriptionRef.update(state, (current) => ({
...current,
phase: "connected" as const,
generation: 1,
}));
yield* Queue.take(debounceArmed);
yield* TestClock.adjust("250 millis");
expect(yield* Queue.take(reports)).toMatchObject({
environmentId,
clientId: "mobile-test-device",
visible: true,
focused: true,
appState: "active",
scopes: [{ type: "provider-status" }],
});
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("retains VCS demand only while the mobile subscription is active", () =>
Effect.gen(function* () {
const environmentId = EnvironmentId.make("mobile-environment");
Expand Down
29 changes: 28 additions & 1 deletion apps/mobile/src/connection/background-activity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
import { EnvironmentRegistry, EnvironmentSupervisor } from "@t3tools/client-runtime/connection";
import { EnvironmentRpcSubscriptionObserver, request } from "@t3tools/client-runtime/rpc";
import {
type BackgroundScope,
Expand Down Expand Up @@ -103,6 +103,33 @@ export const mobileBackgroundActivityReporterLayer = Layer.effectDiscard(
Stream.runForEach(() => Effect.sync(requestReport)),
Effect.forkScoped,
);
// A resume report can fail before the socket reconnects. Report again when
// it connects. Each supervisor has its own generation counter, so restart
// deduplication when the registry replaces the supervisor.
const connectedGenerations = (environmentId: EnvironmentId) =>
registry.followStream(
environmentId,
Stream.unwrap(
Effect.map(EnvironmentSupervisor, (supervisor) =>
SubscriptionRef.changes(supervisor.state).pipe(
Stream.filter((state) => state.phase === "connected"),
Stream.map((state) => state.generation),
Stream.changes,
),
),
),
);
yield* SubscriptionRef.changes(registry.entries).pipe(
Stream.map((entries) => [...entries.keys()].sort()),
Stream.changesWith((a, b) => a.length === b.length && a.every((id, i) => id === b[i])),
Stream.switchMap((environmentIds) =>
Stream.mergeAll(environmentIds.map(connectedGenerations), {
concurrency: "unbounded",
}),
),
Stream.runForEach(() => Effect.sync(requestReport)),
Effect.forkScoped,
);
yield* Stream.fromQueue(reportRequests).pipe(
Stream.debounce("250 millis"),
Stream.runForEach(() => report),
Expand Down
42 changes: 42 additions & 0 deletions apps/mobile/src/connection/network-path-change.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from "@effect/vitest";

import {
observeNetworkPath,
seedNetworkPathBaseline,
UNKNOWN_NETWORK_PATH,
} from "./network-path-change";

describe("network path change detection", () => {
it("does not probe when the listener matches the seeded interface", () => {
const seeded = seedNetworkPathBaseline(UNKNOWN_NETWORK_PATH, "WIFI");

expect(observeNetworkPath(seeded, "WIFI")).toEqual({
baseline: { known: true, type: "WIFI" },
shouldProbe: false,
});
});

it("probes when a known interface changes", () => {
const seeded = seedNetworkPathBaseline(UNKNOWN_NETWORK_PATH, "WIFI");

expect(observeNetworkPath(seeded, "CELLULAR").shouldProbe).toBe(true);
});

it("probes when the listener wins the race with the async seed", () => {
const observed = observeNetworkPath(UNKNOWN_NETWORK_PATH, "CELLULAR");

expect(observed.shouldProbe).toBe(true);
expect(seedNetworkPathBaseline(observed.baseline, "WIFI")).toEqual({
known: true,
type: "CELLULAR",
});
});

it("probes on the first known interface after an unknown observation", () => {
const seeded = seedNetworkPathBaseline(UNKNOWN_NETWORK_PATH, "WIFI");
const unknown = observeNetworkPath(seeded, null);

expect(unknown.shouldProbe).toBe(false);
expect(observeNetworkPath(unknown.baseline, "CELLULAR").shouldProbe).toBe(true);
});
});
41 changes: 41 additions & 0 deletions apps/mobile/src/connection/network-path-change.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export interface NetworkPathBaseline {
readonly known: boolean;
readonly type: string | null;
}

export const UNKNOWN_NETWORK_PATH: NetworkPathBaseline = {
known: false,
type: null,
};

export function seedNetworkPathBaseline(
baseline: NetworkPathBaseline,
type: string | null,
): NetworkPathBaseline {
if (baseline.known || type === null) {
return baseline;
}
return { known: true, type };
}

export function observeNetworkPath(
baseline: NetworkPathBaseline,
type: string | null,
): {
readonly baseline: NetworkPathBaseline;
readonly shouldProbe: boolean;
} {
if (type === null) {
return {
baseline: UNKNOWN_NETWORK_PATH,
shouldProbe: false,
};
}
return {
baseline: { known: true, type },
// If the initial async seed has not landed, the first listener event may
// itself be the WiFi/cellular transition. A cheap advisory probe is safer
// than losing that transition and waiting for the socket ping timeout.
shouldProbe: !baseline.known || baseline.type !== type,
};
}
37 changes: 35 additions & 2 deletions apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ import { appAtomRegistry } from "../state/atom-registry";
import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal";
import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";
import { mobileApplicationActiveWakeup } from "./app-state-wakeups";
import {
observeNetworkPath,
seedNetworkPathBaseline,
UNKNOWN_NETWORK_PATH,
} from "./network-path-change";
import { connectionStorageLayer } from "./storage";

function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" {
Expand Down Expand Up @@ -88,11 +93,13 @@ const connectivityLayer = Connectivity.layer({

const wakeupsLayer = Wakeups.layer({
changes: Stream.merge(
Stream.callback<"application-active-probe" | "application-active-reconnect">((queue) =>
Stream.callback<
"application-active-probe" | "application-active-reconnect" | "network-path-changed"
>((queue) =>
Effect.acquireRelease(
Effect.sync(() => {
let backgroundedAtMs = AppState.currentState === "background" ? Date.now() : null;
return AppState.addEventListener("change", (state) => {
const appStateSubscription = AppState.addEventListener("change", (state) => {
if (state === "background") {
backgroundedAtMs = Date.now();
return;
Expand All @@ -102,6 +109,32 @@ const wakeupsLayer = Wakeups.layer({
backgroundedAtMs = null;
}
});
// Wi-Fi/cellular changes can keep isConnected true while the socket
// stops working. Probe active sessions when the interface changes.
// Seed the baseline because the listener only reports changes.
let networkPath = UNKNOWN_NETWORK_PATH;
void Network.getNetworkStateAsync()
.then((current) => {
networkPath = seedNetworkPathBaseline(networkPath, current.type ?? null);
})
.catch(() => undefined);
const networkSubscription = Network.addNetworkStateListener((state) => {
const observation = observeNetworkPath(networkPath, state.type ?? null);
networkPath = observation.baseline;
if (
observation.shouldProbe &&
state.isConnected === true &&
AppState.currentState === "active"
) {
Queue.offerUnsafe(queue, "network-path-changed");
}
Comment thread
cursor[bot] marked this conversation as resolved.
});
return {
remove: () => {
appStateSubscription.remove();
networkSubscription.remove();
},
};
}),
(subscription) => Effect.sync(() => subscription.remove()),
).pipe(Effect.asVoid),
Expand Down
11 changes: 11 additions & 0 deletions docs/internals/connection-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ Wakeup handling differs by phase, in [supervisor.ts][supervisor]:
mobile's `application-active-probe`) rather than reconnecting; a healthy
session survives foregrounding. `application-active-reconnect` skips the probe
and replaces the lease outright.
- Mobile emits `network-path-changed` when the active network interface changes
while the app is active and online. A connected supervisor probes its current
session with a three-second timeout. Other phases ignore this advisory wakeup,
so repeated interface changes do not shorten backoff. A failed probe uses the
existing immediate reconnect path. It does not request a shell resubscription.

Mobile sends an activity report after each newly connected session generation.
This restores the server's activity lease if the foreground report failed while
the socket was down, without waiting for the 25-second reporting interval.
Reports use the existing 250-millisecond debounce. Generation deduplication is
per supervisor because replacement supervisors restart their counters.

The UI derives `available`, `offline`, `connecting`, `reconnecting`,
`connected`, and `error` from supervisor state plus explicit data-sync state.
Expand Down
8 changes: 8 additions & 0 deletions docs/user/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ With mise, asdf, fnm, or nodenv, make sure the tool's shim directory is installe

If reconnecting after an app update fails, retry the SSH launch once. The launcher now compares its generated runner script, stops stale launcher-managed remote servers, clears the SSH launch PID/port state, and starts a fresh remote server. You should not normally need to delete `~/.t3/ssh-launch` or kill `t3` processes manually.

## Mobile network changes

When your phone switches between Wi-Fi and cellular while the app is active, T3 Code checks each
connected environment and reconnects if it does not respond. After reconnecting, the app sends
your current activity state so provider status and repository updates can resume.

A server at a local network address still requires access to that network.

## Updating a Remote Server

When the T3 Code web or desktop app and a remote server use different versions, a warning appears in
Expand Down
Loading
Loading