refactor: extract host runtime controller for connection management
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HostRuntimeSnapshot } from "./host-runtime";
|
||||
import { toDaemonConnectionUpdateFromRuntime } from "./host-runtime-bridge";
|
||||
|
||||
const DIRECT_CONNECTION = {
|
||||
type: "direct" as const,
|
||||
endpoint: "lan:6767",
|
||||
display: "lan:6767",
|
||||
};
|
||||
|
||||
function makeSnapshot(
|
||||
input: Partial<HostRuntimeSnapshot>
|
||||
): HostRuntimeSnapshot {
|
||||
return {
|
||||
serverId: "srv_test",
|
||||
activeConnectionId: "direct:lan:6767",
|
||||
activeConnection: DIRECT_CONNECTION,
|
||||
connectionStatus: "idle",
|
||||
lastError: null,
|
||||
lastOnlineAt: null,
|
||||
probeByConnectionId: new Map(),
|
||||
clientGeneration: 0,
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
describe("toDaemonConnectionUpdateFromRuntime", () => {
|
||||
it("maps online snapshots to online updates with the same active connection", () => {
|
||||
const snapshot = makeSnapshot({
|
||||
connectionStatus: "online",
|
||||
lastOnlineAt: "2026-02-19T20:00:00.000Z",
|
||||
});
|
||||
|
||||
const update = toDaemonConnectionUpdateFromRuntime(snapshot);
|
||||
|
||||
expect(update).toEqual({
|
||||
status: "online",
|
||||
activeConnection: DIRECT_CONNECTION,
|
||||
lastOnlineAt: "2026-02-19T20:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps error snapshots to error updates with the same error message", () => {
|
||||
const snapshot = makeSnapshot({
|
||||
connectionStatus: "error",
|
||||
lastError: "transport closed",
|
||||
lastOnlineAt: "2026-02-19T20:00:00.000Z",
|
||||
});
|
||||
|
||||
const update = toDaemonConnectionUpdateFromRuntime(snapshot);
|
||||
|
||||
expect(update).toEqual({
|
||||
status: "error",
|
||||
activeConnection: DIRECT_CONNECTION,
|
||||
lastError: "transport closed",
|
||||
lastOnlineAt: "2026-02-19T20:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps missing runtime snapshots to offline updates", () => {
|
||||
const update = toDaemonConnectionUpdateFromRuntime(null);
|
||||
|
||||
expect(update).toEqual({
|
||||
status: "offline",
|
||||
activeConnection: null,
|
||||
lastError: null,
|
||||
lastOnlineAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ConnectionStateUpdate } from "@/contexts/daemon-connections-context";
|
||||
import type { HostRuntimeSnapshot } from "./host-runtime";
|
||||
|
||||
export function toDaemonConnectionUpdateFromRuntime(
|
||||
snapshot: HostRuntimeSnapshot | null
|
||||
): ConnectionStateUpdate {
|
||||
if (!snapshot) {
|
||||
return {
|
||||
status: "offline",
|
||||
activeConnection: null,
|
||||
lastError: null,
|
||||
lastOnlineAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const activeConnection = snapshot.activeConnection;
|
||||
switch (snapshot.connectionStatus) {
|
||||
case "idle":
|
||||
return { status: "idle" };
|
||||
case "connecting":
|
||||
return {
|
||||
status: "connecting",
|
||||
activeConnection,
|
||||
lastOnlineAt: snapshot.lastOnlineAt,
|
||||
};
|
||||
case "online":
|
||||
return {
|
||||
status: "online",
|
||||
activeConnection,
|
||||
lastOnlineAt: snapshot.lastOnlineAt ?? new Date().toISOString(),
|
||||
};
|
||||
case "offline":
|
||||
return {
|
||||
status: "offline",
|
||||
activeConnection,
|
||||
lastError: null,
|
||||
lastOnlineAt: snapshot.lastOnlineAt,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
status: "error",
|
||||
activeConnection,
|
||||
lastError: snapshot.lastError ?? "Connection error",
|
||||
lastOnlineAt: snapshot.lastOnlineAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
|
||||
import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import {
|
||||
HostRuntimeController,
|
||||
type HostRuntimeControllerDeps,
|
||||
} from "./host-runtime";
|
||||
|
||||
class FakeDaemonClient {
|
||||
private state: ConnectionState = { status: "idle" };
|
||||
private listeners = new Set<(status: ConnectionState) => void>();
|
||||
private error: string | null = null;
|
||||
public connectCalls = 0;
|
||||
public closeCalls = 0;
|
||||
public ensureConnectedCalls = 0;
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.connectCalls += 1;
|
||||
this.setConnectionState({ status: "connected" });
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCalls += 1;
|
||||
this.setConnectionState({ status: "disconnected", reason: "client_closed" });
|
||||
}
|
||||
|
||||
ensureConnected(): void {
|
||||
this.ensureConnectedCalls += 1;
|
||||
if (this.state.status !== "connected") {
|
||||
this.setConnectionState({ status: "connected" });
|
||||
}
|
||||
}
|
||||
|
||||
getConnectionState(): ConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
subscribeConnectionStatus(
|
||||
listener: (status: ConnectionState) => void
|
||||
): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.state);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
get lastError(): string | null {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
setConnectionState(next: ConnectionState): void {
|
||||
this.state = next;
|
||||
if (next.status === "disconnected") {
|
||||
this.error = next.reason ?? this.error;
|
||||
}
|
||||
for (const listener of this.listeners) {
|
||||
listener(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeHost(input?: Partial<HostProfile>): HostProfile {
|
||||
const direct: HostConnection = {
|
||||
id: "direct:lan:6767",
|
||||
type: "direct",
|
||||
endpoint: "lan:6767",
|
||||
};
|
||||
const relay: HostConnection = {
|
||||
id: "relay:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
daemonPublicKeyB64: "pk_test",
|
||||
};
|
||||
|
||||
return {
|
||||
serverId: input?.serverId ?? "srv_test",
|
||||
label: input?.label ?? "test host",
|
||||
connections: input?.connections ?? [direct, relay],
|
||||
preferredConnectionId: input?.preferredConnectionId ?? direct.id,
|
||||
createdAt: input?.createdAt ?? new Date(0).toISOString(),
|
||||
updatedAt: input?.updatedAt ?? new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
latencyByConnectionId: Record<string, number | Error>,
|
||||
createdClients: FakeDaemonClient[]
|
||||
): HostRuntimeControllerDeps {
|
||||
return {
|
||||
createClient: () => {
|
||||
const client = new FakeDaemonClient();
|
||||
createdClients.push(client);
|
||||
return client as unknown as DaemonClient;
|
||||
},
|
||||
measureLatency: async ({ connection }) => {
|
||||
const value = latencyByConnectionId[connection.id];
|
||||
if (value instanceof Error) {
|
||||
throw value;
|
||||
}
|
||||
if (typeof value !== "number") {
|
||||
throw new Error(`missing latency for ${connection.id}`);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("HostRuntimeController", () => {
|
||||
it("selects the lowest-latency connection on startup", async () => {
|
||||
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
"direct:lan:6767": 82,
|
||||
"relay:relay.paseo.sh:443": 18,
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: makeDeps(latencies, clients),
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
|
||||
const snapshot = controller.getSnapshot();
|
||||
expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
||||
expect(snapshot.connectionStatus).toBe("online");
|
||||
expect(clients).toHaveLength(1);
|
||||
expect(clients[0]?.connectCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("fails over when active connection becomes unavailable", async () => {
|
||||
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
"direct:lan:6767": 15,
|
||||
"relay:relay.paseo.sh:443": 55,
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: makeDeps(latencies, clients),
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
expect(clients).toHaveLength(1);
|
||||
|
||||
latencies["direct:lan:6767"] = new Error("direct unavailable");
|
||||
latencies["relay:relay.paseo.sh:443"] = 42;
|
||||
await controller.runProbeCycleNow();
|
||||
|
||||
const snapshot = controller.getSnapshot();
|
||||
expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
||||
expect(snapshot.connectionStatus).toBe("online");
|
||||
expect(clients).toHaveLength(2);
|
||||
expect(clients[0]?.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("switches only after the faster alternative wins consecutive probes", async () => {
|
||||
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
"direct:lan:6767": 15,
|
||||
"relay:relay.paseo.sh:443": 60,
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: makeDeps(latencies, clients),
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
latencies["direct:lan:6767"] = 95;
|
||||
latencies["relay:relay.paseo.sh:443"] = 30;
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
||||
expect(clients).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not switch on a transient latency spike", async () => {
|
||||
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
"direct:lan:6767": 15,
|
||||
"relay:relay.paseo.sh:443": 80,
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: makeDeps(latencies, clients),
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
latencies["direct:lan:6767"] = 100;
|
||||
latencies["relay:relay.paseo.sh:443"] = 20;
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
latencies["direct:lan:6767"] = 20;
|
||||
latencies["relay:relay.paseo.sh:443"] = 90;
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
latencies["direct:lan:6767"] = 100;
|
||||
latencies["relay:relay.paseo.sh:443"] = 20;
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
||||
});
|
||||
|
||||
it("exposes one snapshot with active connection and status from same source", async () => {
|
||||
const host = makeHost();
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
"direct:lan:6767": 12,
|
||||
"relay:relay.paseo.sh:443": 65,
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: makeDeps(latencies, clients),
|
||||
});
|
||||
|
||||
const observed = new Array<ReturnType<typeof controller.getSnapshot>>();
|
||||
const unsubscribe = controller.subscribe(() => {
|
||||
observed.push(controller.getSnapshot());
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
|
||||
clients[0]?.setConnectionState({
|
||||
status: "disconnected",
|
||||
reason: "transport closed",
|
||||
});
|
||||
|
||||
const latest = observed[observed.length - 1];
|
||||
expect(latest?.activeConnectionId).toBe("direct:lan:6767");
|
||||
expect(latest?.connectionStatus).toBe("error");
|
||||
expect(latest?.lastError).toBe("transport closed");
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,577 @@
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { DaemonClient, type ConnectionState } from "@server/client/daemon-client";
|
||||
import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context";
|
||||
import type { ActiveConnection } from "@/contexts/daemon-connections-context";
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
buildRelayWebSocketUrl,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
|
||||
import {
|
||||
selectBestConnection,
|
||||
type ConnectionCandidate,
|
||||
type ConnectionProbeState,
|
||||
} from "@/utils/connection-selection";
|
||||
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
|
||||
|
||||
export type HostRuntimeConnectionStatus =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "online"
|
||||
| "offline"
|
||||
| "error";
|
||||
|
||||
export type HostRuntimeSnapshot = {
|
||||
serverId: string;
|
||||
activeConnectionId: string | null;
|
||||
activeConnection: ActiveConnection | null;
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
lastError: string | null;
|
||||
lastOnlineAt: string | null;
|
||||
probeByConnectionId: Map<string, ConnectionProbeState>;
|
||||
clientGeneration: number;
|
||||
};
|
||||
|
||||
export type HostRuntimeControllerDeps = {
|
||||
createClient: (input: {
|
||||
host: HostProfile;
|
||||
connection: HostConnection;
|
||||
}) => DaemonClient;
|
||||
measureLatency: (input: {
|
||||
host: HostProfile;
|
||||
connection: HostConnection;
|
||||
}) => Promise<number>;
|
||||
};
|
||||
|
||||
export type HostRuntimeStartOptions = {
|
||||
autoProbe?: boolean;
|
||||
};
|
||||
|
||||
const PROBE_INTERVAL_MS = 10_000;
|
||||
const ADAPTIVE_SWITCH_THRESHOLD_MS = 40;
|
||||
const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
|
||||
|
||||
function toActiveConnection(connection: HostConnection): ActiveConnection {
|
||||
if (connection.type === "direct") {
|
||||
return {
|
||||
type: "direct",
|
||||
endpoint: connection.endpoint,
|
||||
display: connection.endpoint,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "relay",
|
||||
endpoint: connection.relayEndpoint,
|
||||
display: "relay",
|
||||
};
|
||||
}
|
||||
|
||||
function mapClientConnectionState(input: {
|
||||
state: ConnectionState;
|
||||
lastError: string | null;
|
||||
}): Pick<HostRuntimeSnapshot, "connectionStatus" | "lastError" | "lastOnlineAt"> {
|
||||
const { state, lastError } = input;
|
||||
if (state.status === "connected") {
|
||||
return {
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
lastOnlineAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
if (state.status === "connecting") {
|
||||
return {
|
||||
connectionStatus: "connecting",
|
||||
lastError: null,
|
||||
lastOnlineAt: null,
|
||||
};
|
||||
}
|
||||
if (state.status === "idle") {
|
||||
return {
|
||||
connectionStatus: "idle",
|
||||
lastError: null,
|
||||
lastOnlineAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const reason = state.reason ?? lastError ?? null;
|
||||
if (!reason || reason === "client_closed") {
|
||||
return {
|
||||
connectionStatus: "offline",
|
||||
lastError: null,
|
||||
lastOnlineAt: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
connectionStatus: "error",
|
||||
lastError: reason,
|
||||
lastOnlineAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildConnectionCandidates(host: HostProfile): ConnectionCandidate[] {
|
||||
return host.connections.map((connection) => ({
|
||||
connectionId: connection.id,
|
||||
connection,
|
||||
}));
|
||||
}
|
||||
|
||||
function findConnectionById(
|
||||
host: HostProfile,
|
||||
connectionId: string | null
|
||||
): HostConnection | null {
|
||||
if (!connectionId) {
|
||||
return null;
|
||||
}
|
||||
return host.connections.find((connection) => connection.id === connectionId) ?? null;
|
||||
}
|
||||
|
||||
function selectInitialConnectionId(host: HostProfile): string | null {
|
||||
if (host.connections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return host.connections[0]?.id ?? null;
|
||||
}
|
||||
|
||||
function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
return {
|
||||
createClient: ({ host, connection }) => {
|
||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||
const base = {
|
||||
suppressSendErrors: true,
|
||||
...(tauriTransportFactory
|
||||
? { transportFactory: tauriTransportFactory }
|
||||
: {}),
|
||||
};
|
||||
if (connection.type === "direct") {
|
||||
return new DaemonClient({
|
||||
...base,
|
||||
url: buildDaemonWebSocketUrl(connection.endpoint),
|
||||
});
|
||||
}
|
||||
return new DaemonClient({
|
||||
...base,
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint: connection.relayEndpoint,
|
||||
serverId: host.serverId,
|
||||
}),
|
||||
e2ee: {
|
||||
enabled: true,
|
||||
daemonPublicKeyB64: connection.daemonPublicKeyB64,
|
||||
},
|
||||
});
|
||||
},
|
||||
measureLatency: ({ host, connection }) =>
|
||||
measureConnectionLatency(connection, { serverId: host.serverId }),
|
||||
};
|
||||
}
|
||||
|
||||
export class HostRuntimeController {
|
||||
private host: HostProfile;
|
||||
private deps: HostRuntimeControllerDeps;
|
||||
private snapshot: HostRuntimeSnapshot;
|
||||
private listeners = new Set<() => void>();
|
||||
private activeClient: DaemonClient | null = null;
|
||||
private unsubscribeClientStatus: (() => void) | null = null;
|
||||
private probeIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||
private started = false;
|
||||
private switchCandidateConnectionId: string | null = null;
|
||||
private switchCandidateHitCount = 0;
|
||||
|
||||
constructor(input: {
|
||||
host: HostProfile;
|
||||
deps?: HostRuntimeControllerDeps;
|
||||
}) {
|
||||
this.host = input.host;
|
||||
this.deps = input.deps ?? createDefaultDeps();
|
||||
this.snapshot = {
|
||||
serverId: this.host.serverId,
|
||||
activeConnectionId: null,
|
||||
activeConnection: null,
|
||||
connectionStatus: "idle",
|
||||
lastError: null,
|
||||
lastOnlineAt: null,
|
||||
probeByConnectionId: new Map(),
|
||||
clientGeneration: 0,
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot(): HostRuntimeSnapshot {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
getClient(): DaemonClient | null {
|
||||
return this.activeClient;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async start(options?: HostRuntimeStartOptions): Promise<void> {
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
await this.runProbeCycleNow();
|
||||
|
||||
if (!this.snapshot.activeConnectionId) {
|
||||
const fallbackConnectionId = selectInitialConnectionId(this.host);
|
||||
if (fallbackConnectionId) {
|
||||
await this.switchToConnection(fallbackConnectionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.autoProbe !== false && PROBE_INTERVAL_MS > 0) {
|
||||
this.probeIntervalHandle = setInterval(() => {
|
||||
void this.runProbeCycleNow();
|
||||
}, PROBE_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.started = false;
|
||||
if (this.probeIntervalHandle) {
|
||||
clearInterval(this.probeIntervalHandle);
|
||||
this.probeIntervalHandle = null;
|
||||
}
|
||||
if (this.unsubscribeClientStatus) {
|
||||
this.unsubscribeClientStatus();
|
||||
this.unsubscribeClientStatus = null;
|
||||
}
|
||||
if (this.activeClient) {
|
||||
const prev = this.activeClient;
|
||||
this.activeClient = null;
|
||||
await prev.close().catch(() => undefined);
|
||||
}
|
||||
this.updateSnapshot({
|
||||
activeConnectionId: null,
|
||||
activeConnection: null,
|
||||
connectionStatus: "offline",
|
||||
lastError: null,
|
||||
lastOnlineAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
async updateHost(host: HostProfile): Promise<void> {
|
||||
this.host = host;
|
||||
const activeConnectionId = this.snapshot.activeConnectionId;
|
||||
const activeConnection = findConnectionById(host, activeConnectionId);
|
||||
if (!activeConnection) {
|
||||
await this.runProbeCycleNow();
|
||||
if (this.snapshot.activeConnectionId) {
|
||||
return;
|
||||
}
|
||||
const fallbackConnectionId = selectInitialConnectionId(host);
|
||||
if (!fallbackConnectionId) {
|
||||
await this.stop();
|
||||
return;
|
||||
}
|
||||
await this.switchToConnection(fallbackConnectionId);
|
||||
}
|
||||
}
|
||||
|
||||
ensureConnected(): void {
|
||||
this.activeClient?.ensureConnected();
|
||||
}
|
||||
|
||||
async runProbeCycleNow(): Promise<void> {
|
||||
if (this.host.connections.length === 0) {
|
||||
this.updateSnapshot({
|
||||
probeByConnectionId: new Map(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const probeByConnectionId = new Map<string, ConnectionProbeState>();
|
||||
await Promise.all(
|
||||
this.host.connections.map(async (connection) => {
|
||||
try {
|
||||
const latencyMs = await this.deps.measureLatency({
|
||||
host: this.host,
|
||||
connection,
|
||||
});
|
||||
probeByConnectionId.set(connection.id, {
|
||||
status: "available",
|
||||
latencyMs,
|
||||
});
|
||||
} catch {
|
||||
probeByConnectionId.set(connection.id, {
|
||||
status: "unavailable",
|
||||
latencyMs: null,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.updateSnapshot({ probeByConnectionId });
|
||||
|
||||
const activeConnectionId = this.snapshot.activeConnectionId;
|
||||
const activeProbe = activeConnectionId
|
||||
? probeByConnectionId.get(activeConnectionId)
|
||||
: null;
|
||||
|
||||
if (!activeConnectionId || !findConnectionById(this.host, activeConnectionId)) {
|
||||
const nextConnectionId = selectBestConnection({
|
||||
candidates: buildConnectionCandidates(this.host),
|
||||
probeByConnectionId,
|
||||
});
|
||||
if (nextConnectionId) {
|
||||
await this.switchToConnection(nextConnectionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeProbe?.status === "unavailable") {
|
||||
const nextConnectionId = selectBestConnection({
|
||||
candidates: buildConnectionCandidates(this.host),
|
||||
probeByConnectionId,
|
||||
});
|
||||
if (nextConnectionId && nextConnectionId !== activeConnectionId) {
|
||||
await this.switchToConnection(nextConnectionId);
|
||||
}
|
||||
this.switchCandidateConnectionId = null;
|
||||
this.switchCandidateHitCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeProbe && activeProbe.status === "available") {
|
||||
const available = Array.from(probeByConnectionId.entries())
|
||||
.filter(([, probe]) => probe.status === "available")
|
||||
.map(([connectionId, probe]) => ({
|
||||
connectionId,
|
||||
latencyMs: (probe as { status: "available"; latencyMs: number }).latencyMs,
|
||||
}))
|
||||
.sort((left, right) => left.latencyMs - right.latencyMs);
|
||||
|
||||
const fastest = available[0] ?? null;
|
||||
if (!fastest || fastest.connectionId === activeConnectionId) {
|
||||
this.switchCandidateConnectionId = null;
|
||||
this.switchCandidateHitCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const activeLatency = activeProbe.latencyMs;
|
||||
const improvement = activeLatency - fastest.latencyMs;
|
||||
if (improvement < ADAPTIVE_SWITCH_THRESHOLD_MS) {
|
||||
this.switchCandidateConnectionId = null;
|
||||
this.switchCandidateHitCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.switchCandidateConnectionId === fastest.connectionId) {
|
||||
this.switchCandidateHitCount += 1;
|
||||
} else {
|
||||
this.switchCandidateConnectionId = fastest.connectionId;
|
||||
this.switchCandidateHitCount = 1;
|
||||
}
|
||||
|
||||
if (
|
||||
this.switchCandidateHitCount >=
|
||||
ADAPTIVE_SWITCH_CONSECUTIVE_PROBES
|
||||
) {
|
||||
this.switchCandidateConnectionId = null;
|
||||
this.switchCandidateHitCount = 0;
|
||||
await this.switchToConnection(fastest.connectionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateSnapshot(
|
||||
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>
|
||||
): void {
|
||||
const next: HostRuntimeSnapshot = {
|
||||
...this.snapshot,
|
||||
...patch,
|
||||
serverId: this.host.serverId,
|
||||
};
|
||||
this.snapshot = next;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
private async switchToConnection(connectionId: string): Promise<void> {
|
||||
const connection = findConnectionById(this.host, connectionId);
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.unsubscribeClientStatus) {
|
||||
this.unsubscribeClientStatus();
|
||||
this.unsubscribeClientStatus = null;
|
||||
}
|
||||
if (this.activeClient) {
|
||||
const previousClient = this.activeClient;
|
||||
this.activeClient = null;
|
||||
await previousClient.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
const client = this.deps.createClient({
|
||||
host: this.host,
|
||||
connection,
|
||||
});
|
||||
this.activeClient = client;
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
serverId: this.host.serverId,
|
||||
activeConnectionId: connection.id,
|
||||
activeConnection: toActiveConnection(connection),
|
||||
connectionStatus: "connecting",
|
||||
lastError: null,
|
||||
lastOnlineAt: null,
|
||||
clientGeneration: this.snapshot.clientGeneration + 1,
|
||||
};
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
|
||||
this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => {
|
||||
const mapped = mapClientConnectionState({
|
||||
state,
|
||||
lastError: client.lastError,
|
||||
});
|
||||
this.updateSnapshot(mapped);
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.updateSnapshot({
|
||||
connectionStatus: "error",
|
||||
lastError: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class HostRuntimeStore {
|
||||
private controllers = new Map<string, HostRuntimeController>();
|
||||
private serverListeners = new Map<string, Set<() => void>>();
|
||||
private deps: HostRuntimeControllerDeps;
|
||||
|
||||
constructor(input?: {
|
||||
deps?: HostRuntimeControllerDeps;
|
||||
}) {
|
||||
this.deps = input?.deps ?? createDefaultDeps();
|
||||
}
|
||||
|
||||
syncHosts(hosts: HostProfile[]): void {
|
||||
const nextIds = new Set(hosts.map((host) => host.serverId));
|
||||
for (const [serverId, controller] of this.controllers) {
|
||||
if (nextIds.has(serverId)) {
|
||||
continue;
|
||||
}
|
||||
this.controllers.delete(serverId);
|
||||
void controller.stop();
|
||||
this.emit(serverId);
|
||||
}
|
||||
|
||||
for (const host of hosts) {
|
||||
const existing = this.controllers.get(host.serverId);
|
||||
if (existing) {
|
||||
void existing.updateHost(host);
|
||||
continue;
|
||||
}
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: this.deps,
|
||||
});
|
||||
this.controllers.set(host.serverId, controller);
|
||||
controller.subscribe(() => {
|
||||
this.emit(host.serverId);
|
||||
});
|
||||
void controller.start();
|
||||
this.emit(host.serverId);
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot(serverId: string): HostRuntimeSnapshot | null {
|
||||
return this.controllers.get(serverId)?.getSnapshot() ?? null;
|
||||
}
|
||||
|
||||
getClient(serverId: string): DaemonClient | null {
|
||||
return this.controllers.get(serverId)?.getClient() ?? null;
|
||||
}
|
||||
|
||||
subscribe(serverId: string, listener: () => void): () => void {
|
||||
const existing = this.serverListeners.get(serverId) ?? new Set<() => void>();
|
||||
existing.add(listener);
|
||||
this.serverListeners.set(serverId, existing);
|
||||
return () => {
|
||||
const set = this.serverListeners.get(serverId);
|
||||
if (!set) {
|
||||
return;
|
||||
}
|
||||
set.delete(listener);
|
||||
if (set.size === 0) {
|
||||
this.serverListeners.delete(serverId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
ensureConnectedAll(): void {
|
||||
for (const controller of this.controllers.values()) {
|
||||
controller.ensureConnected();
|
||||
}
|
||||
}
|
||||
|
||||
runProbeCycleNow(serverId?: string): Promise<void> {
|
||||
if (serverId) {
|
||||
return this.controllers.get(serverId)?.runProbeCycleNow() ?? Promise.resolve();
|
||||
}
|
||||
return Promise.all(
|
||||
Array.from(this.controllers.values(), (controller) =>
|
||||
controller.runProbeCycleNow()
|
||||
)
|
||||
).then(() => undefined);
|
||||
}
|
||||
|
||||
private emit(serverId: string): void {
|
||||
const listeners = this.serverListeners.get(serverId);
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let singletonHostRuntimeStore: HostRuntimeStore | null = null;
|
||||
|
||||
export function getHostRuntimeStore(): HostRuntimeStore {
|
||||
if (!singletonHostRuntimeStore) {
|
||||
singletonHostRuntimeStore = new HostRuntimeStore();
|
||||
}
|
||||
return singletonHostRuntimeStore;
|
||||
}
|
||||
|
||||
export function useHostRuntimeSnapshot(
|
||||
serverId: string
|
||||
): HostRuntimeSnapshot | null {
|
||||
const store = getHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
||||
() => store.getSnapshot(serverId),
|
||||
() => store.getSnapshot(serverId)
|
||||
);
|
||||
}
|
||||
|
||||
export function useHostRuntimeSession(serverId: string): {
|
||||
snapshot: HostRuntimeSnapshot | null;
|
||||
client: DaemonClient | null;
|
||||
} {
|
||||
const store = getHostRuntimeStore();
|
||||
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||
return useMemo(
|
||||
() => ({
|
||||
snapshot,
|
||||
client: store.getClient(serverId),
|
||||
}),
|
||||
[serverId, snapshot, store]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user