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 <ogyshjsm@naver.com>
This commit is contained in:
paseo-bot[bot]
2026-10-22 09:08:33 +02:00
committed by GitHub
co-authored by 3ae3ae
parent 76b32544c6
commit 4673ab1903
2 changed files with 52 additions and 0 deletions
@@ -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<string, number | Error> = {
"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 = {
+8
View File
@@ -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;