authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-30 04:10:26-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 04:10:26-07:00
log69a18ce912ac3ae93cf498cfe0cee40beb49fb9f
treea9df8140e4ce993820d437c51386afec98bea00b
parentf0c6ba994670bbd9eeab97eab6701d5c3589178d
signature Signed by PGP key B5690EEEBB952194

perf(mobile): reconnect environments immediately on resume (#4878)


13 files changed, 547 insertions(+), 93 deletions(-)

apps/mobile/src/connection/app-state-wakeups.test.ts created+21
......@@ -0,0 +1,21 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 MOBILE_BACKGROUND_RECONNECT_AFTER_MS,
5 mobileApplicationActiveWakeup,
6} from "./app-state-wakeups";
7
8describe("mobileApplicationActiveWakeup", () => {
9 it("uses a fast probe after a short interruption", () => {
10 expect(mobileApplicationActiveWakeup(null, 20_000)).toBe("application-active-probe");
11 expect(
12 mobileApplicationActiveWakeup(20_000, 20_000 + MOBILE_BACKGROUND_RECONNECT_AFTER_MS - 1),
13 ).toBe("application-active-probe");
14 });
15
16 it("replaces the session after a meaningful background suspension", () => {
17 expect(
18 mobileApplicationActiveWakeup(20_000, 20_000 + MOBILE_BACKGROUND_RECONNECT_AFTER_MS),
19 ).toBe("application-active-reconnect");
20 });
21});
apps/mobile/src/connection/app-state-wakeups.ts created+18
......@@ -0,0 +1,18 @@
1import type { Wakeups } from "@t3tools/client-runtime/connection";
2
3export const MOBILE_BACKGROUND_RECONNECT_AFTER_MS = 10_000;
4
5export type MobileApplicationActiveWakeup = Extract<
6 Wakeups.ConnectionWakeup,
7 "application-active-probe" | "application-active-reconnect"
8>;
9
10export function mobileApplicationActiveWakeup(
11 backgroundedAtMs: number | null,
12 activeAtMs: number,
13): MobileApplicationActiveWakeup {
14 return backgroundedAtMs !== null &&
15 activeAtMs - backgroundedAtMs >= MOBILE_BACKGROUND_RECONNECT_AFTER_MS
16 ? "application-active-reconnect"
17 : "application-active-probe";
18}
apps/mobile/src/connection/platform.ts+38-11
......@@ -30,6 +30,7 @@ import * as MobileStorage from "../persistence/mobile-storage";
3030import { appAtomRegistry } from "../state/atom-registry";
3131import { clearThreadOutboxEnvironment } from "../state/thread-outbox";
3232import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";
33import { mobileApplicationActiveWakeup } from "./app-state-wakeups";
3334import { connectionStorageLayer } from "./storage";
3435
3536function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" {
......@@ -54,27 +55,53 @@ const connectivityLayer = Connectivity.layer({
5455 ),
5556 changes: Stream.callback((queue) =>
5657 Effect.acquireRelease(
57 Effect.sync(() =>
58 Network.addNetworkStateListener((state) => {
58 Effect.sync(() => {
59 let active = true;
60 const networkSubscription = Network.addNetworkStateListener((state) => {
5961 Queue.offerUnsafe(queue, networkStatus(state));
60 }),
61 ),
62 (subscription) => Effect.sync(() => subscription.remove()),
62 });
63 const appStateSubscription = AppState.addEventListener("change", (state) => {
64 if (state !== "active") {
65 return;
66 }
67 void Network.getNetworkStateAsync()
68 .then((current) => {
69 if (active) {
70 Queue.offerUnsafe(queue, networkStatus(current));
71 }
72 })
73 .catch(() => undefined);
74 });
75 return {
76 close: () => {
77 active = false;
78 networkSubscription.remove();
79 appStateSubscription.remove();
80 },
81 };
82 }),
83 ({ close }) => Effect.sync(close),
6384 ).pipe(Effect.asVoid),
6485 ),
6586});
6687
6788const wakeupsLayer = Wakeups.layer({
6889 changes: Stream.merge(
69 Stream.callback<"application-active">((queue) =>
90 Stream.callback<"application-active-probe" | "application-active-reconnect">((queue) =>
7091 Effect.acquireRelease(
71 Effect.sync(() =>
72 AppState.addEventListener("change", (state) => {
92 Effect.sync(() => {
93 let backgroundedAtMs = AppState.currentState === "background" ? Date.now() : null;
94 return AppState.addEventListener("change", (state) => {
95 if (state === "background") {
96 backgroundedAtMs = Date.now();
97 return;
98 }
7399 if (state === "active") {
74 Queue.offerUnsafe(queue, "application-active");
100 Queue.offerUnsafe(queue, mobileApplicationActiveWakeup(backgroundedAtMs, Date.now()));
101 backgroundedAtMs = null;
75102 }
76 }),
77 ),
103 });
104 }),
78105 (subscription) => Effect.sync(() => subscription.remove()),
79106 ).pipe(Effect.asVoid),
80107 ),
docs/architecture/connection-runtime.md+3
......@@ -40,6 +40,9 @@ The supervisor is the only retry owner.
4040 seconds.
41415. Connectivity changes, application activation, credential changes, and
4242 explicit user retry interrupt the current wait and trigger a fresh attempt.
43 Application activation also resets the backoff ladder. Mobile briefly probes
44 a session after short interruptions and replaces it immediately after a
45 meaningful background suspension.
43466. Authentication or configuration failures remain blocked until an external
4447 wakeup changes the relevant input.
45487. An involuntary session close keeps the registration and cache, then retries.
packages/client-runtime/src/authorization/layer.test.ts+78-14
......@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55import * as Option from "effect/Option";
66import * as Ref from "effect/Ref";
7import * as TestClock from "effect/testing/TestClock";
78
89import * as ManagedRelay from "../relay/managedRelay.ts";
910import { remoteHttpClientLayer } from "../rpc/http.ts";
......@@ -155,6 +156,81 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (
155156});
156157
157158describe("RemoteEnvironmentAuthorization", () => {
159 it.effect("reuses a validated bearer descriptor while issuing fresh websocket tickets", () =>
160 Effect.gen(function* () {
161 const harness = yield* makeHarness({
162 responses: [
163 Response.json(DESCRIPTOR),
164 websocketTicket("first-ticket"),
165 websocketTicket("second-ticket"),
166 ],
167 });
168
169 const [first, second] = yield* Effect.gen(function* () {
170 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
171 const authorize = () =>
172 remote.authorizeBearer({
173 expectedEnvironmentId: ENVIRONMENT_ID,
174 httpBaseUrl: ENDPOINT.httpBaseUrl,
175 wsBaseUrl: ENDPOINT.wsBaseUrl,
176 bearerToken: "bearer-token",
177 });
178 return [yield* authorize(), yield* authorize()] as const;
179 }).pipe(Effect.provide(harness.layer));
180
181 expect(first.socketUrl).toContain("wsTicket=first-ticket");
182 expect(second.socketUrl).toContain("wsTicket=second-ticket");
183 expect(
184 harness.fetch.calls.filter(([url]) => String(url).endsWith("/.well-known/t3/environment")),
185 ).toHaveLength(1);
186 expect(
187 harness.fetch.calls.filter(([url]) => String(url).endsWith("/api/auth/websocket-ticket")),
188 ).toHaveLength(2);
189 }),
190 );
191
192 it.effect("revalidates a bearer descriptor after the cache expires", () =>
193 Effect.gen(function* () {
194 const reassignedEnvironmentId = EnvironmentId.make("environment-2");
195 const harness = yield* makeHarness({
196 responses: [
197 Response.json(DESCRIPTOR),
198 websocketTicket("first-ticket"),
199 Response.json({
200 ...DESCRIPTOR,
201 environmentId: reassignedEnvironmentId,
202 }),
203 ],
204 });
205
206 const failure = yield* Effect.gen(function* () {
207 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
208 const authorize = () =>
209 remote.authorizeBearer({
210 expectedEnvironmentId: ENVIRONMENT_ID,
211 httpBaseUrl: ENDPOINT.httpBaseUrl,
212 wsBaseUrl: ENDPOINT.wsBaseUrl,
213 bearerToken: "bearer-token",
214 });
215
216 yield* authorize();
217 yield* TestClock.adjust("10 seconds");
218 return yield* authorize().pipe(Effect.flip);
219 }).pipe(Effect.provide(Layer.merge(harness.layer, TestClock.layer())));
220
221 expect(failure).toEqual(
222 expect.objectContaining({
223 _tag: "ConnectionBlockedError",
224 reason: "configuration",
225 detail: `Connected environment ${reassignedEnvironmentId} does not match ${ENVIRONMENT_ID}.`,
226 }),
227 );
228 expect(
229 harness.fetch.calls.filter(([url]) => String(url).endsWith("/.well-known/t3/environment")),
230 ).toHaveLength(2);
231 }),
232 );
233
158234 it.effect("reuses a valid persisted environment token without contacting the relay", () =>
159235 Effect.gen(function* () {
160236 const cached = new TokenStore.RemoteDpopAccessToken({
......@@ -265,7 +341,7 @@ describe("RemoteEnvironmentAuthorization", () => {
265341 }),
266342 );
267343
268 it.effect("refreshes a cached endpoint after consecutive transient failures", () =>
344 it.effect("refreshes a cached endpoint after its first transient failure", () =>
269345 Effect.gen(function* () {
270346 const cached = new TokenStore.RemoteDpopAccessToken({
271347 environmentId: ENVIRONMENT_ID,
......@@ -279,7 +355,6 @@ describe("RemoteEnvironmentAuthorization", () => {
279355 initialToken: cached,
280356 responses: [
281357 new Response("endpoint unavailable", { status: 503 }),
282 new Response("endpoint still unavailable", { status: 503 }),
283358 Response.json(DESCRIPTOR),
284359 accessToken("replacement-access-token"),
285360 websocketTicket("replacement-ticket"),
......@@ -288,17 +363,6 @@ describe("RemoteEnvironmentAuthorization", () => {
288363
289364 const authorized = yield* Effect.gen(function* () {
290365 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
291 const firstFailure = yield* remote
292 .authorizeDpop({
293 expectedEnvironmentId: ENVIRONMENT_ID,
294 obtainBootstrap: harness.obtainBootstrap,
295 })
296 .pipe(Effect.flip);
297
298 expect(firstFailure._tag).toBe("ConnectionTransientError");
299 expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0);
300 expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toBe(cached);
301
302366 return yield* remote.authorizeDpop({
303367 expectedEnvironmentId: ENVIRONMENT_ID,
304368 obtainBootstrap: harness.obtainBootstrap,
......@@ -312,7 +376,7 @@ describe("RemoteEnvironmentAuthorization", () => {
312376 accessToken: "replacement-access-token",
313377 }),
314378 );
315 expect(harness.fetch.calls).toHaveLength(5);
379 expect(harness.fetch.calls).toHaveLength(4);
316380 }),
317381 );
318382
packages/client-runtime/src/authorization/service.ts+39-35
......@@ -1,4 +1,4 @@
1import { EnvironmentId } from "@t3tools/contracts";
1import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
22import type { RelayManagedEndpoint } from "@t3tools/contracts/relay";
33import {
44 exchangeRemoteDpopAccessToken,
......@@ -58,7 +58,8 @@ export class RemoteEnvironmentAuthorization extends Context.Service<
5858>()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {}
5959
6060const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000;
61const CACHED_ENDPOINT_FAILURE_THRESHOLD = 2;
61const CACHED_ENDPOINT_SOCKET_TIMEOUT_MS = 3_000;
62const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000;
6263
6364function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) {
6465 return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError"
......@@ -79,25 +80,16 @@ export const make = Effect.gen(function* () {
7980 const presentation = yield* ClientCapabilities.ClientPresentation;
8081 const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore;
8182 const httpClient = yield* HttpClient.HttpClient;
82 const cachedEndpointFailures = yield* Ref.make<ReadonlyMap<string, number>>(new Map());
83
84 const resetCachedEndpointFailures = (environmentId: string) =>
85 Ref.update(cachedEndpointFailures, (current) => {
86 if (!current.has(environmentId)) {
87 return current;
83 const bearerDescriptors = yield* Ref.make<
84 ReadonlyMap<
85 EnvironmentId,
86 {
87 readonly httpBaseUrl: string;
88 readonly descriptor: ExecutionEnvironmentDescriptor;
89 readonly validatedAtEpochMs: number;
8890 }
89 const next = new Map(current);
90 next.delete(environmentId);
91 return next;
92 });
93
94 const recordCachedEndpointFailure = (environmentId: string) =>
95 Ref.modify(cachedEndpointFailures, (current) => {
96 const failureCount = (current.get(environmentId) ?? 0) + 1;
97 const next = new Map(current);
98 next.set(environmentId, failureCount);
99 return [failureCount, next] as const;
100 });
91 >
92 >(new Map());
10193
10294 const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")(
10395 function* (input: {
......@@ -108,15 +100,33 @@ export const make = Effect.gen(function* () {
108100 readonly wsBaseUrl: string;
109101 readonly bearerToken: string;
110102 }) {
111 const descriptor = yield* fetchDescriptor(input.httpBaseUrl).pipe(
112 Effect.provideService(HttpClient.HttpClient, httpClient),
113 );
103 const now = yield* Clock.currentTimeMillis;
104 const cachedDescriptor = (yield* Ref.get(bearerDescriptors)).get(input.expectedEnvironmentId);
105 const canReuseDescriptor =
106 cachedDescriptor?.httpBaseUrl === input.httpBaseUrl &&
107 cachedDescriptor.validatedAtEpochMs + BEARER_DESCRIPTOR_CACHE_TTL_MS > now;
108 const descriptor = canReuseDescriptor
109 ? cachedDescriptor.descriptor
110 : yield* fetchDescriptor(input.httpBaseUrl).pipe(
111 Effect.provideService(HttpClient.HttpClient, httpClient),
112 );
114113 if (descriptor.environmentId !== input.expectedEnvironmentId) {
115114 return yield* environmentMismatchError({
116115 expected: input.expectedEnvironmentId,
117116 actual: descriptor.environmentId,
118117 });
119118 }
119 if (!canReuseDescriptor) {
120 yield* Ref.update(bearerDescriptors, (current) => {
121 const next = new Map(current);
122 next.set(input.expectedEnvironmentId, {
123 httpBaseUrl: input.httpBaseUrl,
124 descriptor,
125 validatedAtEpochMs: now,
126 });
127 return next;
128 });
129 }
120130 const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({
121131 wsBaseUrl: input.wsBaseUrl,
122132 httpBaseUrl: input.httpBaseUrl,
......@@ -139,7 +149,7 @@ export const make = Effect.gen(function* () {
139149 );
140150
141151 const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")(
142 function* (token: TokenStore.RemoteDpopAccessToken) {
152 function* (token: TokenStore.RemoteDpopAccessToken, timeoutMs?: number) {
143153 const ticketProof = yield* signer
144154 .createProof({
145155 method: "POST",
......@@ -160,6 +170,7 @@ export const make = Effect.gen(function* () {
160170 httpBaseUrl: token.endpoint.httpBaseUrl,
161171 accessToken: token.accessToken,
162172 dpopProof: ticketProof,
173 ...(timeoutMs === undefined ? {} : { timeoutMs }),
163174 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
164175 },
165176 );
......@@ -196,9 +207,11 @@ export const make = Effect.gen(function* () {
196207 yield* Effect.annotateCurrentSpan({
197208 "connection.remote_token_cache": "hit",
198209 });
199 const cachedSocket = yield* createDpopSocketUrl(cached.value).pipe(Effect.result);
210 const cachedSocket = yield* createDpopSocketUrl(
211 cached.value,
212 CACHED_ENDPOINT_SOCKET_TIMEOUT_MS,
213 ).pipe(Effect.result);
200214 if (Result.isSuccess(cachedSocket)) {
201 yield* resetCachedEndpointFailures(input.expectedEnvironmentId);
202215 return {
203216 environmentId: cached.value.environmentId,
204217 label: cached.value.label,
......@@ -213,20 +226,11 @@ export const make = Effect.gen(function* () {
213226 if (cachedSocket.failure._tag === "ConnectionBlockedError") {
214227 return yield* mapDpopSocketError(cachedSocket.failure);
215228 }
216 const mappedFailure = mapDpopSocketError(cachedSocket.failure);
217 if (mappedFailure._tag === "ConnectionTransientError") {
218 const failureCount = yield* recordCachedEndpointFailure(input.expectedEnvironmentId);
219 if (failureCount < CACHED_ENDPOINT_FAILURE_THRESHOLD) {
220 return yield* mappedFailure;
221 }
222 }
223229 yield* tokenStore
224230 .remove(input.expectedEnvironmentId)
225231 .pipe(Effect.withSpan("environment.authorization.accessToken.remove"));
226 yield* resetCachedEndpointFailures(input.expectedEnvironmentId);
227232 }
228233
229 yield* resetCachedEndpointFailures(input.expectedEnvironmentId);
230234 yield* Effect.annotateCurrentSpan({
231235 "connection.remote_token_cache": "miss",
232236 });
packages/client-runtime/src/connection/supervisor.test.ts+216-5
......@@ -124,7 +124,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
124124 const releaseCount = yield* Ref.make(0);
125125 const wakeups = yield* SubscriptionRef.make<{
126126 readonly sequence: number;
127 readonly reason: "application-active" | "credentials-changed";
127 readonly reason: ConnectionWakeups.ConnectionWakeup;
128128 }>({
129129 sequence: 0,
130130 reason: "application-active",
......@@ -198,7 +198,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?:
198198 sessionCount,
199199 releaseCount,
200200 setNetworkStatus: (status: NetworkStatus) => SubscriptionRef.set(networkStatus, status),
201 wake: (reason: "application-active" | "credentials-changed") =>
201 wake: (reason: ConnectionWakeups.ConnectionWakeup) =>
202202 SubscriptionRef.update(wakeups, (event) => ({
203203 sequence: event.sequence + 1,
204204 reason,
......@@ -311,6 +311,38 @@ describe("EnvironmentSupervisor", () => {
311311 }),
312312 );
313313
314 it.effect("resets retries when activation arrives before the network returns", () =>
315 Effect.gen(function* () {
316 const harness = yield* makeHarness();
317 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
318 initiallyDesired: true,
319 }).pipe(Effect.provide(harness.dependencies));
320
321 yield* awaitState(supervisor.state, (state) => state.phase === "connected");
322 yield* harness.closeLatestSession();
323 yield* awaitState(
324 supervisor.state,
325 (state) => state.phase === "backoff" && state.attempt === 1,
326 );
327 yield* harness.setNetworkStatus("offline");
328 yield* awaitState(
329 supervisor.state,
330 (state) => state.phase === "offline" && state.attempt === 2,
331 );
332
333 yield* harness.wake("application-active-reconnect");
334 yield* awaitState(
335 supervisor.state,
336 (state) => state.phase === "offline" && state.attempt === 1,
337 );
338 yield* harness.setNetworkStatus("online");
339 yield* awaitState(
340 supervisor.state,
341 (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1,
342 );
343 }),
344 );
345
314346 it.effect("retries forever with exponential backoff capped at sixteen seconds", () =>
315347 Effect.gen(function* () {
316348 const harness = yield* makeHarness({
......@@ -501,6 +533,38 @@ describe("EnvironmentSupervisor", () => {
501533 }).pipe(Effect.provide(TestClock.layer())),
502534 );
503535
536 it.effect("resets retries when activation wakes a blocked connection", () =>
537 Effect.gen(function* () {
538 const harness = yield* makeHarness({
539 prepare: (attempt) =>
540 attempt === 1
541 ? Effect.fail(transient())
542 : attempt === 2
543 ? Effect.fail(blocked())
544 : Effect.succeed(PREPARED_CONNECTION),
545 });
546 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
547 initiallyDesired: true,
548 }).pipe(Effect.provide(harness.dependencies));
549
550 yield* awaitState(
551 supervisor.state,
552 (state) => state.phase === "backoff" && state.attempt === 1,
553 );
554 yield* TestClock.adjust("1 second");
555 yield* awaitState(
556 supervisor.state,
557 (state) => state.phase === "blocked" && state.attempt === 2,
558 );
559
560 yield* harness.wake("application-active-reconnect");
561 yield* awaitState(
562 supervisor.state,
563 (state) => state.phase === "connected" && state.attempt === 1,
564 );
565 }).pipe(Effect.provide(TestClock.layer())),
566 );
567
504568 it.effect("releases a live session while offline and starts a new generation when online", () =>
505569 Effect.gen(function* () {
506570 const harness = yield* makeHarness();
......@@ -652,6 +716,101 @@ describe("EnvironmentSupervisor", () => {
652716 }).pipe(Effect.provide(TestClock.layer())),
653717 );
654718
719 it.effect("restarts the retry ladder when mobile returns to the foreground", () =>
720 Effect.gen(function* () {
721 const harness = yield* makeHarness();
722 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
723 initiallyDesired: true,
724 }).pipe(Effect.provide(harness.dependencies));
725
726 yield* awaitState(supervisor.state, (state) => state.phase === "connected");
727 yield* harness.closeLatestSession();
728 yield* awaitState(
729 supervisor.state,
730 (state) => state.phase === "backoff" && state.attempt === 1,
731 );
732 yield* TestClock.adjust("1 second");
733 yield* awaitState(
734 supervisor.state,
735 (state) => state.phase === "connected" && state.generation === 2,
736 );
737 yield* harness.closeLatestSession();
738 yield* awaitState(
739 supervisor.state,
740 (state) => state.phase === "backoff" && state.attempt === 2,
741 );
742
743 yield* harness.wake("application-active-reconnect");
744 yield* awaitState(
745 supervisor.state,
746 (state) => state.phase === "connected" && state.generation === 3 && state.attempt === 1,
747 );
748 yield* harness.closeLatestSession();
749 yield* awaitState(
750 supervisor.state,
751 (state) => state.phase === "backoff" && state.attempt === 1,
752 );
753
754 expect(yield* Ref.get(harness.sessionCount)).toBe(3);
755 }).pipe(Effect.provide(TestClock.layer())),
756 );
757
758 it.effect("restarts the retry ladder when a long resume replaces a connected session", () =>
759 Effect.gen(function* () {
760 const harness = yield* makeHarness();
761 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
762 initiallyDesired: true,
763 }).pipe(Effect.provide(harness.dependencies));
764
765 yield* awaitState(supervisor.state, (state) => state.phase === "connected");
766 yield* harness.closeLatestSession();
767 yield* awaitState(
768 supervisor.state,
769 (state) => state.phase === "backoff" && state.attempt === 1,
770 );
771 yield* TestClock.adjust("1 second");
772 yield* awaitState(
773 supervisor.state,
774 (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 2,
775 );
776
777 yield* harness.wake("application-active-reconnect");
778 yield* awaitState(
779 supervisor.state,
780 (state) => state.phase === "connected" && state.generation === 3 && state.attempt === 1,
781 );
782 }).pipe(Effect.provide(TestClock.layer())),
783 );
784
785 it.effect("restarts the retry ladder when a long resume interrupts connection setup", () =>
786 Effect.gen(function* () {
787 const harness = yield* makeHarness({
788 prepare: (attempt) => (attempt === 2 ? Effect.never : Effect.succeed(PREPARED_CONNECTION)),
789 });
790 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
791 initiallyDesired: true,
792 }).pipe(Effect.provide(harness.dependencies));
793
794 yield* awaitState(supervisor.state, (state) => state.phase === "connected");
795 yield* harness.closeLatestSession();
796 yield* awaitState(
797 supervisor.state,
798 (state) => state.phase === "backoff" && state.attempt === 1,
799 );
800 yield* TestClock.adjust("1 second");
801 yield* awaitState(
802 supervisor.state,
803 (state) => state.phase === "connecting" && state.attempt === 2,
804 );
805
806 yield* harness.wake("application-active-reconnect");
807 yield* awaitState(
808 supervisor.state,
809 (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1,
810 );
811 }).pipe(Effect.provide(TestClock.layer())),
812 );
813
655814 it.effect("probes the active session without reconnecting on application activation", () =>
656815 Effect.gen(function* () {
657816 const probeCount = yield* Ref.make(0);
......@@ -677,6 +836,58 @@ describe("EnvironmentSupervisor", () => {
677836 }),
678837 );
679838
839 it.effect("immediately replaces a mobile session after a long background resume", () =>
840 Effect.gen(function* () {
841 const probeCount = yield* Ref.make(0);
842 const harness = yield* makeHarness({
843 probe: () => Ref.update(probeCount, (count) => count + 1),
844 });
845 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
846 initiallyDesired: true,
847 }).pipe(Effect.provide(harness.dependencies));
848
849 yield* awaitState(
850 supervisor.state,
851 (state) => state.phase === "connected" && state.generation === 1,
852 );
853 yield* harness.wake("application-active-reconnect");
854 yield* awaitState(
855 supervisor.state,
856 (state) => state.phase === "connected" && state.generation === 2,
857 );
858
859 expect(yield* Ref.get(probeCount)).toBe(0);
860 expect(yield* Ref.get(harness.sessionCount)).toBe(2);
861 expect(yield* Ref.get(harness.releaseCount)).toBe(1);
862 }),
863 );
864
865 it.effect("replaces a mobile session when a long resume interrupts an active probe", () =>
866 Effect.gen(function* () {
867 const harness = yield* makeHarness({
868 probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void),
869 });
870 const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, {
871 initiallyDesired: true,
872 }).pipe(Effect.provide(harness.dependencies));
873
874 yield* awaitState(
875 supervisor.state,
876 (state) => state.phase === "connected" && state.generation === 1,
877 );
878 yield* harness.wake("application-active-probe");
879 yield* Effect.yieldNow;
880 yield* harness.wake("application-active-reconnect");
881 yield* awaitState(
882 supervisor.state,
883 (state) => state.phase === "connected" && state.generation === 2,
884 );
885
886 expect(yield* Ref.get(harness.sessionCount)).toBe(2);
887 expect(yield* Ref.get(harness.releaseCount)).toBe(1);
888 }),
889 );
890
680891 it.effect("reconnects when the foreground liveness probe fails", () =>
681892 Effect.gen(function* () {
682893 const harness = yield* makeHarness({
......@@ -701,7 +912,7 @@ describe("EnvironmentSupervisor", () => {
701912 }).pipe(Effect.provide(TestClock.layer())),
702913 );
703914
704 it.effect("times out a stalled foreground liveness probe and reconnects", () =>
915 it.effect("quickly times out a stalled mobile foreground liveness probe", () =>
705916 Effect.gen(function* () {
706917 const harness = yield* makeHarness({
707918 probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void),
......@@ -711,8 +922,8 @@ describe("EnvironmentSupervisor", () => {
711922 }).pipe(Effect.provide(harness.dependencies));
712923
713924 yield* awaitState(supervisor.state, (state) => state.phase === "connected");
714 yield* harness.wake("application-active");
715 yield* TestClock.adjust("15 seconds");
925 yield* harness.wake("application-active-probe");
926 yield* TestClock.adjust("3 seconds");
716927 yield* awaitState(
717928 supervisor.state,
718929 (state) => state.phase === "backoff" && state.lastFailure?.reason === "timeout",
packages/client-runtime/src/connection/supervisor.ts+87-25
......@@ -32,6 +32,7 @@ import * as ConnectionWakeups from "./wakeups.ts";
3232const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const;
3333const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds";
3434const CONNECTION_PROBE_TIMEOUT = "15 seconds";
35const MOBILE_CONNECTION_PROBE_TIMEOUT = "3 seconds";
3536const BACKOFF_RESET_AFTER_MS = 30_000;
3637
3738interface SupervisorIntent {
......@@ -63,6 +64,7 @@ type AttemptOutcome =
6364 readonly _tag: "Interrupted";
6465 readonly established: boolean;
6566 readonly stable: boolean;
67 readonly resetRetry: boolean;
6668 }
6769 | {
6870 readonly _tag: "Failure";
......@@ -82,7 +84,7 @@ type EstablishmentEvent =
8284 TracedAttemptFailure
8385 >;
8486 }
85 | { readonly _tag: "Interrupted" }
87 | { readonly _tag: "Interrupted"; readonly resetRetry: boolean }
8688 | { readonly _tag: "TimedOut" };
8789
8890function exitUnlessInterrupted<A, E, R>(
......@@ -168,7 +170,7 @@ function failureFromExit<A>(
168170 stable: boolean,
169171): AttemptOutcome {
170172 if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) {
171 return { _tag: "Interrupted", established, stable };
173 return { _tag: "Interrupted", established, stable, resetRetry: false };
172174 }
173175 const typedFailure = exit.cause.reasons.find(Cause.isFailReason);
174176 if (typedFailure) {
......@@ -365,18 +367,21 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
365367 switch (next._tag) {
366368 case "DisconnectRequested":
367369 case "RetryRequested":
368 return;
370 return false;
369371 case "NetworkChanged":
370372 if (next.network === "offline") {
371 return;
373 return false;
372374 }
373375 break;
374376 case "ConnectRequested":
375377 break;
376378 case "Wakeup":
379 if (next.reason === "application-active-reconnect") {
380 return true;
381 }
377382 if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") {
378383 yield* logManagedRelayAccountChange;
379 return;
384 return false;
380385 }
381386 break;
382387 }
......@@ -391,21 +396,30 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
391396 switch (next._tag) {
392397 case "DisconnectRequested":
393398 case "RetryRequested":
394 return;
399 return false;
395400 case "NetworkChanged":
396401 if (next.network === "offline") {
397 return;
402 return false;
398403 }
399404 break;
400405 case "Wakeup":
401406 if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") {
402407 yield* logManagedRelayAccountChange;
403 return;
408 return false;
409 }
410 if (next.reason === "application-active-reconnect") {
411 // Mobile operating systems commonly suspend sockets without
412 // delivering a close event. A long background resume deliberately
413 // replaces that lease and starts a fresh attempt without backoff.
414 return true;
404415 }
405 if (next.reason === "application-active") {
416 if (next.reason === "application-active" || next.reason === "application-active-probe") {
406417 const probe = yield* lease.session.probe.pipe(
407418 Effect.timeoutOrElse({
408 duration: CONNECTION_PROBE_TIMEOUT,
419 duration:
420 next.reason === "application-active-probe"
421 ? MOBILE_CONNECTION_PROBE_TIMEOUT
422 : CONNECTION_PROBE_TIMEOUT,
409423 orElse: () =>
410424 Effect.fail(
411425 new ConnectionTransientError({
......@@ -433,15 +447,27 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
433447 case "DisconnectRequested":
434448 case "RetryRequested":
435449 yield* Fiber.interrupt(probe);
436 return;
450 return false;
437451 case "NetworkChanged":
438452 if (probeEvent.signal.network === "offline") {
439453 yield* Fiber.interrupt(probe);
440 return;
454 return false;
441455 }
442456 break;
443 case "ConnectRequested":
444457 case "Wakeup":
458 if (probeEvent.signal.reason === "application-active-reconnect") {
459 yield* Fiber.interrupt(probe);
460 return true;
461 }
462 if (
463 probeEvent.signal.reason === "credentials-changed" &&
464 target._tag === "RelayConnectionTarget"
465 ) {
466 yield* Fiber.interrupt(probe);
467 return false;
468 }
469 break;
470 case "ConnectRequested":
445471 break;
446472 }
447473 }
......@@ -471,7 +497,14 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
471497 }),
472498 ),
473499 ),
474 waitForEstablishmentInterrupt().pipe(Effect.as<EstablishmentEvent>({ _tag: "Interrupted" })),
500 waitForEstablishmentInterrupt().pipe(
501 Effect.map(
502 (resetRetry): EstablishmentEvent => ({
503 _tag: "Interrupted",
504 resetRetry,
505 }),
506 ),
507 ),
475508 Effect.sleep(CONNECTION_ESTABLISHMENT_TIMEOUT).pipe(
476509 Effect.as<EstablishmentEvent>({ _tag: "TimedOut" }),
477510 ),
......@@ -482,6 +515,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
482515 _tag: "Interrupted",
483516 established: false,
484517 stable: false,
518 resetRetry: establishment.resetRetry,
485519 } satisfies AttemptOutcome;
486520 }
487521 if (establishment._tag === "TimedOut") {
......@@ -524,6 +558,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
524558 _tag: "Interrupted",
525559 established: false,
526560 stable: false,
561 resetRetry: false,
527562 } satisfies AttemptOutcome;
528563 }
529564
......@@ -560,42 +595,58 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
560595 ),
561596 ).pipe(exitUnlessInterrupted);
562597 const connectedForMs = (yield* Clock.currentTimeMillis) - connectedAt;
598 if (Exit.isSuccess(connectedExit)) {
599 return {
600 _tag: "Interrupted",
601 established: true,
602 stable: connectedForMs >= BACKOFF_RESET_AFTER_MS,
603 resetRetry: connectedExit.value,
604 } satisfies AttemptOutcome;
605 }
563606 return failureFromExit(target, connectedExit, true, connectedForMs >= BACKOFF_RESET_AFTER_MS);
564607 }, Effect.ensuring(clearLease));
565608
566609 const waitForRetrySignal = Effect.fnUntraced(function* (delayMs: number) {
567610 return yield* Effect.raceFirst(
568 Effect.sleep(delayMs),
611 Effect.sleep(delayMs).pipe(Effect.as(false)),
569612 Effect.gen(function* () {
570613 for (;;) {
571614 const next = yield* Queue.take(signals);
572615 switch (next._tag) {
616 case "Wakeup":
617 return ConnectionWakeups.isApplicationActiveWakeup(next.reason);
573618 case "ConnectRequested":
574619 case "DisconnectRequested":
575620 case "RetryRequested":
576621 case "NetworkChanged":
577 case "Wakeup":
578 return;
622 return false;
579623 }
580624 }
581625 }),
582626 );
583627 });
584628
585 const waitForSignal = Queue.take(signals);
629 const waitForSignal = Queue.take(signals).pipe(
630 Effect.map(
631 (next) => next._tag === "Wakeup" && ConnectionWakeups.isApplicationActiveWakeup(next.reason),
632 ),
633 );
586634
587635 const run = Effect.fnUntraced(function* () {
588636 let failureCount = 0;
589637 let generation = 0;
590638 let latestFailure: ConnectionAttemptError | null = null;
591639 let pendingRetry = Option.none<PendingRetryTrace>();
640 const resetRetryLadder = () => {
641 failureCount = 0;
642 pendingRetry = Option.none();
643 };
592644
593645 for (;;) {
594646 const currentIntent = yield* Ref.get(intent);
595647 if (!currentIntent.desired) {
596 failureCount = 0;
648 resetRetryLadder();
597649 latestFailure = null;
598 pendingRetry = Option.none();
599650 yield* clearLease;
600651 yield* setState(availableState(currentIntent, generation));
601652 yield* waitForSignal;
......@@ -604,7 +655,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
604655 if (currentIntent.network === "offline") {
605656 yield* clearLease;
606657 yield* setState(offlineState(currentIntent, generation, failureCount + 1, latestFailure));
607 yield* waitForSignal;
658 const applicationActivated = yield* waitForSignal;
659 if (applicationActivated) {
660 resetRetryLadder();
661 }
608662 continue;
609663 }
610664
......@@ -616,12 +670,14 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
616670 if (outcome.established) {
617671 generation = nextGeneration;
618672 if (outcome.stable) {
619 failureCount = 0;
673 resetRetryLadder();
620674 latestFailure = null;
621 pendingRetry = Option.none();
622675 }
623676 }
624677 if (outcome._tag === "Interrupted") {
678 if (outcome.resetRetry) {
679 resetRetryLadder();
680 }
625681 continue;
626682 }
627683
......@@ -640,7 +696,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
640696 lastFailure: error,
641697 retryAt: null,
642698 });
643 yield* waitForSignal;
699 const applicationActivated = yield* waitForSignal;
700 if (applicationActivated) {
701 resetRetryLadder();
702 }
644703 continue;
645704 }
646705
......@@ -663,7 +722,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
663722 lastFailure: error,
664723 retryAt: (yield* Clock.currentTimeMillis) + delayMs,
665724 });
666 yield* waitForRetrySignal(delayMs);
725 const applicationActivated = yield* waitForRetrySignal(delayMs);
726 if (applicationActivated) {
727 resetRetryLadder();
728 }
667729 }
668730 });
669731
packages/client-runtime/src/connection/wakeups.ts+17-1
......@@ -2,7 +2,23 @@ import * as Context from "effect/Context";
22import * as Layer from "effect/Layer";
33import type * as Stream from "effect/Stream";
44
5export type ConnectionWakeup = "application-active" | "credentials-changed";
5export type ConnectionWakeup =
6 | "application-active"
7 | "application-active-probe"
8 | "application-active-reconnect"
9 | "credentials-changed";
10
11export function isApplicationActiveWakeup(reason: ConnectionWakeup): boolean {
12 return (
13 reason === "application-active" ||
14 reason === "application-active-probe" ||
15 reason === "application-active-reconnect"
16 );
17}
18
19export function shouldResubscribeAfterWakeup(reason: ConnectionWakeup): boolean {
20 return reason === "application-active" || reason === "application-active-probe";
21}
622
723export class ConnectionWakeups extends Context.Service<
824 ConnectionWakeups,
packages/client-runtime/src/state/shell-sync.test.ts+15
......@@ -329,6 +329,21 @@ describe("environment shell synchronization", () => {
329329
330330 expect(yield* Ref.get(loaderCalls)).toBe(2);
331331 expect(yield* Ref.get(subscriptionCount)).toBe(2);
332
333 yield* Queue.offer(wakeups, "application-active-probe");
334 for (let attempt = 0; attempt < 100; attempt += 1) {
335 if ((yield* Ref.get(subscriptionCount)) >= 3) break;
336 yield* Effect.yieldNow;
337 }
338 expect(yield* Ref.get(loaderCalls)).toBe(3);
339 expect(yield* Ref.get(subscriptionCount)).toBe(3);
340
341 yield* Queue.offer(wakeups, "application-active-reconnect");
342 for (let attempt = 0; attempt < 10; attempt += 1) {
343 yield* Effect.yieldNow;
344 }
345 expect(yield* Ref.get(loaderCalls)).toBe(3);
346 expect(yield* Ref.get(subscriptionCount)).toBe(3);
332347 }),
333348 );
334349});
packages/client-runtime/src/state/shell.ts+1-1
......@@ -172,7 +172,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
172172 const foregroundResubscriptions = Option.match(wakeups, {
173173 onNone: () => Stream.never,
174174 onSome: (service) =>
175 service.changes.pipe(Stream.filter((reason) => reason === "application-active")),
175 service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)),
176176 });
177177
178178 yield* setSynchronizing;
packages/client-runtime/src/state/threads-sync.test.ts+13
......@@ -696,6 +696,19 @@ describe("EnvironmentThreads", () => {
696696 (value) => value.status === "live" && Option.isSome(value.data),
697697 );
698698 expect(Option.getOrThrow(live.data).title).toBe("Latest title");
699
700 yield* Queue.offer(harness.wakeups, "application-active-probe");
701 for (let attempt = 0; attempt < 100; attempt += 1) {
702 if ((yield* Ref.get(harness.subscriptionCount)) >= 3) break;
703 yield* Effect.yieldNow;
704 }
705 expect(yield* Ref.get(harness.subscriptionCount)).toBe(3);
706
707 yield* Queue.offer(harness.wakeups, "application-active-reconnect");
708 for (let attempt = 0; attempt < 10; attempt += 1) {
709 yield* Effect.yieldNow;
710 }
711 expect(yield* Ref.get(harness.subscriptionCount)).toBe(3);
699712 }),
700713 );
701714});
packages/client-runtime/src/state/threads.ts+1-1
......@@ -236,7 +236,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
236236 const foregroundResubscriptions = Option.match(wakeups, {
237237 onNone: () => Stream.never,
238238 onSome: (service) =>
239 service.changes.pipe(Stream.filter((reason) => reason === "application-active")),
239 service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)),
240240 });
241241
242242 yield* setSynchronizing;