| author | |
| committer | |
| log | 69a18ce912ac3ae93cf498cfe0cee40beb49fb9f |
| tree | a9df8140e4ce993820d437c51386afec98bea00b |
| parent | f0c6ba994670bbd9eeab97eab6701d5c3589178d |
| signature | Signed by PGP key B5690EEEBB952194 |
13 files changed, 547 insertions(+), 93 deletions(-)
apps/mobile/src/connection/app-state-wakeups.test.ts created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | MOBILE_BACKGROUND_RECONNECT_AFTER_MS, | |
| 5 | mobileApplicationActiveWakeup, | |
| 6 | } from "./app-state-wakeups"; | |
| 7 | ||
| 8 | describe("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 @@ |
| 1 | import type { Wakeups } from "@t3tools/client-runtime/connection"; | |
| 2 | ||
| 3 | export const MOBILE_BACKGROUND_RECONNECT_AFTER_MS = 10_000; | |
| 4 | ||
| 5 | export type MobileApplicationActiveWakeup = Extract< | |
| 6 | Wakeups.ConnectionWakeup, | |
| 7 | "application-active-probe" | "application-active-reconnect" | |
| 8 | >; | |
| 9 | ||
| 10 | export 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"; |
| 30 | 30 | import { appAtomRegistry } from "../state/atom-registry"; |
| 31 | 31 | import { clearThreadOutboxEnvironment } from "../state/thread-outbox"; |
| 32 | 32 | import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; |
| 33 | import { mobileApplicationActiveWakeup } from "./app-state-wakeups"; | |
| 33 | 34 | import { connectionStorageLayer } from "./storage"; |
| 34 | 35 | |
| 35 | 36 | function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" { |
| ... | ... | @@ -54,27 +55,53 @@ const connectivityLayer = Connectivity.layer({ |
| 54 | 55 | ), |
| 55 | 56 | changes: Stream.callback((queue) => |
| 56 | 57 | Effect.acquireRelease( |
| 57 | Effect.sync(() => | |
| 58 | Network.addNetworkStateListener((state) => { | |
| 58 | Effect.sync(() => { | |
| 59 | let active = true; | |
| 60 | const networkSubscription = Network.addNetworkStateListener((state) => { | |
| 59 | 61 | 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), | |
| 63 | 84 | ).pipe(Effect.asVoid), |
| 64 | 85 | ), |
| 65 | 86 | }); |
| 66 | 87 | |
| 67 | 88 | const wakeupsLayer = Wakeups.layer({ |
| 68 | 89 | changes: Stream.merge( |
| 69 | Stream.callback<"application-active">((queue) => | |
| 90 | Stream.callback<"application-active-probe" | "application-active-reconnect">((queue) => | |
| 70 | 91 | 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 | } | |
| 73 | 99 | if (state === "active") { |
| 74 | Queue.offerUnsafe(queue, "application-active"); | |
| 100 | Queue.offerUnsafe(queue, mobileApplicationActiveWakeup(backgroundedAtMs, Date.now())); | |
| 101 | backgroundedAtMs = null; | |
| 75 | 102 | } |
| 76 | }), | |
| 77 | ), | |
| 103 | }); | |
| 104 | }), | |
| 78 | 105 | (subscription) => Effect.sync(() => subscription.remove()), |
| 79 | 106 | ).pipe(Effect.asVoid), |
| 80 | 107 | ), |
docs/architecture/connection-runtime.md+3| ... | ... | @@ -40,6 +40,9 @@ The supervisor is the only retry owner. |
| 40 | 40 | seconds. |
| 41 | 41 | 5. Connectivity changes, application activation, credential changes, and |
| 42 | 42 | 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. | |
| 43 | 46 | 6. Authentication or configuration failures remain blocked until an external |
| 44 | 47 | wakeup changes the relevant input. |
| 45 | 48 | 7. 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"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | import * as Option from "effect/Option"; |
| 6 | 6 | import * as Ref from "effect/Ref"; |
| 7 | import * as TestClock from "effect/testing/TestClock"; | |
| 7 | 8 | |
| 8 | 9 | import * as ManagedRelay from "../relay/managedRelay.ts"; |
| 9 | 10 | import { remoteHttpClientLayer } from "../rpc/http.ts"; |
| ... | ... | @@ -155,6 +156,81 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( |
| 155 | 156 | }); |
| 156 | 157 | |
| 157 | 158 | describe("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 | ||
| 158 | 234 | it.effect("reuses a valid persisted environment token without contacting the relay", () => |
| 159 | 235 | Effect.gen(function* () { |
| 160 | 236 | const cached = new TokenStore.RemoteDpopAccessToken({ |
| ... | ... | @@ -265,7 +341,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 265 | 341 | }), |
| 266 | 342 | ); |
| 267 | 343 | |
| 268 | it.effect("refreshes a cached endpoint after consecutive transient failures", () => | |
| 344 | it.effect("refreshes a cached endpoint after its first transient failure", () => | |
| 269 | 345 | Effect.gen(function* () { |
| 270 | 346 | const cached = new TokenStore.RemoteDpopAccessToken({ |
| 271 | 347 | environmentId: ENVIRONMENT_ID, |
| ... | ... | @@ -279,7 +355,6 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 279 | 355 | initialToken: cached, |
| 280 | 356 | responses: [ |
| 281 | 357 | new Response("endpoint unavailable", { status: 503 }), |
| 282 | new Response("endpoint still unavailable", { status: 503 }), | |
| 283 | 358 | Response.json(DESCRIPTOR), |
| 284 | 359 | accessToken("replacement-access-token"), |
| 285 | 360 | websocketTicket("replacement-ticket"), |
| ... | ... | @@ -288,17 +363,6 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 288 | 363 | |
| 289 | 364 | const authorized = yield* Effect.gen(function* () { |
| 290 | 365 | 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 | ||
| 302 | 366 | return yield* remote.authorizeDpop({ |
| 303 | 367 | expectedEnvironmentId: ENVIRONMENT_ID, |
| 304 | 368 | obtainBootstrap: harness.obtainBootstrap, |
| ... | ... | @@ -312,7 +376,7 @@ describe("RemoteEnvironmentAuthorization", () => { |
| 312 | 376 | accessToken: "replacement-access-token", |
| 313 | 377 | }), |
| 314 | 378 | ); |
| 315 | expect(harness.fetch.calls).toHaveLength(5); | |
| 379 | expect(harness.fetch.calls).toHaveLength(4); | |
| 316 | 380 | }), |
| 317 | 381 | ); |
| 318 | 382 |
packages/client-runtime/src/authorization/service.ts+39-35| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | |
| 2 | 2 | import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; |
| 3 | 3 | import { |
| 4 | 4 | exchangeRemoteDpopAccessToken, |
| ... | ... | @@ -58,7 +58,8 @@ export class RemoteEnvironmentAuthorization extends Context.Service< |
| 58 | 58 | >()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {} |
| 59 | 59 | |
| 60 | 60 | const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000; |
| 61 | const CACHED_ENDPOINT_FAILURE_THRESHOLD = 2; | |
| 61 | const CACHED_ENDPOINT_SOCKET_TIMEOUT_MS = 3_000; | |
| 62 | const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000; | |
| 62 | 63 | |
| 63 | 64 | function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { |
| 64 | 65 | return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" |
| ... | ... | @@ -79,25 +80,16 @@ export const make = Effect.gen(function* () { |
| 79 | 80 | const presentation = yield* ClientCapabilities.ClientPresentation; |
| 80 | 81 | const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore; |
| 81 | 82 | 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; | |
| 88 | 90 | } |
| 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()); | |
| 101 | 93 | |
| 102 | 94 | const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")( |
| 103 | 95 | function* (input: { |
| ... | ... | @@ -108,15 +100,33 @@ export const make = Effect.gen(function* () { |
| 108 | 100 | readonly wsBaseUrl: string; |
| 109 | 101 | readonly bearerToken: string; |
| 110 | 102 | }) { |
| 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 | ); | |
| 114 | 113 | if (descriptor.environmentId !== input.expectedEnvironmentId) { |
| 115 | 114 | return yield* environmentMismatchError({ |
| 116 | 115 | expected: input.expectedEnvironmentId, |
| 117 | 116 | actual: descriptor.environmentId, |
| 118 | 117 | }); |
| 119 | 118 | } |
| 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 | } | |
| 120 | 130 | const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({ |
| 121 | 131 | wsBaseUrl: input.wsBaseUrl, |
| 122 | 132 | httpBaseUrl: input.httpBaseUrl, |
| ... | ... | @@ -139,7 +149,7 @@ export const make = Effect.gen(function* () { |
| 139 | 149 | ); |
| 140 | 150 | |
| 141 | 151 | const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")( |
| 142 | function* (token: TokenStore.RemoteDpopAccessToken) { | |
| 152 | function* (token: TokenStore.RemoteDpopAccessToken, timeoutMs?: number) { | |
| 143 | 153 | const ticketProof = yield* signer |
| 144 | 154 | .createProof({ |
| 145 | 155 | method: "POST", |
| ... | ... | @@ -160,6 +170,7 @@ export const make = Effect.gen(function* () { |
| 160 | 170 | httpBaseUrl: token.endpoint.httpBaseUrl, |
| 161 | 171 | accessToken: token.accessToken, |
| 162 | 172 | dpopProof: ticketProof, |
| 173 | ...(timeoutMs === undefined ? {} : { timeoutMs }), | |
| 163 | 174 | }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); |
| 164 | 175 | }, |
| 165 | 176 | ); |
| ... | ... | @@ -196,9 +207,11 @@ export const make = Effect.gen(function* () { |
| 196 | 207 | yield* Effect.annotateCurrentSpan({ |
| 197 | 208 | "connection.remote_token_cache": "hit", |
| 198 | 209 | }); |
| 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); | |
| 200 | 214 | if (Result.isSuccess(cachedSocket)) { |
| 201 | yield* resetCachedEndpointFailures(input.expectedEnvironmentId); | |
| 202 | 215 | return { |
| 203 | 216 | environmentId: cached.value.environmentId, |
| 204 | 217 | label: cached.value.label, |
| ... | ... | @@ -213,20 +226,11 @@ export const make = Effect.gen(function* () { |
| 213 | 226 | if (cachedSocket.failure._tag === "ConnectionBlockedError") { |
| 214 | 227 | return yield* mapDpopSocketError(cachedSocket.failure); |
| 215 | 228 | } |
| 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 | } | |
| 223 | 229 | yield* tokenStore |
| 224 | 230 | .remove(input.expectedEnvironmentId) |
| 225 | 231 | .pipe(Effect.withSpan("environment.authorization.accessToken.remove")); |
| 226 | yield* resetCachedEndpointFailures(input.expectedEnvironmentId); | |
| 227 | 232 | } |
| 228 | 233 | |
| 229 | yield* resetCachedEndpointFailures(input.expectedEnvironmentId); | |
| 230 | 234 | yield* Effect.annotateCurrentSpan({ |
| 231 | 235 | "connection.remote_token_cache": "miss", |
| 232 | 236 | }); |
packages/client-runtime/src/connection/supervisor.test.ts+216-5| ... | ... | @@ -124,7 +124,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: |
| 124 | 124 | const releaseCount = yield* Ref.make(0); |
| 125 | 125 | const wakeups = yield* SubscriptionRef.make<{ |
| 126 | 126 | readonly sequence: number; |
| 127 | readonly reason: "application-active" | "credentials-changed"; | |
| 127 | readonly reason: ConnectionWakeups.ConnectionWakeup; | |
| 128 | 128 | }>({ |
| 129 | 129 | sequence: 0, |
| 130 | 130 | reason: "application-active", |
| ... | ... | @@ -198,7 +198,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: |
| 198 | 198 | sessionCount, |
| 199 | 199 | releaseCount, |
| 200 | 200 | setNetworkStatus: (status: NetworkStatus) => SubscriptionRef.set(networkStatus, status), |
| 201 | wake: (reason: "application-active" | "credentials-changed") => | |
| 201 | wake: (reason: ConnectionWakeups.ConnectionWakeup) => | |
| 202 | 202 | SubscriptionRef.update(wakeups, (event) => ({ |
| 203 | 203 | sequence: event.sequence + 1, |
| 204 | 204 | reason, |
| ... | ... | @@ -311,6 +311,38 @@ describe("EnvironmentSupervisor", () => { |
| 311 | 311 | }), |
| 312 | 312 | ); |
| 313 | 313 | |
| 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 | ||
| 314 | 346 | it.effect("retries forever with exponential backoff capped at sixteen seconds", () => |
| 315 | 347 | Effect.gen(function* () { |
| 316 | 348 | const harness = yield* makeHarness({ |
| ... | ... | @@ -501,6 +533,38 @@ describe("EnvironmentSupervisor", () => { |
| 501 | 533 | }).pipe(Effect.provide(TestClock.layer())), |
| 502 | 534 | ); |
| 503 | 535 | |
| 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 | ||
| 504 | 568 | it.effect("releases a live session while offline and starts a new generation when online", () => |
| 505 | 569 | Effect.gen(function* () { |
| 506 | 570 | const harness = yield* makeHarness(); |
| ... | ... | @@ -652,6 +716,101 @@ describe("EnvironmentSupervisor", () => { |
| 652 | 716 | }).pipe(Effect.provide(TestClock.layer())), |
| 653 | 717 | ); |
| 654 | 718 | |
| 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 | ||
| 655 | 814 | it.effect("probes the active session without reconnecting on application activation", () => |
| 656 | 815 | Effect.gen(function* () { |
| 657 | 816 | const probeCount = yield* Ref.make(0); |
| ... | ... | @@ -677,6 +836,58 @@ describe("EnvironmentSupervisor", () => { |
| 677 | 836 | }), |
| 678 | 837 | ); |
| 679 | 838 | |
| 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 | ||
| 680 | 891 | it.effect("reconnects when the foreground liveness probe fails", () => |
| 681 | 892 | Effect.gen(function* () { |
| 682 | 893 | const harness = yield* makeHarness({ |
| ... | ... | @@ -701,7 +912,7 @@ describe("EnvironmentSupervisor", () => { |
| 701 | 912 | }).pipe(Effect.provide(TestClock.layer())), |
| 702 | 913 | ); |
| 703 | 914 | |
| 704 | it.effect("times out a stalled foreground liveness probe and reconnects", () => | |
| 915 | it.effect("quickly times out a stalled mobile foreground liveness probe", () => | |
| 705 | 916 | Effect.gen(function* () { |
| 706 | 917 | const harness = yield* makeHarness({ |
| 707 | 918 | probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), |
| ... | ... | @@ -711,8 +922,8 @@ describe("EnvironmentSupervisor", () => { |
| 711 | 922 | }).pipe(Effect.provide(harness.dependencies)); |
| 712 | 923 | |
| 713 | 924 | 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"); | |
| 716 | 927 | yield* awaitState( |
| 717 | 928 | supervisor.state, |
| 718 | 929 | (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"; |
| 32 | 32 | const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; |
| 33 | 33 | const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; |
| 34 | 34 | const CONNECTION_PROBE_TIMEOUT = "15 seconds"; |
| 35 | const MOBILE_CONNECTION_PROBE_TIMEOUT = "3 seconds"; | |
| 35 | 36 | const BACKOFF_RESET_AFTER_MS = 30_000; |
| 36 | 37 | |
| 37 | 38 | interface SupervisorIntent { |
| ... | ... | @@ -63,6 +64,7 @@ type AttemptOutcome = |
| 63 | 64 | readonly _tag: "Interrupted"; |
| 64 | 65 | readonly established: boolean; |
| 65 | 66 | readonly stable: boolean; |
| 67 | readonly resetRetry: boolean; | |
| 66 | 68 | } |
| 67 | 69 | | { |
| 68 | 70 | readonly _tag: "Failure"; |
| ... | ... | @@ -82,7 +84,7 @@ type EstablishmentEvent = |
| 82 | 84 | TracedAttemptFailure |
| 83 | 85 | >; |
| 84 | 86 | } |
| 85 | | { readonly _tag: "Interrupted" } | |
| 87 | | { readonly _tag: "Interrupted"; readonly resetRetry: boolean } | |
| 86 | 88 | | { readonly _tag: "TimedOut" }; |
| 87 | 89 | |
| 88 | 90 | function exitUnlessInterrupted<A, E, R>( |
| ... | ... | @@ -168,7 +170,7 @@ function failureFromExit<A>( |
| 168 | 170 | stable: boolean, |
| 169 | 171 | ): AttemptOutcome { |
| 170 | 172 | if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) { |
| 171 | return { _tag: "Interrupted", established, stable }; | |
| 173 | return { _tag: "Interrupted", established, stable, resetRetry: false }; | |
| 172 | 174 | } |
| 173 | 175 | const typedFailure = exit.cause.reasons.find(Cause.isFailReason); |
| 174 | 176 | if (typedFailure) { |
| ... | ... | @@ -365,18 +367,21 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 365 | 367 | switch (next._tag) { |
| 366 | 368 | case "DisconnectRequested": |
| 367 | 369 | case "RetryRequested": |
| 368 | return; | |
| 370 | return false; | |
| 369 | 371 | case "NetworkChanged": |
| 370 | 372 | if (next.network === "offline") { |
| 371 | return; | |
| 373 | return false; | |
| 372 | 374 | } |
| 373 | 375 | break; |
| 374 | 376 | case "ConnectRequested": |
| 375 | 377 | break; |
| 376 | 378 | case "Wakeup": |
| 379 | if (next.reason === "application-active-reconnect") { | |
| 380 | return true; | |
| 381 | } | |
| 377 | 382 | if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") { |
| 378 | 383 | yield* logManagedRelayAccountChange; |
| 379 | return; | |
| 384 | return false; | |
| 380 | 385 | } |
| 381 | 386 | break; |
| 382 | 387 | } |
| ... | ... | @@ -391,21 +396,30 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 391 | 396 | switch (next._tag) { |
| 392 | 397 | case "DisconnectRequested": |
| 393 | 398 | case "RetryRequested": |
| 394 | return; | |
| 399 | return false; | |
| 395 | 400 | case "NetworkChanged": |
| 396 | 401 | if (next.network === "offline") { |
| 397 | return; | |
| 402 | return false; | |
| 398 | 403 | } |
| 399 | 404 | break; |
| 400 | 405 | case "Wakeup": |
| 401 | 406 | if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") { |
| 402 | 407 | 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; | |
| 404 | 415 | } |
| 405 | if (next.reason === "application-active") { | |
| 416 | if (next.reason === "application-active" || next.reason === "application-active-probe") { | |
| 406 | 417 | const probe = yield* lease.session.probe.pipe( |
| 407 | 418 | Effect.timeoutOrElse({ |
| 408 | duration: CONNECTION_PROBE_TIMEOUT, | |
| 419 | duration: | |
| 420 | next.reason === "application-active-probe" | |
| 421 | ? MOBILE_CONNECTION_PROBE_TIMEOUT | |
| 422 | : CONNECTION_PROBE_TIMEOUT, | |
| 409 | 423 | orElse: () => |
| 410 | 424 | Effect.fail( |
| 411 | 425 | new ConnectionTransientError({ |
| ... | ... | @@ -433,15 +447,27 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 433 | 447 | case "DisconnectRequested": |
| 434 | 448 | case "RetryRequested": |
| 435 | 449 | yield* Fiber.interrupt(probe); |
| 436 | return; | |
| 450 | return false; | |
| 437 | 451 | case "NetworkChanged": |
| 438 | 452 | if (probeEvent.signal.network === "offline") { |
| 439 | 453 | yield* Fiber.interrupt(probe); |
| 440 | return; | |
| 454 | return false; | |
| 441 | 455 | } |
| 442 | 456 | break; |
| 443 | case "ConnectRequested": | |
| 444 | 457 | 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": | |
| 445 | 471 | break; |
| 446 | 472 | } |
| 447 | 473 | } |
| ... | ... | @@ -471,7 +497,14 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 471 | 497 | }), |
| 472 | 498 | ), |
| 473 | 499 | ), |
| 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 | ), | |
| 475 | 508 | Effect.sleep(CONNECTION_ESTABLISHMENT_TIMEOUT).pipe( |
| 476 | 509 | Effect.as<EstablishmentEvent>({ _tag: "TimedOut" }), |
| 477 | 510 | ), |
| ... | ... | @@ -482,6 +515,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 482 | 515 | _tag: "Interrupted", |
| 483 | 516 | established: false, |
| 484 | 517 | stable: false, |
| 518 | resetRetry: establishment.resetRetry, | |
| 485 | 519 | } satisfies AttemptOutcome; |
| 486 | 520 | } |
| 487 | 521 | if (establishment._tag === "TimedOut") { |
| ... | ... | @@ -524,6 +558,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 524 | 558 | _tag: "Interrupted", |
| 525 | 559 | established: false, |
| 526 | 560 | stable: false, |
| 561 | resetRetry: false, | |
| 527 | 562 | } satisfies AttemptOutcome; |
| 528 | 563 | } |
| 529 | 564 | |
| ... | ... | @@ -560,42 +595,58 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 560 | 595 | ), |
| 561 | 596 | ).pipe(exitUnlessInterrupted); |
| 562 | 597 | 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 | } | |
| 563 | 606 | return failureFromExit(target, connectedExit, true, connectedForMs >= BACKOFF_RESET_AFTER_MS); |
| 564 | 607 | }, Effect.ensuring(clearLease)); |
| 565 | 608 | |
| 566 | 609 | const waitForRetrySignal = Effect.fnUntraced(function* (delayMs: number) { |
| 567 | 610 | return yield* Effect.raceFirst( |
| 568 | Effect.sleep(delayMs), | |
| 611 | Effect.sleep(delayMs).pipe(Effect.as(false)), | |
| 569 | 612 | Effect.gen(function* () { |
| 570 | 613 | for (;;) { |
| 571 | 614 | const next = yield* Queue.take(signals); |
| 572 | 615 | switch (next._tag) { |
| 616 | case "Wakeup": | |
| 617 | return ConnectionWakeups.isApplicationActiveWakeup(next.reason); | |
| 573 | 618 | case "ConnectRequested": |
| 574 | 619 | case "DisconnectRequested": |
| 575 | 620 | case "RetryRequested": |
| 576 | 621 | case "NetworkChanged": |
| 577 | case "Wakeup": | |
| 578 | return; | |
| 622 | return false; | |
| 579 | 623 | } |
| 580 | 624 | } |
| 581 | 625 | }), |
| 582 | 626 | ); |
| 583 | 627 | }); |
| 584 | 628 | |
| 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 | ); | |
| 586 | 634 | |
| 587 | 635 | const run = Effect.fnUntraced(function* () { |
| 588 | 636 | let failureCount = 0; |
| 589 | 637 | let generation = 0; |
| 590 | 638 | let latestFailure: ConnectionAttemptError | null = null; |
| 591 | 639 | let pendingRetry = Option.none<PendingRetryTrace>(); |
| 640 | const resetRetryLadder = () => { | |
| 641 | failureCount = 0; | |
| 642 | pendingRetry = Option.none(); | |
| 643 | }; | |
| 592 | 644 | |
| 593 | 645 | for (;;) { |
| 594 | 646 | const currentIntent = yield* Ref.get(intent); |
| 595 | 647 | if (!currentIntent.desired) { |
| 596 | failureCount = 0; | |
| 648 | resetRetryLadder(); | |
| 597 | 649 | latestFailure = null; |
| 598 | pendingRetry = Option.none(); | |
| 599 | 650 | yield* clearLease; |
| 600 | 651 | yield* setState(availableState(currentIntent, generation)); |
| 601 | 652 | yield* waitForSignal; |
| ... | ... | @@ -604,7 +655,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 604 | 655 | if (currentIntent.network === "offline") { |
| 605 | 656 | yield* clearLease; |
| 606 | 657 | yield* setState(offlineState(currentIntent, generation, failureCount + 1, latestFailure)); |
| 607 | yield* waitForSignal; | |
| 658 | const applicationActivated = yield* waitForSignal; | |
| 659 | if (applicationActivated) { | |
| 660 | resetRetryLadder(); | |
| 661 | } | |
| 608 | 662 | continue; |
| 609 | 663 | } |
| 610 | 664 | |
| ... | ... | @@ -616,12 +670,14 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 616 | 670 | if (outcome.established) { |
| 617 | 671 | generation = nextGeneration; |
| 618 | 672 | if (outcome.stable) { |
| 619 | failureCount = 0; | |
| 673 | resetRetryLadder(); | |
| 620 | 674 | latestFailure = null; |
| 621 | pendingRetry = Option.none(); | |
| 622 | 675 | } |
| 623 | 676 | } |
| 624 | 677 | if (outcome._tag === "Interrupted") { |
| 678 | if (outcome.resetRetry) { | |
| 679 | resetRetryLadder(); | |
| 680 | } | |
| 625 | 681 | continue; |
| 626 | 682 | } |
| 627 | 683 | |
| ... | ... | @@ -640,7 +696,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 640 | 696 | lastFailure: error, |
| 641 | 697 | retryAt: null, |
| 642 | 698 | }); |
| 643 | yield* waitForSignal; | |
| 699 | const applicationActivated = yield* waitForSignal; | |
| 700 | if (applicationActivated) { | |
| 701 | resetRetryLadder(); | |
| 702 | } | |
| 644 | 703 | continue; |
| 645 | 704 | } |
| 646 | 705 | |
| ... | ... | @@ -663,7 +722,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 663 | 722 | lastFailure: error, |
| 664 | 723 | retryAt: (yield* Clock.currentTimeMillis) + delayMs, |
| 665 | 724 | }); |
| 666 | yield* waitForRetrySignal(delayMs); | |
| 725 | const applicationActivated = yield* waitForRetrySignal(delayMs); | |
| 726 | if (applicationActivated) { | |
| 727 | resetRetryLadder(); | |
| 728 | } | |
| 667 | 729 | } |
| 668 | 730 | }); |
| 669 | 731 |
packages/client-runtime/src/connection/wakeups.ts+17-1| ... | ... | @@ -2,7 +2,23 @@ import * as Context from "effect/Context"; |
| 2 | 2 | import * as Layer from "effect/Layer"; |
| 3 | 3 | import type * as Stream from "effect/Stream"; |
| 4 | 4 | |
| 5 | export type ConnectionWakeup = "application-active" | "credentials-changed"; | |
| 5 | export type ConnectionWakeup = | |
| 6 | | "application-active" | |
| 7 | | "application-active-probe" | |
| 8 | | "application-active-reconnect" | |
| 9 | | "credentials-changed"; | |
| 10 | ||
| 11 | export function isApplicationActiveWakeup(reason: ConnectionWakeup): boolean { | |
| 12 | return ( | |
| 13 | reason === "application-active" || | |
| 14 | reason === "application-active-probe" || | |
| 15 | reason === "application-active-reconnect" | |
| 16 | ); | |
| 17 | } | |
| 18 | ||
| 19 | export function shouldResubscribeAfterWakeup(reason: ConnectionWakeup): boolean { | |
| 20 | return reason === "application-active" || reason === "application-active-probe"; | |
| 21 | } | |
| 6 | 22 | |
| 7 | 23 | export class ConnectionWakeups extends Context.Service< |
| 8 | 24 | ConnectionWakeups, |
packages/client-runtime/src/state/shell-sync.test.ts+15| ... | ... | @@ -329,6 +329,21 @@ describe("environment shell synchronization", () => { |
| 329 | 329 | |
| 330 | 330 | expect(yield* Ref.get(loaderCalls)).toBe(2); |
| 331 | 331 | 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); | |
| 332 | 347 | }), |
| 333 | 348 | ); |
| 334 | 349 | }); |
packages/client-runtime/src/state/shell.ts+1-1| ... | ... | @@ -172,7 +172,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 172 | 172 | const foregroundResubscriptions = Option.match(wakeups, { |
| 173 | 173 | onNone: () => Stream.never, |
| 174 | 174 | onSome: (service) => |
| 175 | service.changes.pipe(Stream.filter((reason) => reason === "application-active")), | |
| 175 | service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)), | |
| 176 | 176 | }); |
| 177 | 177 | |
| 178 | 178 | yield* setSynchronizing; |
packages/client-runtime/src/state/threads-sync.test.ts+13| ... | ... | @@ -696,6 +696,19 @@ describe("EnvironmentThreads", () => { |
| 696 | 696 | (value) => value.status === "live" && Option.isSome(value.data), |
| 697 | 697 | ); |
| 698 | 698 | 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); | |
| 699 | 712 | }), |
| 700 | 713 | ); |
| 701 | 714 | }); |
packages/client-runtime/src/state/threads.ts+1-1| ... | ... | @@ -236,7 +236,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make |
| 236 | 236 | const foregroundResubscriptions = Option.match(wakeups, { |
| 237 | 237 | onNone: () => Stream.never, |
| 238 | 238 | onSome: (service) => |
| 239 | service.changes.pipe(Stream.filter((reason) => reason === "application-active")), | |
| 239 | service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)), | |
| 240 | 240 | }); |
| 241 | 241 | |
| 242 | 242 | yield* setSynchronizing; |