fix(app): stabilize startup connection routing

This commit is contained in:
Mohamed Boudra
2026-04-22 13:11:41 +07:00
parent 9a39ba4be0
commit 6b210df471
7 changed files with 187 additions and 90 deletions
+5
View File
@@ -178,6 +178,11 @@ class StartupAssertions {
await expect(this.page.locator('[data-testid="sidebar-project-list"]:visible')).toHaveCount(0); await expect(this.page.locator('[data-testid="sidebar-project-list"]:visible')).toHaveCount(0);
return this; return this;
} }
async expectsNoUndefinedRoute(): Promise<this> {
await expect(this.page).not.toHaveURL(/\/h\/undefined\/workspace\/undefined/);
return this;
}
} }
async function installPendingDesktopBridge(page: Page): Promise<void> { async function installPendingDesktopBridge(page: Page): Promise<void> {
+1
View File
@@ -43,6 +43,7 @@ test.describe("Startup loading presentation", () => {
await startup.expectsDesktopDaemonStartup(); await startup.expectsDesktopDaemonStartup();
await startup.expectsSidebarHidden(); await startup.expectsSidebarHidden();
await startup.expectsNoUndefinedRoute();
}); });
test("host-route refresh does not render route chrome around the bootstrap splash", async ({ test("host-route refresh does not render route chrome around the bootstrap splash", async ({
+4 -33
View File
@@ -353,7 +353,9 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
} }
} }
} else { } else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon }); setPhase("connecting");
setError(null);
await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
if (!cancelled) { if (!cancelled) {
setPhase("online"); setPhase("online");
setError(null); setError(null);
@@ -435,8 +437,6 @@ function AppContainer({
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer); const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode); const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const cycleTheme = useCallback(() => { const cycleTheme = useCallback(() => {
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName); const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
@@ -473,35 +473,6 @@ function AppContainer({
// other non-workspace routes) don't need a special-case to keep shortcuts alive. // other non-workspace routes) don't need a special-case to keep shortcuts alive.
const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings"); const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings");
useEffect(() => {
const bp = UnistylesRuntime.breakpoint;
const screenW = UnistylesRuntime.screen.width;
const screenH = UnistylesRuntime.screen.height;
const isElectron = getIsElectronRuntime();
const windowW = isWeb ? window.innerWidth : undefined;
const windowH = isWeb ? window.innerHeight : undefined;
const dpr = isWeb ? window.devicePixelRatio : undefined;
const ua = isWeb ? navigator.userAgent : undefined;
console.log(
"[layout-debug]",
JSON.stringify({
breakpoint: bp,
isCompactLayout,
isElectron,
chromeEnabled,
isFocusModeEnabled,
agentListOpen,
sidebarWidth,
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
unistylesScreen: { w: screenW, h: screenH },
window: { w: windowW, h: windowH },
devicePixelRatio: dpr,
userAgent: ua,
}),
);
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
useKeyboardShortcuts({ useKeyboardShortcuts({
enabled: keyboardShortcutsEnabled, enabled: keyboardShortcutsEnabled,
isMobile: isCompactLayout, isMobile: isCompactLayout,
@@ -876,6 +847,7 @@ function RootStack() {
}, },
}} }}
> >
<Stack.Screen name="index" />
<Stack.Protected guard={storeReady}> <Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" /> <Stack.Screen name="welcome" />
<Stack.Screen name="settings/index" /> <Stack.Screen name="settings/index" />
@@ -895,7 +867,6 @@ function RootStack() {
<Stack.Screen name="h/[serverId]/sessions" /> <Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" /> <Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" /> <Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="index" />
<Stack.Screen name="settings/hosts/[serverId]" /> <Stack.Screen name="settings/hosts/[serverId]" />
</Stack> </Stack>
); );
@@ -247,7 +247,10 @@ export function useKeyboardShortcuts({
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection(); const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
if (lastWorkspaceRoute) { if (lastWorkspaceRoute) {
router.replace( router.replace(
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId), buildHostWorkspaceRoute(
lastWorkspaceRoute.serverId,
lastWorkspaceRoute.workspaceId,
),
); );
return true; return true;
} }
+110 -28
View File
@@ -213,17 +213,21 @@ function makeDeps(
return client as unknown as DaemonClient; return client as unknown as DaemonClient;
}, },
connectToDaemon: async ({ host, connection }) => { connectToDaemon: async ({ host, connection }) => {
const value = latencyByConnectionId[connection.id]; const readLatency = (): number => {
if (value instanceof Error) { const value = latencyByConnectionId[connection.id];
throw value; if (value instanceof Error) {
} throw value;
if (typeof value !== "number") { }
throw new Error(`missing latency for ${connection.id}`); if (typeof value !== "number") {
} throw new Error(`missing latency for ${connection.id}`);
}
return value;
};
readLatency();
const client = new FakeDaemonClient(); const client = new FakeDaemonClient();
client.connectCalls = 1; client.connectCalls = 1;
client.setConnectionState({ status: "connected" }); client.setConnectionState({ status: "connected" });
client.ping = async () => ({ rttMs: value }); client.ping = async () => ({ rttMs: readLatency() });
createdClients.push(client); createdClients.push(client);
return { return {
client: client as unknown as DaemonClient, client: client as unknown as DaemonClient,
@@ -433,7 +437,95 @@ describe("HostRuntimeController", () => {
await probeCycle; await probeCycle;
}); });
it("fails over when active connection becomes unavailable", async () => { it("probes the active online connection through the existing client", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const probeAttempts: string[] = [];
const latencies: Record<string, number | Error> = {
"direct:lan:6767": 12,
"relay:relay.paseo.sh:443": 65,
};
const controller = new HostRuntimeController({
host,
deps: {
createClient: () => {
throw new Error("should adopt probe clients");
},
connectToDaemon: async ({ host, connection }) => {
probeAttempts.push(connection.id);
const value = latencies[connection.id];
if (value instanceof Error) {
throw value;
}
if (typeof value !== "number") {
throw new Error(`missing latency for ${connection.id}`);
}
return {
client: makeConnectedProbeClient(value) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
};
},
getClientId: async () => "cid_test_runtime",
},
});
await controller.start({ autoProbe: false });
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
probeAttempts.length = 0;
const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient;
activeClient.ping = async () => ({ rttMs: 9 });
clearProbeBackoff(controller);
await controller.runProbeCycleNow();
expect(probeAttempts).toEqual(["relay:relay.paseo.sh:443"]);
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
expect(controller.getSnapshot().connectionStatus).toBe("online");
expect(controller.getSnapshot().probeByConnectionId.get("direct:lan:6767")).toEqual({
status: "available",
latencyMs: 9,
});
});
it("rejects probes that resolve to a different server id", async () => {
const host = makeHost({
serverId: "srv_old",
connections: [
{
id: "direct:localhost:6767",
type: "directTcp",
endpoint: "localhost:6767",
},
],
});
const mismatchedClient = makeConnectedProbeClient(8);
const controller = new HostRuntimeController({
host,
deps: {
createClient: () => {
throw new Error("should not create active client");
},
connectToDaemon: async () => ({
client: mismatchedClient as unknown as DaemonClient,
serverId: "srv_current",
hostname: "current host",
}),
getClientId: async () => "cid_test_runtime",
},
});
await controller.start({ autoProbe: false });
expect(controller.getSnapshot().connectionStatus).toBe("connecting");
expect(controller.getSnapshot().activeConnectionId).toBeNull();
expect(controller.getSnapshot().probeByConnectionId.get("direct:localhost:6767")).toEqual({
status: "unavailable",
latencyMs: null,
});
expect(mismatchedClient.closeCalls).toBe(1);
});
it("fails over when the active client ping fails", async () => {
const host = makeHost({ preferredConnectionId: "direct:lan:6767" }); const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
const clients: FakeDaemonClient[] = []; const clients: FakeDaemonClient[] = [];
const latencies: Record<string, number | Error> = { const latencies: Record<string, number | Error> = {
@@ -450,6 +542,9 @@ describe("HostRuntimeController", () => {
const initialClient = controller.getSnapshot().client; const initialClient = controller.getSnapshot().client;
expect(initialClient).toBeTruthy(); expect(initialClient).toBeTruthy();
(initialClient as unknown as FakeDaemonClient).ping = async () => {
throw new Error("active ping failed");
};
latencies["direct:lan:6767"] = new Error("direct unavailable"); latencies["direct:lan:6767"] = new Error("direct unavailable");
latencies["relay:relay.paseo.sh:443"] = 42; latencies["relay:relay.paseo.sh:443"] = 42;
clearProbeBackoff(controller); clearProbeBackoff(controller);
@@ -838,7 +933,7 @@ describe("HostRuntimeController", () => {
expect(createdClients[0]?.closeCalls).toBe(1); expect(createdClients[0]?.closeCalls).toBe(1);
}); });
it("ignores stale probe results when overlapping probe cycles finish out of order", async () => { it("coalesces overlapping probe cycles instead of invalidating the in-flight result", async () => {
const host = makeHost({ const host = makeHost({
connections: [ connections: [
{ {
@@ -849,7 +944,6 @@ describe("HostRuntimeController", () => {
], ],
}); });
const slowProbe = createDeferred<number>(); const slowProbe = createDeferred<number>();
const fastProbe = createDeferred<number>();
let probeCalls = 0; let probeCalls = 0;
const controller = new HostRuntimeController({ const controller = new HostRuntimeController({
@@ -865,9 +959,6 @@ describe("HostRuntimeController", () => {
if (probeCalls === 1) { if (probeCalls === 1) {
return { rttMs: await slowProbe.promise }; return { rttMs: await slowProbe.promise };
} }
if (probeCalls === 2) {
return { rttMs: await fastProbe.promise };
}
throw new Error("unexpected probe call"); throw new Error("unexpected probe call");
}; };
return { return {
@@ -883,23 +974,14 @@ describe("HostRuntimeController", () => {
const first = controller.runProbeCycleNow(); const first = controller.runProbeCycleNow();
clearProbeBackoff(controller); clearProbeBackoff(controller);
const second = controller.runProbeCycleNow(); const second = controller.runProbeCycleNow();
expect(probeCalls).toBe(1);
fastProbe.resolve(12);
await second;
const probeAfterSecond = controller.getSnapshot().probeByConnectionId.get("direct:lan:6767");
expect(probeAfterSecond).toEqual({
status: "available",
latencyMs: 12,
});
slowProbe.resolve(900); slowProbe.resolve(900);
await first; await Promise.all([first, second]);
const probeAfterFirstSettles = controller const probeAfterCycle = controller.getSnapshot().probeByConnectionId.get("direct:lan:6767");
.getSnapshot() expect(probeAfterCycle).toEqual({
.probeByConnectionId.get("direct:lan:6767");
expect(probeAfterFirstSettles).toEqual({
status: "available", status: "available",
latencyMs: 12, latencyMs: 900,
}); });
}); });
+51 -27
View File
@@ -11,7 +11,6 @@ import {
connectionFromListen, connectionFromListen,
normalizeStoredHostProfile, normalizeStoredHostProfile,
upsertHostConnectionInProfiles, upsertHostConnectionInProfiles,
registryHasDirectEndpoint,
type HostConnection, type HostConnection,
type HostProfile, type HostProfile,
} from "@/types/host-connection"; } from "@/types/host-connection";
@@ -489,6 +488,7 @@ export class HostRuntimeController {
private clientIdHash: string | null = null; private clientIdHash: string | null = null;
private switchRequestVersion = 0; private switchRequestVersion = 0;
private probeRequestVersion = 0; private probeRequestVersion = 0;
private probeCycleInFlight: Promise<void> | null = null;
constructor(input: { constructor(input: {
host: HostProfile; host: HostProfile;
@@ -629,6 +629,20 @@ export class HostRuntimeController {
} }
async runProbeCycleNow(): Promise<void> { async runProbeCycleNow(): Promise<void> {
if (this.probeCycleInFlight) {
return this.probeCycleInFlight;
}
const cycle = this.runProbeCycle().finally(() => {
if (this.probeCycleInFlight === cycle) {
this.probeCycleInFlight = null;
}
});
this.probeCycleInFlight = cycle;
return cycle;
}
private async runProbeCycle(): Promise<void> {
const requestVersion = ++this.probeRequestVersion; const requestVersion = ++this.probeRequestVersion;
if (this.host.connections.length === 0) { if (this.host.connections.length === 0) {
if (!this.isCurrentProbeRequest(requestVersion)) { if (!this.isCurrentProbeRequest(requestVersion)) {
@@ -664,10 +678,15 @@ export class HostRuntimeController {
const probeByConnectionId = new Map(this.snapshot.probeByConnectionId); const probeByConnectionId = new Map(this.snapshot.probeByConnectionId);
for (const connection of connectionsToProbe) { for (const connection of connectionsToProbe) {
this.connectionLastProbedAt.set(connection.id, performance.now()); this.connectionLastProbedAt.set(connection.id, performance.now());
probeByConnectionId.set(connection.id, { const existingProbe = probeByConnectionId.get(connection.id);
status: "pending", const shouldPreserveActiveLatency =
latencyMs: null, isOnline && connection.id === activeConnectionId && existingProbe?.status === "available";
}); if (!shouldPreserveActiveLatency) {
probeByConnectionId.set(connection.id, {
status: "pending",
latencyMs: null,
});
}
} }
this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) }); this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) });
@@ -801,22 +820,39 @@ export class HostRuntimeController {
for (const connection of connectionsToProbe) { for (const connection of connectionsToProbe) {
void (async () => { void (async () => {
let connectedClient: DaemonClient | null = null; let connectedClient: DaemonClient | null = null;
let handedOffClient = false; let shouldCloseClient = false;
try { try {
const { client } = await this.deps.connectToDaemon({ const activeClient =
host: this.host, this.snapshot.connectionStatus === "online" &&
connection, this.snapshot.activeConnectionId === connection.id
}); ? this.snapshot.client
connectedClient = client; : null;
if (activeClient) {
connectedClient = activeClient;
} else {
const { client, serverId } = await this.deps.connectToDaemon({
host: this.host,
connection,
});
if (serverId !== this.host.serverId) {
await client.close().catch(() => undefined);
throw new Error(
`Connection resolved to ${serverId}, expected ${this.host.serverId}.`,
);
}
connectedClient = client;
shouldCloseClient = true;
}
if (!this.isCurrentProbeRequest(requestVersion)) { if (!this.isCurrentProbeRequest(requestVersion)) {
return; return;
} }
const activated = await maybeActivateFirstAvailable(connection.id, client); const activated = await maybeActivateFirstAvailable(connection.id, connectedClient);
handedOffClient = activated; shouldCloseClient = shouldCloseClient && !activated;
const { rttMs } = await client.ping({ timeoutMs: 5000 }); const { rttMs } = await connectedClient.ping({ timeoutMs: 5000 });
if (!this.isCurrentProbeRequest(requestVersion)) { if (!this.isCurrentProbeRequest(requestVersion)) {
return; return;
} }
@@ -835,7 +871,7 @@ export class HostRuntimeController {
publishProbeState(); publishProbeState();
} }
} finally { } finally {
if (connectedClient && !handedOffClient) { if (connectedClient && shouldCloseClient) {
await connectedClient.close().catch(() => undefined); await connectedClient.close().catch(() => undefined);
} }
settleProbe(); settleProbe();
@@ -1100,7 +1136,6 @@ export class HostRuntimeController {
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry"; const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767"; const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1";
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500; const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000; const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
const E2E_STORAGE_KEY = "@paseo:e2e"; const E2E_STORAGE_KEY = "@paseo:e2e";
@@ -1230,16 +1265,6 @@ export class HostRuntimeStore {
private async bootstrapLocalhost(): Promise<void> { private async bootstrapLocalhost(): Promise<void> {
try { try {
const alreadyHandled = await AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY);
if (alreadyHandled) {
return;
}
if (registryHasDirectEndpoint(this.hosts, DEFAULT_LOCALHOST_ENDPOINT)) {
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, "1");
return;
}
try { try {
const { client, serverId, hostname } = await connectToDaemon( const { client, serverId, hostname } = await connectToDaemon(
{ {
@@ -1256,7 +1281,6 @@ export class HostRuntimeStore {
label: hostname ?? undefined, label: hostname ?? undefined,
existingClient: client, existingClient: client,
}); });
await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, "1");
} catch { } catch {
// Best-effort bootstrap only // Best-effort bootstrap only
} }
+12 -1
View File
@@ -61,10 +61,12 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { import {
buildHostOpenProjectRoute, buildHostOpenProjectRoute,
buildHostWorkspaceRoute,
buildSettingsHostRoute, buildSettingsHostRoute,
buildSettingsSectionRoute, buildSettingsSectionRoute,
type SettingsSectionSlug, type SettingsSectionSlug,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { getLastNavigationWorkspaceRouteSelection } from "@/stores/navigation-active-workspace-store";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// View model // View model
@@ -720,6 +722,15 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
}, [router]); }, [router]);
const handleBackToWorkspace = useCallback(() => { const handleBackToWorkspace = useCallback(() => {
if (!isCompactLayout) {
const lastWorkspaceRoute = getLastNavigationWorkspaceRouteSelection();
if (lastWorkspaceRoute) {
router.replace(
buildHostWorkspaceRoute(lastWorkspaceRoute.serverId, lastWorkspaceRoute.workspaceId),
);
return;
}
}
if (router.canGoBack()) { if (router.canGoBack()) {
router.back(); router.back();
return; return;
@@ -729,7 +740,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
return; return;
} }
router.replace("/"); router.replace("/");
}, [anyOnlineServerId, router]); }, [anyOnlineServerId, isCompactLayout, router]);
const detailHeader = ((): { const detailHeader = ((): {
title: string; title: string;