Stop the double reconnect when returning to the app (#8855)

* fix(app): keep the selected connection when it reconnects during a probe cycle

When the app returns from the background with more than one saved
connection and the selected socket was closed meanwhile, the probe cycle
marks the selected connection unavailable at probe start, while its client
is still reconnecting. The alternative probe lands later, after the
selected client has already reconnected, and finalizing the cycle switched
to the alternative using that stale verdict. That disposed the live client
(rejecting the in-flight timeline catch-up) and reconnected a second time.

The cycle now reconciles the selected connection's verdict with its
client's current state before deciding to fail over. A client that is
still down at that point still fails over as before.

* test(app): cover reconnect with and without a heartbeat RTT
This commit is contained in:
Mohamed Boudra
2026-11-05 21:30:09 +07:00
committed by GitHub
parent 412550c36f
commit b0a8a5db6d
2 changed files with 169 additions and 0 deletions
@@ -1189,6 +1189,151 @@ describe("HostRuntimeController", () => {
expect(controller.getSnapshot().client).toBe(activeClient as unknown as DaemonClient);
});
it.each([40, null])(
"keeps the selected connection when it reconnects before an alternative probe resolves with heartbeat RTT %s",
async (reconnectedRtt) => {
useHostRuntimeClock();
const primary: HostConnection = {
id: "relay:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
daemonPublicKeyB64: "pk_test",
};
const alternative: HostConnection = {
id: "relay:relay-next.paseo.sh:443",
type: "relay",
relayEndpoint: "relay-next.paseo.sh:443",
daemonPublicKeyB64: "pk_test",
};
const host = makeHost({
connections: [primary, alternative],
preferredConnectionId: primary.id,
});
const activeClient = new FakeDaemonClient();
activeClient.setConnectionState({ status: "connected" });
activeClient.heartbeatReportsRtt(40);
// The first alternative probe (startup) answers immediately; the one that
// runs after the foreground is slow, like a relay handshake on mobile.
const slowAlternativeProbe = createDeferred<void>();
let alternativeProbes = 0;
const createdClients: FakeDaemonClient[] = [];
const controller = new HostRuntimeController({
host,
deps: {
createClient: () => {
const client = new FakeDaemonClient();
createdClients.push(client);
return client as unknown as DaemonClient;
},
connectToDaemon: async ({ connection }) => {
if (connection.id !== alternative.id) {
throw new Error(`unexpected probe for ${connection.id}`);
}
alternativeProbes += 1;
if (alternativeProbes > 1) await slowAlternativeProbe.promise;
return {
client: makeConnectedProbeClient(60) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
};
},
getClientId: async () => "cid_test_runtime",
},
});
await controller.start({
autoProbe: false,
initialConnection: {
connectionId: primary.id,
existingClient: activeClient as unknown as DaemonClient,
},
});
expect(controller.getSnapshot().activeConnectionId).toBe(primary.id);
// The relay closed the socket while the app was in the background.
activeClient.setConnectionState({ status: "disconnected", reason: "1012" });
await vi.advanceTimersByTimeAsync(130_000);
// Foreground: the probe cycle starts while the selected client is still down ...
const cycle = controller.runProbeCycleNow();
await vi.advanceTimersByTimeAsync(1_000);
expect(alternativeProbes).toBe(2);
// ... the selected client reconnects ...
activeClient.heartbeatReportsRtt(reconnectedRtt);
activeClient.setConnectionState({ status: "connected" });
expect(controller.getSnapshot().connectionStatus).toBe("online");
const statuses: string[] = [];
controller.subscribe(() => statuses.push(controller.getSnapshot().connectionStatus));
// ... and only then does the alternative probe come back.
slowAlternativeProbe.resolve();
await cycle;
// Once the selected connection is back online, nothing should take it down again.
expect(statuses.filter((status) => status !== "online")).toEqual([]);
expect(controller.getSnapshot().activeConnectionId).toBe(primary.id);
expect(controller.getSnapshot().client).toBe(activeClient as unknown as DaemonClient);
expect(activeClient.isDisposed()).toBe(false);
expect(createdClients).toEqual([]);
},
);
it("fails over when the selected connection stays down until an alternative probe resolves", async () => {
useHostRuntimeClock();
const primary: HostConnection = {
id: "relay:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
daemonPublicKeyB64: "pk_test",
};
const alternative: HostConnection = {
id: "relay:relay-next.paseo.sh:443",
type: "relay",
relayEndpoint: "relay-next.paseo.sh:443",
daemonPublicKeyB64: "pk_test",
};
const host = makeHost({
connections: [primary, alternative],
preferredConnectionId: primary.id,
});
const activeClient = new FakeDaemonClient();
activeClient.setConnectionState({ status: "connected" });
const slowAlternativeProbe = createDeferred<void>();
let alternativeProbes = 0;
const controller = new HostRuntimeController({
host,
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async () => {
alternativeProbes += 1;
if (alternativeProbes > 1) await slowAlternativeProbe.promise;
return {
client: makeConnectedProbeClient(60) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
};
},
getClientId: async () => "cid_test_runtime",
},
});
await controller.start({
autoProbe: false,
initialConnection: {
connectionId: primary.id,
existingClient: activeClient as unknown as DaemonClient,
},
});
activeClient.setConnectionState({ status: "disconnected", reason: "1012" });
await vi.advanceTimersByTimeAsync(130_000);
const cycle = controller.runProbeCycleNow();
await vi.advanceTimersByTimeAsync(1_000);
slowAlternativeProbe.resolve();
await cycle;
expect(controller.getSnapshot().activeConnectionId).toBe(alternative.id);
expect(activeClient.isDisposed()).toBe(true);
await controller.stop();
});
it("rejects probes that resolve to a different server id", async () => {
const host = makeHost({
serverId: "srv_old",
+24
View File
@@ -1021,6 +1021,9 @@ export class HostRuntimeController {
}
const currentActiveConnectionId = this.snapshot.activeConnectionId;
if (currentActiveConnectionId) {
this.reconcileActiveProbeWithClient(currentActiveConnectionId, probeByConnectionId);
}
const activeProbe = currentActiveConnectionId
? probeByConnectionId.get(currentActiveConnectionId)
: null;
@@ -1211,6 +1214,27 @@ export class HostRuntimeController {
});
}
// The active connection is probed through its own client, which may still be
// reconnecting when the probe starts. A verdict taken then is stale if the client
// has since connected, so the decision uses the client's current state.
private reconcileActiveProbeWithClient(
connectionId: string,
probeByConnectionId: Map<string, ConnectionProbeState>,
): void {
if (probeByConnectionId.get(connectionId)?.status !== "unavailable") return;
if (this.authRejectedConnectionIds.has(connectionId)) return;
const client = this.snapshot.client;
if (!client || client.getConnectionState().status !== "connected") return;
const rttMs = client.getLastLivenessRttMs();
probeByConnectionId.set(
connectionId,
rttMs === null
? { status: "pending", latencyMs: null }
: { status: "available", latencyMs: rttMs },
);
this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) });
}
private isConnectionUnavailable(
connectionId: string,
probe: ConnectionProbeState | null | undefined,