mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix dev override host bootstrap (#789)
This commit is contained in:
@@ -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() });
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<FetchAgentsOptions["sort"]> = [
|
||||
{ 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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function rekeyMap<V>(map: Map<string, V>, 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<string, HostRuntimeConnectionStatus>();
|
||||
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
|
||||
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
|
||||
private bootStarted = false;
|
||||
|
||||
constructor(input?: { deps?: HostRuntimeControllerDeps }) {
|
||||
@@ -1239,12 +1251,8 @@ export class HostRuntimeStore {
|
||||
}
|
||||
|
||||
private async runBoot(): Promise<void> {
|
||||
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<void> {
|
||||
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<HostProfile>;
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user