diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index a22880ed4..4d7269e71 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -65,6 +65,7 @@ import { polyfillCrypto } from "@/polyfills/crypto"; import { queryClient } from "@/query/query-client"; import { getHostRuntimeStore, + hasConfiguredLocalDaemonOverride, useHostMutations, useHostRuntimeClient, useHosts, @@ -326,6 +327,8 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { const anyOnlineHostServerId = useEarliestOnlineHostServerId(); const daemonStartError = useDaemonStartLastError(); const daemonStartIsRunning = useDaemonStartIsRunning(); + const waitForConfiguredLocalDaemon = + hasConfiguredLocalDaemonOverride() && !shouldUseDesktopDaemon(); const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false); useEffect(() => { @@ -333,6 +336,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { anyOnlineHostServerId || daemonStartError || daemonStartIsRunning || + waitForConfiguredLocalDaemon || hasGivenUpWaitingForHost ) { return; @@ -343,7 +347,13 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { return () => { clearTimeout(handle); }; - }, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]); + }, [ + anyOnlineHostServerId, + daemonStartError, + daemonStartIsRunning, + waitForConfiguredLocalDaemon, + hasGivenUpWaitingForHost, + ]); const retry = useCallback(() => { const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() }); diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index 93296b37a..c3831e917 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -10,7 +10,7 @@ import { serializeConnectionUri, serializeConnectionUriForStorage, } from "@/utils/daemon-endpoints"; -import { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon-connection"; +import { DaemonConnectionTestError } from "@/utils/test-daemon-connection"; import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; @@ -267,7 +267,7 @@ export interface AddHostModalProps { export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) { const { theme } = useUnistyles(); const daemons = useHosts(); - const { upsertDirectConnection } = useHostMutations(); + const { probeAndUpsertDirectConnection } = useHostMutations(); const isMobile = useIsCompactFormFactor(); const [isSaving, setIsSaving] = useState(false); @@ -336,22 +336,12 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod setIsSaving(true); setErrorMessage(""); - const { client, serverId, hostname } = await connectToDaemon({ - id: "probe", - type: "directTcp", + const { profile, serverId, hostname } = await probeAndUpsertDirectConnection({ endpoint: connection.endpoint, useTls: connection.useTls, ...(connection.password ? { password: connection.password } : {}), }); - await client.close().catch(() => undefined); const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId); - const profile = await upsertDirectConnection({ - serverId, - endpoint: connection.endpoint, - useTls: connection.useTls, - ...(connection.password ? { password: connection.password } : {}), - label: hostname ?? undefined, - }); onSaved?.({ profile, serverId, hostname, isNewHost }); handleClose(); @@ -381,7 +371,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod onSaved, password, port, - upsertDirectConnection, + probeAndUpsertDirectConnection, useTls, ]); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 682c3b39b..872703861 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -1596,6 +1596,85 @@ describe("HostRuntimeStore", () => { store.syncHosts([]); }); + it("probeAndUpsertConnection learns the real server id before storing a direct host", async () => { + const connection: HostConnection = { + id: "direct:lan:6767", + type: "directTcp", + endpoint: "lan:6767", + }; + const probeClient = makeConnectedProbeClient(5); + const seenProbeHosts: string[] = []; + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host, connection: probedConnection }) => { + seenProbeHosts.push(host.serverId); + expect(probedConnection).toEqual(connection); + return { + client: probeClient as unknown as DaemonClient, + serverId: "srv_real_direct", + hostname: "mbp", + }; + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + const result = await store.probeAndUpsertConnection({ connection }); + + expect(result.serverId).toBe("srv_real_direct"); + expect(result.hostname).toBe("mbp"); + expect(seenProbeHosts).toEqual([""]); + expect(probeClient.closeCalls).toBe(0); + expect(store.getHosts()).toMatchObject([ + { + serverId: "srv_real_direct", + label: "mbp", + connections: [connection], + }, + ]); + + store.syncHosts([]); + }); + + it("probeAndUpsertConnection replaces a matching placeholder host with the real server id", async () => { + const connection: HostConnection = { + id: "direct:lan:6767", + type: "directTcp", + endpoint: "lan:6767", + }; + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: "srv_real_direct", + hostname: "mbp", + }), + getClientId: async () => "cid_test_runtime", + }, + }); + ( + store as unknown as { + hosts: HostProfile[]; + } + ).hosts = [ + makeHost({ + serverId: "local:lan:6767", + label: "local:lan:6767", + connections: [connection], + preferredConnectionId: connection.id, + }), + ]; + + await store.probeAndUpsertConnection({ connection }); + + expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_real_direct"]); + expect(store.getHosts()[0]?.label).toBe("mbp"); + + store.syncHosts([]); + }); + it("uses the advertised hostname when adding a relay host from a pairing offer", async () => { const store = new HostRuntimeStore({ deps: { diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index b38233411..0851d838e 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -9,7 +9,6 @@ import { } from "@server/client/daemon-client"; import { connectionFromListen, - hostHasConnection, normalizeStoredHostProfile, upsertHostConnectionInProfiles, registryHasConnection, @@ -120,7 +119,11 @@ export interface HostRuntimeControllerDeps { clientId: string; runtimeGeneration: number; }) => DaemonClient; - connectToDaemon: (input: { host: HostProfile; connection: HostConnection }) => Promise<{ + connectToDaemon: (input: { + host: HostProfile; + connection: HostConnection; + timeoutMs?: number; + }) => Promise<{ client: DaemonClient; serverId: string; hostname: string | null; @@ -142,6 +145,7 @@ const PROBE_MAX_BACKOFF_MS = 30_000; const ADAPTIVE_SWITCH_THRESHOLD_MS = 40; const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3; const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200; +const CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS = 1_000; const DEFAULT_AGENT_DIRECTORY_SORT: NonNullable = [ { key: "updated_at", direction: "desc" }, @@ -485,8 +489,11 @@ function createDefaultDeps(): HostRuntimeControllerDeps { }, }); }, - connectToDaemon: ({ host, connection }) => - connectToDaemon(connection, { serverId: host.serverId }), + connectToDaemon: ({ host, connection, timeoutMs }) => + connectToDaemon(connection, { + ...(host.serverId ? { serverId: host.serverId } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }), getClientId: () => getOrCreateClientId(), }; } @@ -1179,14 +1186,18 @@ function readConfiguredLocalDaemonOverride(): string | null { return value && value.length > 0 ? value : null; } -function placeholderServerIdForEndpoint(endpoint: string): string { - return `local:${normalizeHostPort(endpoint)}`; +export function hasConfiguredLocalDaemonOverride(): boolean { + return readConfiguredLocalDaemonOverride() !== null; } function isPlaceholderServerId(serverId: string): boolean { return serverId.startsWith("local:"); } +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function rekeyMap(map: Map, oldKey: string, newKey: string): void { const value = map.get(oldKey); if (value === undefined) { @@ -1207,6 +1218,7 @@ export class HostRuntimeStore { private deps: HostRuntimeControllerDeps; private lastConnectionStatusByServer = new Map(); private agentDirectoryBootstrapInFlight = new Map>(); + private configuredOverrideBootstrapInFlight: Promise | null = null; private bootStarted = false; constructor(input?: { deps?: HostRuntimeControllerDeps }) { @@ -1239,12 +1251,8 @@ export class HostRuntimeStore { } private async runBoot(): Promise { - await this.loadFromStorage(); - const override = readConfiguredLocalDaemonOverride(); - if (override) { - this.prunePlaceholderHostsForOtherEndpoints(override); - } + await this.loadFromStorage(); let isE2E: string | null = null; try { @@ -1277,12 +1285,16 @@ export class HostRuntimeStore { if (!Array.isArray(parsed)) { return; } - const profiles = parsed + const normalizedProfiles = parsed .map((entry) => normalizeStoredHostProfile(entry)) .filter((entry): entry is HostProfile => entry !== null); + const profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId)); this.hosts = profiles; this.syncHosts(profiles); this.emitHostList(); + if (profiles.length !== normalizedProfiles.length) { + void this.persistHosts(); + } } catch (error) { console.error("[HostRuntime] Failed to load host registry from storage", error); } @@ -1295,15 +1307,9 @@ export class HostRuntimeStore { } try { - const { client, serverId, hostname } = await connectToDaemon(connection, { - timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS, - }); - - await this.upsertHostConnection({ - serverId, - label: hostname ?? undefined, + await this.probeAndUpsertConnection({ connection, - existingClient: client, + timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS, }); } catch (error) { console.warn("[HostRuntime] bootstrap probe failed", { @@ -1318,35 +1324,45 @@ export class HostRuntimeStore { if (!connection) { return; } - const placeholderId = placeholderServerIdForEndpoint(endpoint); - const staleHosts = this.hosts.filter( - (host) => host.serverId !== placeholderId && hostHasConnection(host, connection), - ); - for (const staleHost of staleHosts) { - void this.removeConnection(staleHost.serverId, connection.id); - } if (registryHasConnection(this.hosts, connection)) { return; } - void this.upsertHostConnection({ - serverId: placeholderId, - connection, - }); - } - - private prunePlaceholderHostsForOtherEndpoints(currentEndpoint: string): void { - const expectedServerId = placeholderServerIdForEndpoint(currentEndpoint); - const remaining = this.hosts.filter((host) => { - if (!isPlaceholderServerId(host.serverId)) { - return true; - } - return host.serverId === expectedServerId; - }); - if (remaining.length === this.hosts.length) { + if (this.configuredOverrideBootstrapInFlight) { return; } - this.setHostsAndSync(remaining); - void this.persistHosts(); + + const bootstrap = this.runConfiguredOverrideBootstrap(endpoint, connection).finally(() => { + if (this.configuredOverrideBootstrapInFlight === bootstrap) { + this.configuredOverrideBootstrapInFlight = null; + } + }); + this.configuredOverrideBootstrapInFlight = bootstrap; + } + + private async runConfiguredOverrideBootstrap( + endpoint: string, + connection: HostConnection, + ): Promise { + let attempt = 0; + while (!registryHasConnection(this.hosts, connection)) { + attempt += 1; + try { + await this.probeAndUpsertConnection({ + connection, + timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS, + }); + return; + } catch (error) { + if (attempt === 1 || attempt % 10 === 0) { + console.warn("[HostRuntime] configured bootstrap probe failed", { + endpoint, + attempt, + error, + }); + } + await delay(CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS); + } + } } reconcileServerId(oldServerId: string, newServerId: string): void { @@ -1411,6 +1427,57 @@ export class HostRuntimeStore { }); } + async probeAndUpsertConnection(input: { + connection: HostConnection; + label?: string; + timeoutMs?: number; + }): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { + if (input.connection.type === "relay") { + throw new Error("Cannot probe a relay connection without a server id."); + } + const probeHost: HostProfile = { + serverId: "", + label: input.label ?? input.connection.id, + lifecycle: {}, + connections: [input.connection], + preferredConnectionId: input.connection.id, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }; + const { client, serverId, hostname } = await this.deps.connectToDaemon({ + host: probeHost, + connection: input.connection, + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + }); + const profile = await this.upsertHostConnection({ + serverId, + label: input.label ?? hostname ?? undefined, + connection: input.connection, + existingClient: client, + }); + return { profile, serverId, hostname }; + } + + async probeAndUpsertDirectConnection(input: { + endpoint: string; + useTls?: boolean; + password?: string; + label?: string; + }): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { + const endpoint = normalizeHostPort(input.endpoint); + const password = input.password?.trim(); + return this.probeAndUpsertConnection({ + label: input.label, + connection: { + id: `direct:${endpoint}`, + type: "directTcp", + endpoint, + useTls: input.useTls ?? false, + ...(password ? { password } : {}), + }, + }); + } + async upsertRelayConnection(input: { serverId: string; relayEndpoint: string; @@ -1985,6 +2052,12 @@ export interface HostMutations { password?: string; label?: string; }) => Promise; + probeAndUpsertDirectConnection: (input: { + endpoint: string; + useTls?: boolean; + password?: string; + label?: string; + }) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>; upsertRelayConnection: (input: { serverId: string; relayEndpoint: string; @@ -2007,6 +2080,7 @@ export function useHostMutations(): HostMutations { return useMemo( () => ({ upsertDirectConnection: (input) => store.upsertDirectConnection(input), + probeAndUpsertDirectConnection: (input) => store.probeAndUpsertDirectConnection(input), upsertRelayConnection: (input) => store.upsertRelayConnection(input), upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label), upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),