From 4673ab1903cda8f34a99429f4764fa438f3364d8 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:08:33 +0200 Subject: [PATCH] fix(app): fall back to relay when the direct connection drops (#7616) The probe cycle reused the selected client's last heartbeat RTT even after that client disconnected. A dead direct connection kept reporting as available, so the app never switched to a reachable relay until it was restarted. Mark the selected connection unavailable while its client is not connected and let the existing failover pick another connection. Co-authored-by: 3ae3ae --- packages/app/src/runtime/host-runtime.test.ts | 44 +++++++++++++++++++ packages/app/src/runtime/host-runtime.ts | 8 ++++ 2 files changed, 52 insertions(+) diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 8f4d2acab5..0142548674 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -1011,6 +1011,50 @@ describe("HostRuntimeController", () => { expect(activeClient.latencyMeasurements()).toEqual([]); }); + it.each([12, null])( + "falls back to relay when the direct client disconnects with heartbeat RTT %s", + async (heartbeatRtt) => { + useHostRuntimeClock(); + const clients: FakeDaemonClient[] = []; + const latencies: Record = { + "direct:lan:6767": 12, + "relay:relay.paseo.sh:443": 65, + }; + const controller = new HostRuntimeController({ + host: makeHost(), + deps: makeDeps(latencies, clients), + }); + + await controller.start({ autoProbe: false }); + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767"); + expect(controller.getSnapshot().connectionStatus).toBe("online"); + + const directClient = clients[0]; + directClient.heartbeatReportsRtt(heartbeatRtt); + directClient.clearLatencyMeasurements(); + latencies["direct:lan:6767"] = new Error("LAN unreachable"); + directClient.setConnectionState({ status: "disconnected", reason: "network changed" }); + expect(controller.getSnapshot().connectionStatus).toBe("error"); + expect(directClient.getLastLivenessRttMs()).toBe(heartbeatRtt); + + await vi.advanceTimersByTimeAsync(2_000); + await controller.runProbeCycleNow(); + + const snapshot = controller.getSnapshot(); + expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443"); + expect(snapshot.connectionStatus).toBe("online"); + expect(snapshot.probeByConnectionId).toEqual( + new Map([ + ["direct:lan:6767", { status: "unavailable", latencyMs: null }], + ["relay:relay.paseo.sh:443", { status: "available", latencyMs: 65 }], + ]), + ); + expect(directClient.isDisposed()).toBe(true); + expect(directClient.latencyMeasurements()).toEqual([]); + await controller.stop(); + }, + ); + it("does not create a probe client for the selected connection while it reconnects", async () => { useHostRuntimeClock(); const relay: HostConnection = { diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 152e3331e5..fd8c1499d0 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1110,6 +1110,14 @@ export class HostRuntimeController { shouldCloseClient = shouldCloseClient && !activated; if (activeClient) { + if (activeClient.getConnectionState().status !== "connected") { + probeByConnectionId.set(connection.id, { + status: "unavailable", + latencyMs: null, + }); + publishProbeState(); + return; + } const rttMs = activeClient.getLastLivenessRttMs(); if (!this.isCurrentProbeRequest(requestVersion)) { return;