Recover stale daemon connections with liveness probes (#1124)
Use top-level WebSocket ping/pong for client liveness so session RPC timeouts stay scoped to the operation that timed out. Back off inactive connection probes while a host already has a healthy active connection.
This commit is contained in:
@@ -76,6 +76,10 @@ class FakeDaemonClient {
|
||||
return { rttMs: 0 };
|
||||
}
|
||||
|
||||
async checkLiveness(): Promise<{ rttMs: number }> {
|
||||
return this.ping();
|
||||
}
|
||||
|
||||
setReconnectEnabled(_enabled: boolean): void {}
|
||||
|
||||
setConnectionState(next: ConnectionState): void {
|
||||
@@ -620,6 +624,85 @@ describe("HostRuntimeController", () => {
|
||||
expect((initialClient as unknown as FakeDaemonClient | null)?.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("uses liveness probes instead of session RPC timeouts for active connection health", async () => {
|
||||
const relay: HostConnection = {
|
||||
id: "relay:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
useTls: true,
|
||||
daemonPublicKeyB64: "pk_test",
|
||||
};
|
||||
const host = makeHost({
|
||||
connections: [relay],
|
||||
preferredConnectionId: relay.id,
|
||||
});
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: makeDeps({ [relay.id]: 12 }, clients),
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
expect(controller.getSnapshot().connectionStatus).toBe("online");
|
||||
|
||||
const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient;
|
||||
activeClient.ping = async () => {
|
||||
throw new Error("Timeout waiting for message (5000ms)");
|
||||
};
|
||||
activeClient.checkLiveness = async () => ({ rttMs: 11 });
|
||||
clearProbeBackoff(controller);
|
||||
|
||||
await controller.runProbeCycleNow();
|
||||
|
||||
const snapshot = controller.getSnapshot();
|
||||
expect(snapshot.probeByConnectionId.get(relay.id)).toEqual({
|
||||
status: "available",
|
||||
latencyMs: 11,
|
||||
});
|
||||
expect(snapshot.connectionStatus).toBe("online");
|
||||
});
|
||||
|
||||
it("backs off inactive connection probes while a host is online", async () => {
|
||||
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
"direct:lan:6767": 10,
|
||||
"relay:relay.paseo.sh:443": 50,
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: makeDeps(latencies, clients),
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
const initialClientCount = clients.length;
|
||||
const initialRelayProbe = controller
|
||||
.getSnapshot()
|
||||
.probeByConnectionId.get("relay:relay.paseo.sh:443");
|
||||
|
||||
latencies["direct:lan:6767"] = 12;
|
||||
latencies["relay:relay.paseo.sh:443"] = 25;
|
||||
const lastProbedAt = (
|
||||
controller as unknown as {
|
||||
connectionLastProbedAt: Map<string, number>;
|
||||
}
|
||||
).connectionLastProbedAt;
|
||||
const now = performance.now();
|
||||
lastProbedAt.set("direct:lan:6767", now - 60_000);
|
||||
lastProbedAt.set("relay:relay.paseo.sh:443", now - 60_000);
|
||||
|
||||
await controller.runProbeCycleNow();
|
||||
|
||||
const snapshot = controller.getSnapshot();
|
||||
expect(clients.length).toBe(initialClientCount);
|
||||
expect(snapshot.probeByConnectionId.get("direct:lan:6767")).toEqual({
|
||||
status: "available",
|
||||
latencyMs: 12,
|
||||
});
|
||||
expect(snapshot.probeByConnectionId.get("relay:relay.paseo.sh:443")).toEqual(initialRelayProbe);
|
||||
});
|
||||
|
||||
it("switches only after the faster alternative wins consecutive probes", async () => {
|
||||
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
|
||||
@@ -142,6 +142,7 @@ export interface HostRuntimeStartOptions {
|
||||
const PROBE_TICK_MS = 2_000;
|
||||
const PROBE_STEADY_MS = 10_000;
|
||||
const PROBE_MAX_BACKOFF_MS = 30_000;
|
||||
const PROBE_INACTIVE_WHILE_ONLINE_MS = 120_000;
|
||||
const ADAPTIVE_SWITCH_THRESHOLD_MS = 40;
|
||||
const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
|
||||
const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200;
|
||||
@@ -434,11 +435,15 @@ function findConnectionById(host: HostProfile, connectionId: string | null): Hos
|
||||
function probeIntervalForConnection(
|
||||
firstSeenAt: number,
|
||||
isActiveOnline: boolean,
|
||||
hasActiveOnlineConnection: boolean,
|
||||
now: number,
|
||||
): number {
|
||||
if (isActiveOnline) {
|
||||
return PROBE_STEADY_MS;
|
||||
}
|
||||
if (hasActiveOnlineConnection) {
|
||||
return PROBE_INACTIVE_WHILE_ONLINE_MS;
|
||||
}
|
||||
const age = now - firstSeenAt;
|
||||
if (age < 10_000) return 2_000;
|
||||
if (age < 30_000) return 5_000;
|
||||
@@ -702,6 +707,7 @@ export class HostRuntimeController {
|
||||
const now = performance.now();
|
||||
const isOnline = this.snapshot.connectionStatus === "online";
|
||||
const activeConnectionId = this.snapshot.activeConnectionId;
|
||||
const hasActiveOnlineConnection = isOnline && activeConnectionId !== null;
|
||||
|
||||
const connectionsToProbe = this.host.connections.filter((connection) => {
|
||||
const lastProbed = this.connectionLastProbedAt.get(connection.id);
|
||||
@@ -710,7 +716,12 @@ export class HostRuntimeController {
|
||||
}
|
||||
const firstSeen = this.connectionFirstSeenAt.get(connection.id) ?? now;
|
||||
const isActiveOnline = isOnline && connection.id === activeConnectionId;
|
||||
const interval = probeIntervalForConnection(firstSeen, isActiveOnline, now);
|
||||
const interval = probeIntervalForConnection(
|
||||
firstSeen,
|
||||
isActiveOnline,
|
||||
hasActiveOnlineConnection,
|
||||
now,
|
||||
);
|
||||
return now - lastProbed >= interval;
|
||||
});
|
||||
|
||||
@@ -899,7 +910,7 @@ export class HostRuntimeController {
|
||||
const activated = await maybeActivateFirstAvailable(connection.id, connectedClient);
|
||||
shouldCloseClient = shouldCloseClient && !activated;
|
||||
|
||||
const { rttMs } = await connectedClient.ping({ timeoutMs: 5000 });
|
||||
const { rttMs } = await connectedClient.checkLiveness({ timeoutMs: 5000 });
|
||||
if (!this.isCurrentProbeRequest(requestVersion)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user