mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
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:
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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]!] });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user