From d779514f100ebdd4be9e0f81281925d56167c522 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 7 Sep 2026 23:34:37 +0200 Subject: [PATCH] Keep daemon reconnect running while the app is backgrounded (#4444) * fix(app): keep reconnect enabled while backgrounded Visibility should flush cached state and accelerate foreground recovery without disabling normal reconnect backoff. Preserve healthy sockets and retry whenever the OS permits execution. * test(app): clarify the background reconnect journey * test(app): name reconnect screenshot helper inputs * test(app): wait for stable content before delayed-history scroll * chore(app): keep reconnect QA evidence in the PR body --- .../app/e2e/browser/agent-stream-ui.spec.ts | 4 +- .../e2e/browser/background-reconnect.spec.ts | 86 ++++++++++ .../navigation/host-runtime-bootstrap.test.ts | 8 +- packages/app/src/runtime/host-runtime.test.ts | 153 +++++++++++------- packages/app/src/runtime/host-runtime.ts | 16 +- 5 files changed, 190 insertions(+), 77 deletions(-) create mode 100644 packages/app/e2e/browser/background-reconnect.spec.ts diff --git a/packages/app/e2e/browser/agent-stream-ui.spec.ts b/packages/app/e2e/browser/agent-stream-ui.spec.ts index 229536893b..2c81fb3308 100644 --- a/packages/app/e2e/browser/agent-stream-ui.spec.ts +++ b/packages/app/e2e/browser/agent-stream-ui.spec.ts @@ -216,7 +216,9 @@ test.describe("Agent stream UI", () => { await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({ timeout: 30_000, }); - await awaitAssistantMessage(page); + // Reasoning rows collapse when the mock starts its next assistant response. Wait past + // that transition so their temporary height cannot satisfy the scroll-away setup. + await awaitAssistantMessage(page, "Now I have a clearer picture."); await waitForScrollableChat(page, { minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE, timeout: 45_000, diff --git a/packages/app/e2e/browser/background-reconnect.spec.ts b/packages/app/e2e/browser/background-reconnect.spec.ts new file mode 100644 index 0000000000..e9831c257d --- /dev/null +++ b/packages/app/e2e/browser/background-reconnect.spec.ts @@ -0,0 +1,86 @@ +import { expect, test as base } from "../support/fixtures"; +import type { Page, TestInfo } from "@playwright/test"; +import { + startIsolatedHostDaemon, + type IsolatedHostDaemon, +} from "../support/helpers/isolated-host-daemon"; +import { seedSavedSettingsHosts } from "../support/helpers/settings"; + +const test = base.extend<{ reconnectHost: IsolatedHostDaemon }>({ + reconnectHost: async ({ e2eWorker }, provide) => { + void e2eWorker; + const daemon = await startIsolatedHostDaemon("background-reconnect"); + try { + await provide(daemon); + } finally { + await daemon.close(); + } + }, +}); + +test.describe.configure({ timeout: 120_000 }); + +async function captureHost({ + page, + testInfo, + name, +}: { + page: Page; + testInfo: TestInfo; + name: string; +}): Promise { + await page.screenshot({ path: testInfo.outputPath(`${name}.png`) }); +} + +async function refocusConnectedTab(page: Page): Promise { + await setTabVisibility(page, "visible"); + await expect(page.getByText("Online", { exact: true })).toBeVisible(); +} + +async function setTabVisibility(page: Page, visibility: DocumentVisibilityState): Promise { + await page.evaluate((state) => { + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state }); + Object.defineProperty(document, "hidden", { + configurable: true, + get: () => state === "hidden", + }); + document.dispatchEvent(new Event("visibilitychange")); + }, visibility); +} + +async function openHostOverview(page: Page, daemon: IsolatedHostDaemon): Promise { + await page.goto("/"); + await seedSavedSettingsHosts(page, [ + { + serverId: daemon.serverId, + label: "Reconnect QA", + endpoint: `127.0.0.1:${daemon.port}`, + }, + ]); + await page.goto(`/settings/hosts/${daemon.serverId}/host`); + await expect(page.getByText("Online", { exact: true })).toBeVisible(); +} + +async function restartHostWhileHidden(page: Page, daemon: IsolatedHostDaemon): Promise { + const reconnectedSocket = page.waitForEvent("websocket", { + timeout: 30_000, + predicate: (socket) => new URL(socket.url()).port === String(daemon.port), + }); + await daemon.restart(); + await reconnectedSocket; + await expect(page.getByText("Online", { exact: true })).toBeVisible({ timeout: 30_000 }); + expect(await page.evaluate(() => document.visibilityState)).toBe("hidden"); +} + +test("a hidden tab reconnects after daemon restart and stays connected on refocus", async ({ + page, + reconnectHost, +}, testInfo) => { + await openHostOverview(page, reconnectHost); + await captureHost({ page, testInfo, name: "before-hide" }); + await setTabVisibility(page, "hidden"); + await restartHostWhileHidden(page, reconnectHost); + await captureHost({ page, testInfo, name: "reconnected-while-hidden" }); + await refocusConnectedTab(page); + await captureHost({ page, testInfo, name: "reconnected-after-refocus" }); +}); diff --git a/packages/app/src/navigation/host-runtime-bootstrap.test.ts b/packages/app/src/navigation/host-runtime-bootstrap.test.ts index 603532086d..14eccad781 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.test.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.test.ts @@ -397,10 +397,10 @@ describe("resolveHostIndexRoute", () => { describe("host runtime app lifecycle", () => { it.each(["inactive", "background"] as const)( - "starts with reconnect disabled when mounted %s", + "applies initial visibility and forwards lifecycle changes when mounted %s", (currentState) => { const visibility: boolean[] = []; - let listener: ((state: "active" | "background") => void) | undefined; + let listener: ((state: "active" | "inactive" | "background") => void) | undefined; let subscribed = true; const dispose = bindHostRuntimeAppState( { setAppVisible: (visible) => visibility.push(visible) }, @@ -418,8 +418,10 @@ describe("host runtime app lifecycle", () => { ); expect(visibility).toEqual([false]); listener?.("active"); + listener?.("inactive"); listener?.("background"); - expect(visibility).toEqual([false, true, false]); + listener?.("active"); + expect(visibility).toEqual([false, true, false, false, true]); dispose(); expect(subscribed).toBe(false); }, diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index b77d53f646..cd6be83fae 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -1,4 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AppStateStatus } from "react-native"; +import { bindHostRuntimeAppState } from "@/navigation/host-runtime-bootstrap"; import type { DaemonClient, ConnectionState, @@ -1486,70 +1488,101 @@ describe("HostRuntimeController", () => { }); describe("HostRuntimeStore", () => { - it("reconnects every registered host immediately on app resume", async () => { - const relay = (suffix: string): HostConnection => ({ - id: `relay:relay-${suffix}.paseo.sh:443`, - type: "relay", - relayEndpoint: `relay-${suffix}.paseo.sh:443`, - daemonPublicKeyB64: `pk_${suffix}`, - }); - const hostAConnection = relay("a"); - const hostBConnection = relay("b"); - const hostA = makeHost({ - serverId: "srv_a", - connections: [hostAConnection], - preferredConnectionId: hostAConnection.id, - }); - const hostB = makeHost({ - serverId: "srv_b", - connections: [hostBConnection], - preferredConnectionId: hostBConnection.id, - }); - const clientA = new FakeDaemonClient(); - const clientB = new FakeDaemonClient(); - clientA.setConnectionState({ status: "connected" }); - clientB.setConnectionState({ status: "connected" }); - const store = new HostRuntimeStore({ - storage: createMemoryHostRuntimeStorage(), - deps: { - createClient: () => { - throw new Error("initial clients are supplied"); + it.each(["active", "inactive", "background"] as const)( + "keeps reconnect enabled through inactive/background and resumes immediately (mounted %s)", + async (currentState) => { + const relay = (suffix: string): HostConnection => ({ + id: `relay:relay-${suffix}.paseo.sh:443`, + type: "relay", + relayEndpoint: `relay-${suffix}.paseo.sh:443`, + daemonPublicKeyB64: `pk_${suffix}`, + }); + const hostAConnection = relay("a"); + const hostBConnection = relay("b"); + const hostA = makeHost({ + serverId: "srv_a", + connections: [hostAConnection], + preferredConnectionId: hostAConnection.id, + }); + const hostB = makeHost({ + serverId: "srv_b", + connections: [hostBConnection], + preferredConnectionId: hostBConnection.id, + }); + const clientA = new FakeDaemonClient(); + const clientB = new FakeDaemonClient(); + clientA.setConnectionState({ status: "connected" }); + clientB.setConnectionState({ status: "connected" }); + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: () => { + throw new Error("initial clients are supplied"); + }, + connectToDaemon: async () => { + throw new Error("single-connection hosts reuse their active clients"); + }, + getClientId: async () => "cid_test_runtime", }, - connectToDaemon: async () => { - throw new Error("single-connection hosts reuse their active clients"); + }); + + let changeAppState: (state: AppStateStatus) => void = () => { + throw new Error("AppState listener not registered"); + }; + const unbind = bindHostRuntimeAppState(store, { + currentState, + addEventListener: (_event, listener) => { + changeAppState = listener; + return { remove: () => {} }; }, - getClientId: async () => "cid_test_runtime", - }, - }); + }); + store.syncHosts([hostA, hostB], { + initialConnectionByServerId: new Map([ + [ + hostA.serverId, + { + connectionId: hostAConnection.id, + existingClient: clientA as unknown as DaemonClient, + }, + ], + [ + hostB.serverId, + { + connectionId: hostBConnection.id, + existingClient: clientB as unknown as DaemonClient, + }, + ], + ]), + }); + await waitForHostOnline(store, hostA.serverId); + await waitForHostOnline(store, hostB.serverId); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); + changeAppState("inactive"); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); + changeAppState("background"); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientA.getConnectionState()).toEqual({ status: "connected" }); + expect(clientB.getConnectionState()).toEqual({ status: "connected" }); + expect(clientA.ensureConnectedCalls).toBe(0); + expect(clientB.ensureConnectedCalls).toBe(0); + clientA.setConnectionState({ status: "disconnected", reason: "backgrounded" }); + clientB.setConnectionState({ status: "disconnected", reason: "backgrounded" }); - store.syncHosts([hostA, hostB], { - initialConnectionByServerId: new Map([ - [ - hostA.serverId, - { connectionId: hostAConnection.id, existingClient: clientA as unknown as DaemonClient }, - ], - [ - hostB.serverId, - { connectionId: hostBConnection.id, existingClient: clientB as unknown as DaemonClient }, - ], - ]), - }); - await waitForHostOnline(store, hostA.serverId); - await waitForHostOnline(store, hostB.serverId); - store.setAppVisible(false); - expect(clientA.reconnectEnabledChanges.at(-1)).toBe(false); - expect(clientB.reconnectEnabledChanges.at(-1)).toBe(false); - clientA.setConnectionState({ status: "disconnected", reason: "backgrounded" }); - clientB.setConnectionState({ status: "disconnected", reason: "backgrounded" }); + changeAppState("active"); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientA.ensureConnectedCalls).toBe(1); + expect(clientB.ensureConnectedCalls).toBe(1); + expect(store.getSnapshot(hostA.serverId)?.connectionStatus).toBe("online"); + expect(store.getSnapshot(hostB.serverId)?.connectionStatus).toBe("online"); - store.setAppVisible(true); - expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); - expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); - expect(clientA.ensureConnectedCalls).toBe(1); - expect(clientB.ensureConnectedCalls).toBe(1); - - store.syncHosts([]); - }); + unbind(); + store.syncHosts([]); + }, + ); it("revokes push notifications before removing a host", async () => { const host = makeHost({ connections: [makeHost().connections[0]!] }); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index ad33968740..9d551be1c7 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -619,7 +619,6 @@ export class HostRuntimeController { private switchRequestVersion = 0; private probeRequestVersion = 0; private probeCycleInFlight: Promise | null = null; - private reconnectEnabled = true; constructor(input: { host: HostProfile; @@ -729,11 +728,6 @@ export class HostRuntimeController { this.activeClient?.ensureConnected(); } - setReconnectEnabled(enabled: boolean): void { - this.reconnectEnabled = enabled; - this.activeClient?.setReconnectEnabled(enabled); - } - markAgentDirectorySyncLoading(): void { const status = this.snapshot.hasEverLoadedAgentDirectory ? "revalidating" : "initial_loading"; this.updateSnapshot({ @@ -1244,7 +1238,7 @@ export class HostRuntimeController { clientId, runtimeGeneration: nextGeneration, }); - client.setReconnectEnabled(this.reconnectEnabled); + client.setReconnectEnabled(true); if (!this.isSwitchStillValid(requestVersion, expectedProbeVersion)) { await client.close().catch(() => undefined); @@ -1406,7 +1400,6 @@ export class HostRuntimeStore { private timelineReplicaByServer = new Map(); private configuredOverrideBootstrapInFlight: Promise | null = null; private bootPromise: Promise | null = null; - private appVisible = true; private storage: HostRuntimeStorage; private replicaCache: ReplicaCache; private readonly revokePushNotifications: typeof revokePushNotifications; @@ -2080,7 +2073,6 @@ export class HostRuntimeStore { deps: this.deps, onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId), }); - controller.setReconnectEnabled(this.appVisible); this.controllers.set(host.serverId, controller); useSessionStore.getState().initializeSession(host.serverId, null); const directory = new DirectorySync( @@ -2342,10 +2334,8 @@ export class HostRuntimeStore { } setAppVisible(visible: boolean): void { - this.appVisible = visible; - for (const controller of this.controllers.values()) { - controller.setReconnectEnabled(visible); - } + // Keep normal reconnect backoff running while hidden, for as long as the OS + // lets us execute. Foregrounding bypasses that backoff without closing healthy sockets. if (!visible) { void this.replicaCache.flush(); return;