From 4abafa4cc621b43989fe0c7c943d80541fe4d416 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 31 Jul 2026 14:30:48 -0700 Subject: [PATCH 1/4] fix(mobile): harden background resume and reconnection Five targeted fixes for mobile clients returning from iOS suspension: - Jitter the supervisor backoff ladder (equal jitter, [rung/2, rung]) so clients sharing an environment stop reconnecting in lockstep. - A failed foreground liveness probe now replaces the session immediately instead of paying the first backoff delay for a failure the probe just diagnosed. - New advisory `network-path-changed` wakeup: WiFi<->cellular keeps isConnected true while invalidating the socket's path, so the mobile adapter now emits a wakeup on interface-type changes while active and the supervisor probes the session instead of waiting ~5-10s for the ping timeout. - Report client activity on every newly connected session generation; the AppState-triggered report races the reconnect and is dropped, which left the server lease stale (provider/VCS work paused) for up to 25s after a resume. - Shell resumes a warm cache via afterSequence without the previously unconditional HTTP snapshot fetch, mirroring the thread-detail guard; foreground returns no longer pay an HTTP round trip. Supporting changes: wire the patched RPC onPingTimeout hook to logging so zombie-socket disconnects are distinguishable from ordinary closes, and set the server websocket idleTimeout to 30s (Bun defaults to 120s) so a suspended phone's half-dead socket and its server-side buffers are released four times sooner; live clients ping every 5s and never idle. Co-Authored-By: Claude Fable 5 --- .../src/connection/background-activity.ts | 37 ++++++++- apps/mobile/src/connection/platform.ts | 32 +++++++- .../src/connection/supervisor.test.ts | 78 +++++++++++++++---- .../src/connection/supervisor.ts | 29 +++++-- .../client-runtime/src/connection/wakeups.ts | 4 + packages/client-runtime/src/rpc/session.ts | 8 ++ .../src/state/shell-sync.test.ts | 42 ++++------ packages/client-runtime/src/state/shell.ts | 13 ++++ ...@effect__platform-bun@4.0.0-beta.102.patch | 14 +++- pnpm-lock.yaml | 26 +++---- 10 files changed, 218 insertions(+), 65 deletions(-) diff --git a/apps/mobile/src/connection/background-activity.ts b/apps/mobile/src/connection/background-activity.ts index 3364eed3c2d..0f84bbe4089 100644 --- a/apps/mobile/src/connection/background-activity.ts +++ b/apps/mobile/src/connection/background-activity.ts @@ -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, @@ -103,6 +103,41 @@ export const mobileBackgroundActivityReporterLayer = Layer.effectDiscard( Stream.runForEach(() => Effect.sync(requestReport)), Effect.forkScoped, ); + // Re-report on every newly connected session generation. The AppState + // report races the reconnect (the RPC fails while the socket is down and + // is ignored), so without this the server's lease can lag a resume by up + // to REPORT_INTERVAL_MS, keeping provider/VCS work paused. + 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) => `${environmentId}:${state.generation}`), + Stream.changes, + ); + yield* Stream.concat( + Stream.fromEffect(SubscriptionRef.get(registry.entries)), + SubscriptionRef.changes(registry.entries), + ).pipe( + Stream.map((entries) => [...entries.keys()].sort()), + Stream.changesWith((a, b) => a.join(",") === b.join(",")), + Stream.switchMap( + (environmentIds) => + Stream.mergeAll(environmentIds.map(connectedGenerations), { + concurrency: "unbounded", + }), + { concurrency: "unbounded" }, + ), + Stream.runForEach(() => Effect.sync(requestReport)), + Effect.forkScoped, + ); yield* Stream.fromQueue(reportRequests).pipe( Stream.debounce("250 millis"), Stream.runForEach(() => report), diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 852535d9d10..ed68e3cbbac 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -87,11 +87,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; @@ -101,6 +103,32 @@ const wakeupsLayer = Wakeups.layer({ backgroundedAtMs = null; } }); + // WiFi <-> cellular keeps isConnected true while invalidating the + // socket's path, so the coarse online/offline signal never fires. + // Emit an advisory wakeup on interface-type changes while active; + // the supervisor probes the session rather than blindly replacing + // it, which keeps flapping paths cheap. + let networkType: string | null = null; + const networkSubscription = Network.addNetworkStateListener((state) => { + const nextType = state.type ?? null; + const previousType = networkType; + networkType = nextType; + if ( + previousType !== null && + nextType !== null && + nextType !== previousType && + state.isConnected === true && + AppState.currentState === "active" + ) { + Queue.offerUnsafe(queue, "network-path-changed"); + } + }); + return { + remove: () => { + appStateSubscription.remove(); + networkSubscription.remove(); + }, + }; }), (subscription) => Effect.sync(() => subscription.remove()), ).pipe(Effect.asVoid), diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index a925859049f..ef37b2e0036 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -358,15 +358,18 @@ describe("EnvironmentSupervisor", () => { ); expect(yield* Ref.get(harness.prepareCount)).toBe(1); - for (const [index, delay] of [1_000, 2_000, 4_000, 8_000, 16_000, 16_000].entries()) { - yield* TestClock.adjust(delay); + // Jitter puts each delay in [rung/2, rung], so exact rung boundaries + // are not observable; advancing by the 16s cap always covers at least + // one more retry and proves the ladder keeps going past its last rung. + for (const [index] of [1_000, 2_000, 4_000, 8_000, 16_000, 16_000].entries()) { + yield* TestClock.adjust(16_000); yield* eventuallyState( supervisor.state, - (state) => state.phase === "backoff" && state.attempt === index + 2, + (state) => state.phase === "backoff" && state.attempt >= index + 2, ); } - expect(yield* Ref.get(harness.prepareCount)).toBe(7); + expect(yield* Ref.get(harness.prepareCount)).toBeGreaterThanOrEqual(7); }).pipe(Effect.provide(TestClock.layer())), ); @@ -539,9 +542,10 @@ describe("EnvironmentSupervisor", () => { ); expect(yield* Ref.get(harness.prepareCount)).toBe(3); - yield* TestClock.adjust("999 millis"); + // Jittered first-rung delay lands in [500ms, 1000ms]. + yield* TestClock.adjust("499 millis"); expect(yield* Ref.get(harness.prepareCount)).toBe(3); - yield* TestClock.adjust("1 milli"); + yield* TestClock.adjust("501 millis"); yield* eventuallyState( supervisor.state, (state) => state.phase === "backoff" && state.attempt === 2, @@ -873,6 +877,55 @@ describe("EnvironmentSupervisor", () => { }), ); + it.effect("probes the active session when the network path changes", () => + Effect.gen(function* () { + const probeCount = yield* Ref.make(0); + const probeCalled = yield* Deferred.make(); + const harness = yield* makeHarness({ + probe: () => + Ref.update(probeCount, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(probeCalled, undefined)), + ), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("network-path-changed"); + yield* Deferred.await(probeCalled); + + expect(yield* Ref.get(probeCount)).toBe(1); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("connected"); + }), + ); + + it.effect("replaces the session when a probe after a network path change fails", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => + attempt === 1 + ? Effect.fail(transient("The path changed under the socket.")) + : Effect.void, + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("network-path-changed"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }), + ); + it.effect("immediately replaces a mobile session after a long background resume", () => Effect.gen(function* () { const probeCount = yield* Ref.make(0); @@ -937,9 +990,9 @@ describe("EnvironmentSupervisor", () => { yield* awaitState(supervisor.state, (state) => state.phase === "connected"); yield* harness.wake("application-active"); - yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); - yield* TestClock.adjust("1 second"); - yield* eventuallyState( + // A failed probe already proves the session is dead: the supervisor + // replaces it immediately without entering backoff. + yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, ); @@ -961,11 +1014,8 @@ describe("EnvironmentSupervisor", () => { yield* awaitState(supervisor.state, (state) => state.phase === "connected"); yield* harness.wake("application-active-probe"); yield* TestClock.adjust("3 seconds"); - yield* awaitState( - supervisor.state, - (state) => state.phase === "backoff" && state.lastFailure?.reason === "timeout", - ); - yield* TestClock.adjust("1 second"); + // The 3s mobile probe timeout counts as a failed probe: the stale + // session is replaced immediately without a backoff delay. yield* eventuallyState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 2a9c7519072..420cd27eb30 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -8,6 +8,7 @@ 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 Random from "effect/Random"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -101,8 +102,11 @@ export interface EnvironmentSupervisorOptions { readonly initiallyDesired?: boolean; } -function retryDelayMs(failureCount: number): number { - return RETRY_DELAYS_MS[Math.min(failureCount, RETRY_DELAYS_MS.length - 1)] ?? 16_000; +// Equal jitter: each retry waits between half and all of its ladder rung so +// clients sharing an environment don't reconnect in lockstep after a restart. +function retryDelayMs(failureCount: number, random: number): number { + const base = RETRY_DELAYS_MS[Math.min(failureCount, RETRY_DELAYS_MS.length - 1)] ?? 16_000; + return Math.round(base / 2 + random * (base / 2)); } function annotateTarget(target: ConnectionTarget) { @@ -414,13 +418,17 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( // replaces that lease and starts a fresh attempt without backoff. return true; } - if (next.reason === "application-active" || next.reason === "application-active-probe") { + if ( + next.reason === "application-active" || + next.reason === "application-active-probe" || + next.reason === "network-path-changed" + ) { const probe = yield* lease.session.probe.pipe( Effect.timeoutOrElse({ duration: - next.reason === "application-active-probe" - ? MOBILE_CONNECTION_PROBE_TIMEOUT - : CONNECTION_PROBE_TIMEOUT, + next.reason === "application-active" + ? CONNECTION_PROBE_TIMEOUT + : MOBILE_CONNECTION_PROBE_TIMEOUT, orElse: () => Effect.fail( new ConnectionTransientError({ @@ -441,7 +449,12 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ), ); if (probeEvent._tag === "ProbeCompleted") { - yield* probeEvent.exit; + if (Exit.isFailure(probeEvent.exit)) { + // A failed health check already proves the session is dead; + // replace it immediately instead of paying the retry ladder + // for a failure the probe just diagnosed. + return true; + } break; } switch (probeEvent.signal._tag) { @@ -710,7 +723,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } failureCount += 1; - const delayMs = retryDelayMs(failureCount - 1); + const delayMs = retryDelayMs(failureCount - 1, yield* Random.next); pendingRetry = Option.map(attemptSpan, (previousAttempt) => ({ previousAttempt, failureCount, diff --git a/packages/client-runtime/src/connection/wakeups.ts b/packages/client-runtime/src/connection/wakeups.ts index 8573a49c147..bad34559ad5 100644 --- a/packages/client-runtime/src/connection/wakeups.ts +++ b/packages/client-runtime/src/connection/wakeups.ts @@ -6,6 +6,10 @@ export type ConnectionWakeup = | "application-active" | "application-active-probe" | "application-active-reconnect" + // Advisory: the network path changed shape (e.g. WiFi to cellular) while + // still nominally online. The old socket often survives in appearance only, + // so the supervisor probes it instead of waiting for a ping timeout. + | "network-path-changed" | "credentials-changed"; export function isApplicationActiveWakeup(reason: ConnectionWakeup): boolean { diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 9625effa406..23e9af7b811 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -91,6 +91,14 @@ export const make = Effect.gen(function* () { ), Effect.asVoid, ), + // Distinguishes zombie-socket disconnects (missed pong after suspension + // or a silent path change) from ordinary closes in logs and telemetry. + onPingTimeout: Effect.logInfo("Connection ping timed out; treating the socket as dead.").pipe( + Effect.annotateLogs({ + environmentId: connection.environmentId, + connectionLabel: connection.label, + }), + ), }); const socketLayer = Socket.layerWebSocket(connection.socketUrl, { openTimeout: SOCKET_OPEN_TIMEOUT, diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index e006fc3cd76..a2202eb8150 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -150,7 +150,7 @@ describe("environment shell synchronization", () => { }), ); - it.effect("replaces a warm shell cache with an authoritative HTTP snapshot", () => + it.effect("resumes a warm shell cache via afterSequence without an HTTP fetch", () => Effect.gen(function* () { const cachedSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 5, @@ -225,12 +225,15 @@ describe("environment shell synchronization", () => { Stream.runHead, ); - expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(9); + // The warm cache resumes from its own sequence; the server replays the + // gap (or sends a fresh socket snapshot when it is too large), so the + // HTTP loader stays untouched. + expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(5); expect(yield* Ref.get(capturedCompletionMarker)).toBe(true); - expect(yield* SubscriptionRef.get(loaderCalls)).toBe(1); + expect(yield* SubscriptionRef.get(loaderCalls)).toBe(0); const synchronizing = yield* SubscriptionRef.get(shellState); expect(synchronizing.status).toBe("synchronizing"); - expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(httpSnapshot); + expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(cachedSnapshot); yield* Queue.offer(events, { kind: "synchronized" }); yield* SubscriptionRef.changes(shellState).pipe( @@ -240,7 +243,7 @@ describe("environment shell synchronization", () => { }), ); - it.effect("refreshes the authoritative shell snapshot when the app becomes active", () => + it.effect("resubscribes from the cached sequence when the app becomes active", () => Effect.gen(function* () { const events = yield* Queue.unbounded(); const wakeups = yield* Queue.unbounded(); @@ -296,13 +299,9 @@ describe("environment shell synchronization", () => { ), ); + // The warm cache subscribes directly; no HTTP fetch. yield* SubscriptionRef.changes(shellState).pipe( - Stream.filter( - (value) => - value.status === "synchronizing" && - Option.isSome(value.snapshot) && - value.snapshot.value.snapshotSequence === 10, - ), + Stream.filter((value) => value.status === "synchronizing"), Stream.runHead, ); yield* Queue.offer(events, { kind: "synchronized" }); @@ -310,24 +309,17 @@ describe("environment shell synchronization", () => { Stream.filter((value) => value.status === "live"), Stream.runHead, ); + expect(yield* Ref.get(loaderCalls)).toBe(0); + expect(yield* Ref.get(subscriptionCount)).toBe(1); + // Foreground wakeups resubscribe from the cached sequence and still + // skip the HTTP loader; catch-up is the server's sequence replay. yield* Queue.offer(wakeups, "application-active"); - yield* SubscriptionRef.changes(shellState).pipe( - Stream.filter( - (value) => - value.status === "synchronizing" && - Option.isSome(value.snapshot) && - value.snapshot.value.snapshotSequence === 20, - ), - Stream.runHead, - ); - for (let attempt = 0; attempt < 100; attempt += 1) { if ((yield* Ref.get(subscriptionCount)) >= 2) break; yield* Effect.yieldNow; } - - expect(yield* Ref.get(loaderCalls)).toBe(2); + expect(yield* Ref.get(loaderCalls)).toBe(0); expect(yield* Ref.get(subscriptionCount)).toBe(2); yield* Queue.offer(wakeups, "application-active-probe"); @@ -335,14 +327,14 @@ describe("environment shell synchronization", () => { if ((yield* Ref.get(subscriptionCount)) >= 3) break; yield* Effect.yieldNow; } - expect(yield* Ref.get(loaderCalls)).toBe(3); + expect(yield* Ref.get(loaderCalls)).toBe(0); expect(yield* Ref.get(subscriptionCount)).toBe(3); yield* Queue.offer(wakeups, "application-active-reconnect"); for (let attempt = 0; attempt < 10; attempt += 1) { yield* Effect.yieldNow; } - expect(yield* Ref.get(loaderCalls)).toBe(3); + expect(yield* Ref.get(loaderCalls)).toBe(0); expect(yield* Ref.get(subscriptionCount)).toBe(3); }), ); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index a266af5f5f4..2a239f59bea 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -187,6 +187,19 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") yield* Ref.set(awaitingCompletion, supportsCompletionMarker); yield* setSynchronizing; + // A warm snapshot resumes via afterSequence alone; the server replays + // the gap or falls back to a fresh socket snapshot when it is too + // large (SHELL_RESUME_MAX_GAP), so the HTTP fetch is only needed on a + // truly cold start. This mirrors the thread-detail guard and keeps + // foreground resubscribes off the HTTP path. + const cached = yield* SubscriptionRef.get(state); + if (Option.isSome(cached.snapshot)) { + return { + afterSequence: cached.snapshot.value.snapshotSequence, + ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), + }; + } + const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( Effect.flatMap( Option.match({ diff --git a/patches/@effect__platform-bun@4.0.0-beta.102.patch b/patches/@effect__platform-bun@4.0.0-beta.102.patch index 8b8687295df..3283557cab7 100644 --- a/patches/@effect__platform-bun@4.0.0-beta.102.patch +++ b/patches/@effect__platform-bun@4.0.0-beta.102.patch @@ -2,7 +2,7 @@ diff --git a/dist/BunHttpServer.js b/dist/BunHttpServer.js index 5fb0cbacc183b58e31a442badf4aef06bebcd592..e31fb00c430821fc06247079091a93fb15a1ab92 100644 --- a/dist/BunHttpServer.js +++ b/dist/BunHttpServer.js -@@ -45,6 +45,20 @@ export const make = /*#__PURE__*/Effect.fnUntraced(function* (options) { +@@ -45,6 +45,25 @@ export const make = /*#__PURE__*/Effect.fnUntraced(function* (options) { ...options, fetch: handlerStack[0], websocket: { @@ -20,6 +20,11 @@ index 5fb0cbacc183b58e31a442badf4aef06bebcd592..e31fb00c430821fc06247079091a93fb + compress: "dedicated", + decompress: "shared" + }, ++ // Reap connections that stop sending within 30s instead of Bun's 120s ++ // default. RPC clients ping every 5s, so a live client never idles; a ++ // suspended mobile app stops pinging and its half-dead socket (plus the ++ // server-side buffers feeding it) is released four times sooner. ++ idleTimeout: 30, open(ws) { Deferred.doneUnsafe(ws.data.deferred, Exit.succeed(ws)); }, @@ -27,7 +32,7 @@ diff --git a/src/BunHttpServer.ts b/src/BunHttpServer.ts index 9f2ac3dfc018e2d35c6f44785f1ce05e2e06fc7c..f2bd3def712677ae1e7d475f5eaf27ac2e42ae1a 100644 --- a/src/BunHttpServer.ts +++ b/src/BunHttpServer.ts -@@ -90,6 +90,20 @@ export const make = Effect.fnUntraced( +@@ -90,6 +90,25 @@ export const make = Effect.fnUntraced( ...options as ServeOptions, fetch: handlerStack[0], websocket: { @@ -45,6 +50,11 @@ index 9f2ac3dfc018e2d35c6f44785f1ce05e2e06fc7c..f2bd3def712677ae1e7d475f5eaf27ac + compress: "dedicated", + decompress: "shared" + }, ++ // Reap connections that stop sending within 30s instead of Bun's 120s ++ // default. RPC clients ping every 5s, so a live client never idles; a ++ // suspended mobile app stops pinging and its half-dead socket (plus the ++ // server-side buffers feeding it) is released four times sooner. ++ idleTimeout: 30, open(ws) { Deferred.doneUnsafe(ws.data.deferred, Exit.succeed(ws)) }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85afdd75d7a..d8314e1491b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,7 +69,7 @@ overrides: packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= patchedDependencies: - '@effect/platform-bun@4.0.0-beta.102': 9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23 + '@effect/platform-bun@4.0.0-beta.102': 57600f449cc69deb52a5b903d63085b139c19321758ce61cf4a93e67fdf05ed5 '@effect/platform-node@4.0.0-beta.102': cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0 '@effect/vitest@4.0.0-beta.102': a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c13425 '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 @@ -448,7 +448,7 @@ importers: version: 0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@effect/platform-bun': specifier: 4.0.0-beta.102 - version: 4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) + version: 4.0.0-beta.102(patch_hash=57600f449cc69deb52a5b903d63085b139c19321758ce61cf4a93e67fdf05ed5)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) '@effect/platform-node': specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) @@ -684,7 +684,7 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.65 - version: 2.0.0-beta.65(249551d75ad3792c0b22cd2754b25266) + version: 2.0.0-beta.65(4601445bce56d6a7dfd9e662a0d43d47) drizzle-orm: specifier: 1.0.0-rc.4 version: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/sql-pg@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/sql-sqlite-bun@4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) @@ -11687,25 +11687,25 @@ snapshots: transitivePeerDependencies: - workerd - '@distilled.cloud/cloudflare-runtime@0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/platform-bun@4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))': + '@distilled.cloud/cloudflare-runtime@0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/platform-bun@4.0.0-beta.102(patch_hash=57600f449cc69deb52a5b903d63085b139c19321758ce61cf4a93e67fdf05ed5)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))': dependencies: '@alchemy.run/node-utils': 0.0.5 '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) effect: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) workerd: 1.20260704.1 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.102(patch_hash=57600f449cc69deb52a5b903d63085b139c19321758ce61cf4a93e67fdf05ed5)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) '@effect/platform-node': 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@distilled.cloud/cloudflare-vite-plugin@0.13.10(554b80dcfb9268a9eeb72fedc0ae62a5)': + '@distilled.cloud/cloudflare-vite-plugin@0.13.10(b51eb06b8f98f9ad141300daf47877c3)': dependencies: '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) - '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/platform-bun@4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) + '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/platform-bun@4.0.0-beta.102(patch_hash=57600f449cc69deb52a5b903d63085b139c19321758ce61cf4a93e67fdf05ed5)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) effect: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.102(patch_hash=57600f449cc69deb52a5b903d63085b139c19321758ce61cf4a93e67fdf05ed5)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) '@effect/platform-node': 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - rolldown @@ -11784,7 +11784,7 @@ snapshots: transitivePeerDependencies: - encoding - '@effect/platform-bun@4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6)': + '@effect/platform-bun@4.0.0-beta.102(patch_hash=57600f449cc69deb52a5b903d63085b139c19321758ce61cf4a93e67fdf05ed5)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6)': dependencies: '@effect/platform-node-shared': 4.0.0-beta.102(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) effect: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) @@ -15371,7 +15371,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.65(249551d75ad3792c0b22cd2754b25266): + alchemy@2.0.0-beta.65(4601445bce56d6a7dfd9e662a0d43d47): dependencies: '@alchemy.run/node-utils': 0.0.5 '@aws-sdk/credential-providers': 3.1062.0 @@ -15380,8 +15380,8 @@ snapshots: '@distilled.cloud/axiom': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) - '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/platform-bun@4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) - '@distilled.cloud/cloudflare-vite-plugin': 0.13.10(554b80dcfb9268a9eeb72fedc0ae62a5) + '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)))(@effect/platform-bun@4.0.0-beta.102(patch_hash=57600f449cc69deb52a5b903d63085b139c19321758ce61cf4a93e67fdf05ed5)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) + '@distilled.cloud/cloudflare-vite-plugin': 0.13.10(b51eb06b8f98f9ad141300daf47877c3) '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@distilled.cloud/neon': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) '@distilled.cloud/planetscale': 0.30.2(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) @@ -15412,7 +15412,7 @@ snapshots: undici: 7.27.1 yaml: 2.9.0 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.102(patch_hash=9f7cfa69ef19b624ac7704fe505a6775dc272d734208883f3decc7e5201a3d23)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.102(patch_hash=57600f449cc69deb52a5b903d63085b139c19321758ce61cf4a93e67fdf05ed5)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(utf-8-validate@6.0.6) '@effect/platform-node': 4.0.0-beta.102(patch_hash=cae7efcda6fd29f4db2efd357cb7dc6c41cb6adc957901d437e13ed1f3c017f0)(bufferutil@4.1.0)(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/sql-pg': 4.0.0-beta.102(effect@4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488)) drizzle-kit: 1.0.0-rc.4 From 4df84c91b33c4dee85b80d99de4ae2964a4a81e1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 31 Jul 2026 16:24:21 -0700 Subject: [PATCH 2/4] fix(mobile): address review findings on resume hardening - Warm-cache shell resume now requires the completion marker; without it nothing promotes an already-caught-up resume to live, so legacy servers keep the HTTP snapshot path. - Seed the initial interface type from getNetworkStateAsync so the first WiFi/cellular flip after startup emits a wakeup; the listener only reports changes. - network-path-changed is ignored while waiting in backoff/offline/ blocked states: it is advisory (probe a connected session) and a flapping interface must not cut retry delays short. Regression test added. Co-Authored-By: Claude Fable 5 --- apps/mobile/src/connection/platform.ts | 7 +++++ .../src/connection/supervisor.test.ts | 27 +++++++++++++++++++ .../src/connection/supervisor.ts | 21 +++++++++++---- packages/client-runtime/src/state/shell.ts | 6 +++-- 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index ed68e3cbbac..41c8abf758b 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -108,7 +108,14 @@ const wakeupsLayer = Wakeups.layer({ // Emit an advisory wakeup on interface-type changes while active; // the supervisor probes the session rather than blindly replacing // it, which keeps flapping paths cheap. + // Seed the current interface type so the first flip after startup + // is detected; the listener only reports changes. let networkType: string | null = null; + void Network.getNetworkStateAsync() + .then((current) => { + networkType ??= current.type ?? null; + }) + .catch(() => undefined); const networkSubscription = Network.addNetworkStateListener((state) => { const nextType = state.type ?? null; const previousType = networkType; diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index ef37b2e0036..8195d4ac95c 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -902,6 +902,33 @@ describe("EnvironmentSupervisor", () => { }), ); + it.effect("does not cut backoff short when the network path flaps", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: () => Effect.fail(transient()), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + + // Advisory path-change wakeups have no session to probe during backoff + // and must not trigger an early retry. + yield* harness.wake("network-path-changed"); + yield* harness.wake("network-path-changed"); + for (let attempt = 0; attempt < 20; attempt += 1) { + yield* Effect.yieldNow; + } + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("backoff"); + }), + ); + it.effect("replaces the session when a probe after a network path change fails", () => Effect.gen(function* () { const harness = yield* makeHarness({ diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 420cd27eb30..e177ee85318 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -628,6 +628,12 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const next = yield* Queue.take(signals); switch (next._tag) { case "Wakeup": + // The path-change wakeup is advisory: it only prompts a probe + // of a connected session. With no session to probe, a flapping + // interface must not cut backoff delays short. + if (next.reason === "network-path-changed") { + break; + } return ConnectionWakeups.isApplicationActiveWakeup(next.reason); case "ConnectRequested": case "DisconnectRequested": @@ -640,11 +646,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ); }); - const waitForSignal = Queue.take(signals).pipe( - Effect.map( - (next) => next._tag === "Wakeup" && ConnectionWakeups.isApplicationActiveWakeup(next.reason), - ), - ); + const waitForSignal = Effect.gen(function* () { + for (;;) { + const next = yield* Queue.take(signals); + if (next._tag === "Wakeup" && next.reason === "network-path-changed") { + // Advisory only; see waitForRetrySignal. + continue; + } + return next._tag === "Wakeup" && ConnectionWakeups.isApplicationActiveWakeup(next.reason); + } + }); const run = Effect.fnUntraced(function* () { let failureCount = 0; diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 2a239f59bea..4523874dda6 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -191,9 +191,11 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") // the gap or falls back to a fresh socket snapshot when it is too // large (SHELL_RESUME_MAX_GAP), so the HTTP fetch is only needed on a // truly cold start. This mirrors the thread-detail guard and keeps - // foreground resubscribes off the HTTP path. + // foreground resubscribes off the HTTP path. Requires the completion + // marker: without it nothing promotes an already-caught-up resume to + // "live" (the HTTP path below promotes via its snapshot item instead). const cached = yield* SubscriptionRef.get(state); - if (Option.isSome(cached.snapshot)) { + if (supportsCompletionMarker && Option.isSome(cached.snapshot)) { return { afterSequence: cached.snapshot.value.snapshotSequence, ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), From 2c8fe6654470760efe90ce7d1b8a92c6f35897f3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 31 Jul 2026 16:28:32 -0700 Subject: [PATCH 3/4] fix(mobile): dedup connected generations per supervisor, lock legacy shell path A replacement supervisor restarts its generation counter, so deduping connected events outside followStream could suppress the first connected event of a new supervisor and reintroduce the up-to-25s lease lag. The generation dedup now lives inside the per-supervisor stream, which switchMap resets when the supervisor is replaced. Also adds a test locking the legacy (no completion marker) shell resume to the HTTP snapshot path so the warm-cache short-circuit cannot regress it silently. Co-Authored-By: Claude Fable 5 --- .../src/connection/background-activity.ts | 25 +++--- .../src/state/shell-sync.test.ts | 86 ++++++++++++++++++- 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/connection/background-activity.ts b/apps/mobile/src/connection/background-activity.ts index 0f84bbe4089..303618f03a9 100644 --- a/apps/mobile/src/connection/background-activity.ts +++ b/apps/mobile/src/connection/background-activity.ts @@ -108,20 +108,21 @@ export const mobileBackgroundActivityReporterLayer = Layer.effectDiscard( // is ignored), so without this the server's lease can lag a resume by up // to REPORT_INTERVAL_MS, keeping provider/VCS work paused. const connectedGenerations = (environmentId: EnvironmentId) => - registry - .followStream( - environmentId, - Stream.unwrap( - Effect.map(EnvironmentSupervisor, (supervisor) => - SubscriptionRef.changes(supervisor.state), + registry.followStream( + environmentId, + Stream.unwrap( + Effect.map(EnvironmentSupervisor, (supervisor) => + // The generation dedup must live inside the per-supervisor stream: + // a replacement supervisor restarts generations at 1, so deduping + // outside followStream would suppress its first connected event. + SubscriptionRef.changes(supervisor.state).pipe( + Stream.filter((state) => state.phase === "connected"), + Stream.map((state) => state.generation), + Stream.changes, ), ), - ) - .pipe( - Stream.filter((state) => state.phase === "connected"), - Stream.map((state) => `${environmentId}:${state.generation}`), - Stream.changes, - ); + ), + ); yield* Stream.concat( Stream.fromEffect(SubscriptionRef.get(registry.entries)), SubscriptionRef.changes(registry.entries), diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index a2202eb8150..bca85ded664 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -47,10 +47,15 @@ const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = { updatedAt: "2026-06-06T00:00:00.000Z", }; -function session(client: WsRpcProtocolClient): RpcSession.RpcSession { +function session( + client: WsRpcProtocolClient, + options?: { readonly shellResumeCompletionMarker?: boolean }, +): RpcSession.RpcSession { return { client, - initialConfig: Effect.succeed({ shellResumeCompletionMarker: true } as never), + initialConfig: Effect.succeed({ + shellResumeCompletionMarker: options?.shellResumeCompletionMarker ?? true, + } as never), ready: Effect.void, probe: Effect.void, closed: Effect.never, @@ -243,6 +248,83 @@ describe("environment shell synchronization", () => { }), ); + it.effect("keeps the HTTP snapshot path when the server lacks the completion marker", () => + Effect.gen(function* () { + const cachedSnapshot: OrchestrationShellSnapshot = { + snapshotSequence: 5, + projects: [], + threads: [], + updatedAt: "2026-06-06T00:00:00.000Z", + }; + const httpSnapshot: OrchestrationShellSnapshot = { + ...cachedSnapshot, + snapshotSequence: 9, + updatedAt: "2026-06-07T00:00:00.000Z", + }; + const events = yield* Queue.unbounded(); + const capturedAfterSequence = yield* SubscriptionRef.make(undefined); + const loaderCalls = yield* SubscriptionRef.make(0); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number }) => + Stream.unwrap( + SubscriptionRef.set(capturedAfterSequence, input.afterSequence).pipe( + Effect.as(Stream.fromQueue(events)), + ), + ), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make( + Option.some(session(client, { shellResumeCompletionMarker: false })), + ), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.some(cachedSnapshot)), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const snapshotLoader = ShellSnapshotLoader.of({ + load: () => + SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe( + Effect.as(Option.some(httpSnapshot)), + ), + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ShellSnapshotLoader, snapshotLoader), + ); + + yield* SubscriptionRef.changes(capturedAfterSequence).pipe( + Stream.filter((value) => value !== undefined), + Stream.runHead, + ); + + // Without the marker there is no synchronized item to promote a warm + // resume to live, so the HTTP snapshot path must be used: it fetches, + // applies, and promotes via the snapshot item. + expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(9); + expect(yield* SubscriptionRef.get(loaderCalls)).toBe(1); + const current = yield* SubscriptionRef.get(shellState); + expect(current.status).toBe("live"); + expect(Option.getOrThrow(current.snapshot)).toEqual(httpSnapshot); + }), + ); + it.effect("resubscribes from the cached sequence when the app becomes active", () => Effect.gen(function* () { const events = yield* Queue.unbounded(); From b889ec4d04d38050e550e810e63d3411058c6469 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 31 Jul 2026 16:32:24 -0700 Subject: [PATCH 4/4] fix(mobile): sol/macroscope review fixes for resume hardening - Blocked probe failures (auth, permissions) keep their classification: the supervisor now parks in blocked instead of churning immediate reconnects when a foreground probe fails with ConnectionBlockedError. - The environment-set dedup compares elements instead of join(","), which was ambiguous for ids containing commas. - switchMap uses its default concurrency so each environment-set change interrupts the previous subscription set instead of leaking one per change; mergeAll inside stays unbounded. - The backoff ladder test reads the scheduled retryAt per rung and asserts it lands in the jitter bounds [rung/2, rung], restoring real coverage of ladder progression and the 16s cap. Co-Authored-By: Claude Fable 5 --- .../src/connection/background-activity.ts | 16 +++--- .../src/connection/supervisor.test.ts | 49 ++++++++++++++++--- .../src/connection/supervisor.ts | 13 +++-- 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/connection/background-activity.ts b/apps/mobile/src/connection/background-activity.ts index 303618f03a9..f0d6c4ded18 100644 --- a/apps/mobile/src/connection/background-activity.ts +++ b/apps/mobile/src/connection/background-activity.ts @@ -128,13 +128,15 @@ export const mobileBackgroundActivityReporterLayer = Layer.effectDiscard( SubscriptionRef.changes(registry.entries), ).pipe( Stream.map((entries) => [...entries.keys()].sort()), - Stream.changesWith((a, b) => a.join(",") === b.join(",")), - Stream.switchMap( - (environmentIds) => - Stream.mergeAll(environmentIds.map(connectedGenerations), { - concurrency: "unbounded", - }), - { concurrency: "unbounded" }, + Stream.changesWith((a, b) => a.length === b.length && a.every((id, i) => id === b[i])), + // Default switchMap concurrency (1) interrupts the previous inner + // stream on every environment-set change; unbounded here would leak a + // subscription set per change. mergeAll inside stays unbounded so all + // environments are observed concurrently. + Stream.switchMap((environmentIds) => + Stream.mergeAll(environmentIds.map(connectedGenerations), { + concurrency: "unbounded", + }), ), Stream.runForEach(() => Effect.sync(requestReport)), Effect.forkScoped, diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 8195d4ac95c..c1f34587a6f 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -1,6 +1,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import { describe, expect, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -358,18 +359,24 @@ describe("EnvironmentSupervisor", () => { ); expect(yield* Ref.get(harness.prepareCount)).toBe(1); - // Jitter puts each delay in [rung/2, rung], so exact rung boundaries - // are not observable; advancing by the 16s cap always covers at least - // one more retry and proves the ladder keeps going past its last rung. - for (const [index] of [1_000, 2_000, 4_000, 8_000, 16_000, 16_000].entries()) { - yield* TestClock.adjust(16_000); + // Jitter puts each delay in [rung/2, rung]. Read the scheduled retryAt + // per rung, assert it falls inside the jitter bounds (proving ladder + // progression and the 16s cap), then advance exactly to it. + for (const [index, rung] of [1_000, 2_000, 4_000, 8_000, 16_000, 16_000].entries()) { + const current = yield* SubscriptionRef.get(supervisor.state); + const now = yield* Clock.currentTimeMillis; + expect(current.retryAt).not.toBeNull(); + const delay = (current.retryAt ?? now) - now; + expect(delay).toBeGreaterThanOrEqual(rung / 2); + expect(delay).toBeLessThanOrEqual(rung); + yield* TestClock.adjust(delay); yield* eventuallyState( supervisor.state, - (state) => state.phase === "backoff" && state.attempt >= index + 2, + (state) => state.phase === "backoff" && state.attempt === index + 2, ); } - expect(yield* Ref.get(harness.prepareCount)).toBeGreaterThanOrEqual(7); + expect(yield* Ref.get(harness.prepareCount)).toBe(7); }).pipe(Effect.provide(TestClock.layer())), ); @@ -1029,6 +1036,34 @@ describe("EnvironmentSupervisor", () => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("parks in blocked when the foreground probe fails with a blocked error", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => (attempt === 1 ? Effect.fail(blocked()) : Effect.void), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + // A blocked probe failure (auth revoked, permissions) must not churn + // immediate reconnects; the supervisor parks until an external signal. + yield* awaitState( + supervisor.state, + (state) => state.phase === "blocked" && state.lastFailure?.reason === "authentication", + ); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + + yield* harness.wake("application-active"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + }), + ); + it.effect("quickly times out a stalled mobile foreground liveness probe", () => Effect.gen(function* () { const harness = yield* makeHarness({ diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index e177ee85318..ea40a7e01ba 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -450,9 +450,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ); if (probeEvent._tag === "ProbeCompleted") { if (Exit.isFailure(probeEvent.exit)) { - // A failed health check already proves the session is dead; - // replace it immediately instead of paying the retry ladder - // for a failure the probe just diagnosed. + const failure = Cause.findErrorOption(probeEvent.exit.cause); + if (Option.isSome(failure) && failure.value._tag === "ConnectionBlockedError") { + // Blocked failures (auth, permissions) must keep their + // classification so the run loop parks in the blocked + // state instead of churning immediate reconnects. + yield* probeEvent.exit; + } + // A transiently failed health check already proves the + // session is dead; replace it immediately instead of paying + // the retry ladder for a failure the probe just diagnosed. return true; } break;