fix: preserve remote route during transient reconnects

This commit is contained in:
Federico Jaramillo Martinez
2026-06-10 23:04:25 +02:00
parent 9a3abe494d
commit ef22247d76
4 changed files with 167 additions and 16 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Keep the selected remote machine during transient reconnects instead of switching the web UI back to Local.
+132 -3
View File
@@ -65,6 +65,7 @@ import { appStyles } from "./shared";
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
const PI_WEB_STATUS_DEFER_MS = 750;
const REMOTE_ROUTE_RESTORE_RETRY_DELAYS_MS = [1_000, 3_000, 8_000, 15_000, 30_000] as const;
const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
const THEME_AUTO_ON_VALUE = "auto:on";
const THEME_AUTO_OFF_VALUE = "auto:off";
@@ -154,6 +155,10 @@ export class PiWebApp extends LitElement {
private routeRestoreSeq = 0;
private routeRestoreDepth = 0;
private restoringRouteTerminalId: string | undefined;
private pendingRemoteRouteRestore: AppRoute | undefined;
private remoteRouteRestoreTimer: number | undefined;
private remoteRouteRestoreAttempt = 0;
private remoteRouteRestoreInProgress = false;
private readonly plugins = createPluginRegistry();
private readonly loadedMachinePluginIds = new Set<string>();
private readonly machinePluginLoadPromises = new Map<string, Promise<void>>();
@@ -169,6 +174,7 @@ export class PiWebApp extends LitElement {
});
private readonly onPageShow = () => {
this.appShell.repairViewportPosition();
this.retryPendingRemoteRouteRestoreSoon();
};
private readonly onFocus = () => {
this.appShell.repairViewportPosition();
@@ -176,6 +182,7 @@ export class PiWebApp extends LitElement {
this.schedulePiWebStatusRefresh();
void this.refreshMachineActivities();
void this.refreshWorkspaceDeletionRuns();
this.retryPendingRemoteRouteRestoreSoon();
};
private readonly onVisibilityChange = () => {
if (document.visibilityState === "visible") {
@@ -184,6 +191,7 @@ export class PiWebApp extends LitElement {
this.schedulePiWebStatusRefresh();
void this.refreshMachineActivities();
void this.refreshWorkspaceDeletionRuns();
this.retryPendingRemoteRouteRestoreSoon();
}
};
private readonly onSystemLightThemeChange = () => {
@@ -240,6 +248,7 @@ export class PiWebApp extends LitElement {
this.clearScheduledPiWebStatusRefresh();
if (this.workspaceDeletionPollTimer !== undefined) window.clearInterval(this.workspaceDeletionPollTimer);
this.workspaceDeletionPollTimer = undefined;
this.clearPendingRemoteRouteRestore();
super.disconnectedCallback();
}
@@ -257,13 +266,16 @@ export class PiWebApp extends LitElement {
this.restoreSettingsRoute();
const route = readRoute();
await this.machines.loadMachines(route.machineId);
const machineFallbackMessage = this.state.error;
const effectiveRoute = this.routeForSelectedMachine(route);
const initialRouteMachineHealth = this.state.machineStatuses[effectiveRoute.machineId ?? "local"];
if (effectiveRoute !== route) this.replaceRouteAndClearWorkspaceQuery(effectiveRoute);
await this.projects.loadProjects();
if (machineFallbackMessage !== "" && this.state.error === "") this.setState({ error: machineFallbackMessage });
await this.withChatScrollTransition(() => this.restoreRouteFor(effectiveRoute, false));
this.rememberCurrentMachineNavigation();
if (this.shouldDeferRemoteRouteRestore(effectiveRoute, initialRouteMachineHealth)) this.deferRemoteRouteRestore(effectiveRoute);
else {
this.clearPendingRemoteRouteRestore();
this.rememberCurrentMachineNavigation();
}
await this.refreshWorkspaceDeletionRuns();
}
@@ -430,6 +442,116 @@ export class PiWebApp extends LitElement {
setNamespacedQueryKey(TERMINAL_ROUTE_NAMESPACE, "terminal", undefined, { replace: true });
}
private shouldDeferRemoteRouteRestore(route: AppRoute, routeMachineHealth = this.state.machineStatuses[route.machineId ?? "local"]): boolean {
const machineId = route.machineId ?? "local";
const machine = this.state.selectedMachine;
if (machineId === "local" || machine?.id !== machineId || machine.kind !== "remote") return false;
if (routeMachineHealth?.ok !== false) return false;
if (route.projectId === undefined || route.projectId === "") return this.state.projects.length === 0;
return this.state.selectedProject?.id !== route.projectId;
}
private deferRemoteRouteRestore(route: AppRoute): void {
this.pendingRemoteRouteRestore = route;
this.remoteRouteRestoreAttempt = 0;
this.setRemoteRouteRestoreMessage(route);
this.schedulePendingRemoteRouteRestore();
}
private retryPendingRemoteRouteRestoreSoon(): void {
if (this.pendingRemoteRouteRestore === undefined) return;
this.schedulePendingRemoteRouteRestore(0);
}
private schedulePendingRemoteRouteRestore(delayMs = remoteRouteRestoreRetryDelay(this.remoteRouteRestoreAttempt)): void {
if (this.pendingRemoteRouteRestore === undefined) return;
this.clearPendingRemoteRouteRestoreTimer();
this.remoteRouteRestoreTimer = window.setTimeout(() => {
this.remoteRouteRestoreTimer = undefined;
void this.retryPendingRemoteRouteRestore();
}, delayMs);
}
private async retryPendingRemoteRouteRestore(): Promise<void> {
if (this.remoteRouteRestoreInProgress) return;
const route = this.pendingRemoteRouteRestore;
if (route === undefined) return;
if (!this.pendingRemoteRouteRestoreStillCurrent(route)) {
this.clearPendingRemoteRouteRestore();
return;
}
this.remoteRouteRestoreInProgress = true;
try {
const machineId = route.machineId ?? "local";
const health = await this.machines.refreshMachineHealth(machineId);
if (!this.pendingRemoteRouteRestoreStillCurrent(route)) return;
if (health?.ok !== true) {
this.scheduleNextRemoteRouteRestoreAttempt(route);
return;
}
await this.machines.refreshMachineRuntime(machineId);
if (!this.pendingRemoteRouteRestoreStillCurrent(route)) return;
await this.projects.loadProjects();
if (!this.pendingRemoteRouteRestoreStillCurrent(route)) return;
if (this.state.error !== "") {
this.scheduleNextRemoteRouteRestoreAttempt(route);
return;
}
await this.withChatScrollTransition(() => this.restoreRouteFor(route, false));
if (!this.pendingRemoteRouteRestoreStillCurrent(route)) return;
this.clearPendingRemoteRouteRestore();
this.rememberCurrentMachineNavigation();
await this.refreshWorkspaceDeletionRuns();
} finally {
this.remoteRouteRestoreInProgress = false;
}
}
private scheduleNextRemoteRouteRestoreAttempt(route: AppRoute): void {
this.remoteRouteRestoreAttempt += 1;
if (this.remoteRouteRestoreAttempt >= REMOTE_ROUTE_RESTORE_RETRY_DELAYS_MS.length) {
this.setRemoteRouteRestoreMessage(route, { exhausted: true });
this.clearPendingRemoteRouteRestore();
return;
}
this.setRemoteRouteRestoreMessage(route);
this.schedulePendingRemoteRouteRestore();
}
private setRemoteRouteRestoreMessage(route: AppRoute, options: { exhausted?: boolean } = {}): void {
const machineId = route.machineId ?? "local";
const machineName = this.state.machines.find((machine) => machine.id === machineId)?.name ?? this.state.selectedMachine?.name ?? "Remote machine";
const health = this.state.machineStatuses[machineId];
const detail = health?.error ?? (this.state.error === "" ? undefined : this.state.error);
const prefix = options.exhausted === true
? `${machineName} is still unavailable.`
: `${machineName} is unavailable; reconnecting…`;
this.setState({ error: `${prefix}${detail === undefined ? "" : ` ${detail}`}` });
}
private pendingRemoteRouteRestoreStillCurrent(route: AppRoute): boolean {
const machineId = route.machineId ?? "local";
return machineId !== "local"
&& this.pendingRemoteRouteRestore === route
&& this.state.selectedMachine?.id === machineId
&& this.state.machines.some((machine) => machine.id === machineId);
}
private clearPendingRemoteRouteRestore(): void {
this.clearPendingRemoteRouteRestoreTimer();
this.pendingRemoteRouteRestore = undefined;
this.remoteRouteRestoreAttempt = 0;
}
private clearPendingRemoteRouteRestoreTimer(): void {
if (this.remoteRouteRestoreTimer === undefined) return;
window.clearTimeout(this.remoteRouteRestoreTimer);
this.remoteRouteRestoreTimer = undefined;
}
private async restoreRouteMachine(route: AppRoute, updateUrl: boolean): Promise<void> {
const routeMachineId = route.machineId ?? "local";
if (this.state.selectedMachine?.id === routeMachineId) return;
@@ -740,6 +862,8 @@ export class PiWebApp extends LitElement {
private handleMachineChange(previous: AppState, next: AppState): void {
if ((previous.selectedMachine?.id ?? "local") === (next.selectedMachine?.id ?? "local")) return;
const pendingMachineId = this.pendingRemoteRouteRestore?.machineId ?? "local";
if (pendingMachineId !== (next.selectedMachine?.id ?? "local")) this.clearPendingRemoteRouteRestore();
this.sessions.clearActiveSession();
this.realtime.close();
this.connectRealtime();
@@ -1675,6 +1799,11 @@ function machineScopedKey(machineId: string, value: string): string {
return JSON.stringify([machineId, value]);
}
function remoteRouteRestoreRetryDelay(attempt: number): number {
const index = Math.min(attempt, REMOTE_ROUTE_RESTORE_RETRY_DELAYS_MS.length - 1);
return REMOTE_ROUTE_RESTORE_RETRY_DELAYS_MS[index] ?? 30_000;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -33,7 +33,7 @@ describe("MachineController", () => {
vi.restoreAllMocks();
});
it("falls back to the local machine when the routed remote machine is offline", async () => {
it("keeps the routed remote machine selected while its health is offline", async () => {
let state: AppState = initialAppState();
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
const updateUrl = vi.fn();
@@ -50,12 +50,12 @@ describe("MachineController", () => {
await controller.loadMachines(remoteMachine.id);
expect(state.selectedMachine).toEqual(localMachine);
expect(state.selectedMachine).toEqual(remoteMachine);
expect(state.machineStatuses[remoteMachine.id]).toEqual(offlineHealth);
expect(state.error).toContain("Remote is offline");
expect(state.error).toContain("Remote is unavailable");
});
it("records offline health when the routed remote health request rejects", async () => {
it("records offline health without falling back when the routed remote health request rejects", async () => {
let state: AppState = initialAppState();
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
const updateUrl = vi.fn();
@@ -68,9 +68,26 @@ describe("MachineController", () => {
await controller.loadMachines(remoteMachine.id);
expect(state.selectedMachine).toEqual(localMachine);
expect(state.selectedMachine).toEqual(remoteMachine);
expect(state.machineStatuses[remoteMachine.id]).toMatchObject({ machineId: remoteMachine.id, ok: false, status: "offline", error: "Internal Server Error" });
expect(state.error).toContain("Remote is offline");
expect(state.error).toContain("Remote is unavailable");
});
it("falls back to local when the routed machine is no longer configured", async () => {
let state: AppState = initialAppState();
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
const updateUrl = vi.fn();
const projects = { loadProjects: vi.fn() };
vi.spyOn(api, "machines").mockResolvedValue([localMachine]);
vi.spyOn(api, "health").mockResolvedValue({ machineId: "local", ok: true, checkedAt: "2026-05-26T00:00:01.000Z", status: "online" });
const controller = new MachineController(() => state, setState, updateUrl, projects);
await controller.loadMachines(remoteMachine.id);
expect(state.selectedMachine).toEqual(localMachine);
expect(state.error).toBe("");
});
it("returns the fallback machine without selecting it when requested", async () => {
@@ -88,12 +88,14 @@ export class MachineController {
}
}
async refreshMachineHealth(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<void> {
async refreshMachineHealth(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<MachineHealth | undefined> {
try {
const health = await api.health(machineId);
this.setState({ machineStatuses: { ...this.getState().machineStatuses, [health.machineId]: health } });
return health;
} catch (error) {
this.setState({ error: String(error) });
return undefined;
}
}
@@ -108,17 +110,15 @@ export class MachineController {
private async selectInitialMachine(machines: Machine[], routeMachineId?: string): Promise<Machine | undefined> {
const requestedMachine = machines.find((machine) => machine.id === (routeMachineId ?? "local"));
if (requestedMachine?.kind !== "remote") return requestedMachine ?? this.localMachine(machines);
if (requestedMachine === undefined) return this.localMachine(machines);
if (requestedMachine.kind !== "remote") return requestedMachine;
const health = await this.safeRemoteHealth(requestedMachine);
if (health.ok) return requestedMachine;
const local = this.localMachine(machines);
this.setState({
error: `${requestedMachine.name} is offline; showing ${local?.name ?? "another machine"} instead.`,
machineStatuses: { ...this.getState().machineStatuses, [health.machineId]: health },
...(health.ok ? {} : { error: `${requestedMachine.name} is unavailable; reconnecting…` }),
});
return local ?? requestedMachine;
return requestedMachine;
}
private async safeRemoteHealth(machine: Machine): Promise<MachineHealth> {