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
This commit is contained in:
Mohamed Boudra
2026-09-07 23:34:37 +02:00
committed by GitHub
parent 0859a67ab1
commit d779514f10
5 changed files with 190 additions and 77 deletions
@@ -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,
@@ -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<void> {
await page.screenshot({ path: testInfo.outputPath(`${name}.png`) });
}
async function refocusConnectedTab(page: Page): Promise<void> {
await setTabVisibility(page, "visible");
await expect(page.getByText("Online", { exact: true })).toBeVisible();
}
async function setTabVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
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<void> {
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<void> {
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" });
});
@@ -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);
},
+93 -60
View File
@@ -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]!] });
+3 -13
View File
@@ -619,7 +619,6 @@ export class HostRuntimeController {
private switchRequestVersion = 0;
private probeRequestVersion = 0;
private probeCycleInFlight: Promise<void> | 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<string, TimelineReplica>();
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
private bootPromise: Promise<void> | 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;