fix(app): split local-daemon bootstrap into default vs configured override
EXPO_PUBLIC_LOCAL_DAEMON is a developer directive that the app must show the configured host even when disconnected, so the user can see and diagnose connection failures. The previous bootstrap conflated this with the default "auto-add localhost:6767 if a daemon answers" behavior, and the silent inner catch hid every failure mode. - Split bootstrapLocalhost into bootstrapDefaultLocalhost (probe-then- upsert, unchanged) and bootstrapConfiguredOverride (unconditional upsert with placeholder serverId local:<endpoint>). - Reconcile the placeholder to the daemon's real serverId in place when the probe receives server-info; rekey controllers and listeners. - Replace the silent catch with console.warn carrying endpoint and error. - On every override boot, purge any host owning the override connection under a non-matching serverId so a fresh dev daemon (rotated keypair) no longer leaves the registry pointing at a defunct host. - Prune cross-endpoint placeholders when EXPO_PUBLIC_LOCAL_DAEMON changes. Refs #637.
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
} from "@server/client/daemon-client";
|
||||
import {
|
||||
connectionFromListen,
|
||||
hostHasConnection,
|
||||
normalizeStoredHostProfile,
|
||||
upsertHostConnectionInProfiles,
|
||||
registryHasConnection,
|
||||
@@ -486,6 +487,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
export class HostRuntimeController {
|
||||
private host: HostProfile;
|
||||
private deps: HostRuntimeControllerDeps;
|
||||
private onReconcileServerId: ((oldId: string, newId: string) => void) | null;
|
||||
private connectionMachineState: HostRuntimeConnectionMachineState;
|
||||
private snapshot: HostRuntimeSnapshot;
|
||||
private listeners = new Set<() => void>();
|
||||
@@ -503,9 +505,14 @@ export class HostRuntimeController {
|
||||
private probeRequestVersion = 0;
|
||||
private probeCycleInFlight: Promise<void> | null = null;
|
||||
|
||||
constructor(input: { host: HostProfile; deps?: HostRuntimeControllerDeps }) {
|
||||
constructor(input: {
|
||||
host: HostProfile;
|
||||
deps?: HostRuntimeControllerDeps;
|
||||
onReconcileServerId?: (oldId: string, newId: string) => void;
|
||||
}) {
|
||||
this.host = input.host;
|
||||
this.deps = input.deps ?? createDefaultDeps();
|
||||
this.onReconcileServerId = input.onReconcileServerId ?? null;
|
||||
this.connectionMachineState = {
|
||||
tag: "booting",
|
||||
};
|
||||
@@ -846,10 +853,14 @@ export class HostRuntimeController {
|
||||
connection,
|
||||
});
|
||||
if (serverId !== this.host.serverId) {
|
||||
await client.close().catch(() => undefined);
|
||||
throw new Error(
|
||||
`Connection resolved to ${serverId}, expected ${this.host.serverId}.`,
|
||||
);
|
||||
if (isPlaceholderServerId(this.host.serverId) && this.onReconcileServerId) {
|
||||
this.onReconcileServerId(this.host.serverId, serverId);
|
||||
} else {
|
||||
await client.close().catch(() => undefined);
|
||||
throw new Error(
|
||||
`Connection resolved to ${serverId}, expected ${this.host.serverId}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
connectedClient = client;
|
||||
shouldCloseClient = true;
|
||||
@@ -1132,6 +1143,14 @@ export class HostRuntimeController {
|
||||
}
|
||||
}
|
||||
|
||||
adoptReconciledServerId(newServerId: string): void {
|
||||
this.host = { ...this.host, serverId: newServerId };
|
||||
this.snapshot = { ...this.snapshot, serverId: newServerId };
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
private resolveClientId(): Promise<string> {
|
||||
if (!this.clientIdPromise) {
|
||||
this.clientIdPromise = this.deps.getClientId().then((value) => {
|
||||
@@ -1144,10 +1163,32 @@ export class HostRuntimeController {
|
||||
}
|
||||
|
||||
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
||||
const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
|
||||
const LOCALHOST_FALLBACK_ENDPOINT = "localhost:6767";
|
||||
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
|
||||
const E2E_STORAGE_KEY = "@paseo:e2e";
|
||||
|
||||
function readConfiguredLocalDaemonOverride(): string | null {
|
||||
const value = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim();
|
||||
return value && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function placeholderServerIdForEndpoint(endpoint: string): string {
|
||||
return `local:${normalizeHostPort(endpoint)}`;
|
||||
}
|
||||
|
||||
function isPlaceholderServerId(serverId: string): boolean {
|
||||
return serverId.startsWith("local:");
|
||||
}
|
||||
|
||||
function rekeyMap<V>(map: Map<string, V>, oldKey: string, newKey: string): void {
|
||||
const value = map.get(oldKey);
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
map.delete(oldKey);
|
||||
map.set(newKey, value);
|
||||
}
|
||||
|
||||
export class HostRuntimeStore {
|
||||
private controllers = new Map<string, HostRuntimeController>();
|
||||
private serverListeners = new Map<string, Set<() => void>>();
|
||||
@@ -1193,6 +1234,11 @@ export class HostRuntimeStore {
|
||||
private async runBoot(): Promise<void> {
|
||||
await this.loadFromStorage();
|
||||
|
||||
const override = readConfiguredLocalDaemonOverride();
|
||||
if (override) {
|
||||
this.prunePlaceholderHostsForOtherEndpoints(override);
|
||||
}
|
||||
|
||||
let isE2E: string | null = null;
|
||||
try {
|
||||
isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY);
|
||||
@@ -1203,8 +1249,14 @@ export class HostRuntimeStore {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldUseDesktopDaemon()) {
|
||||
await this.bootstrapLocalhost();
|
||||
if (shouldUseDesktopDaemon()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (override) {
|
||||
this.bootstrapConfiguredOverride(override);
|
||||
} else {
|
||||
await this.bootstrapDefaultLocalhost();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1229,30 +1281,103 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
private async bootstrapLocalhost(): Promise<void> {
|
||||
try {
|
||||
const connection = connectionFromListen(DEFAULT_LOCALHOST_ENDPOINT);
|
||||
if (!connection || registryHasConnection(this.hosts, connection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { client, serverId, hostname } = await connectToDaemon(connection, {
|
||||
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await this.upsertHostConnection({
|
||||
serverId,
|
||||
label: hostname ?? undefined,
|
||||
connection,
|
||||
existingClient: client,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort bootstrap only
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[HostRuntime] Failed to bootstrap host connections", error);
|
||||
private async bootstrapDefaultLocalhost(): Promise<void> {
|
||||
const connection = connectionFromListen(LOCALHOST_FALLBACK_ENDPOINT);
|
||||
if (!connection || registryHasConnection(this.hosts, connection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { client, serverId, hostname } = await connectToDaemon(connection, {
|
||||
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await this.upsertHostConnection({
|
||||
serverId,
|
||||
label: hostname ?? undefined,
|
||||
connection,
|
||||
existingClient: client,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[HostRuntime] bootstrap probe failed", {
|
||||
endpoint: LOCALHOST_FALLBACK_ENDPOINT,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private bootstrapConfiguredOverride(endpoint: string): void {
|
||||
const connection = connectionFromListen(endpoint);
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
this.setHostsAndSync(remaining);
|
||||
void this.persistHosts();
|
||||
}
|
||||
|
||||
reconcileServerId(oldServerId: string, newServerId: string): void {
|
||||
if (oldServerId === newServerId) {
|
||||
return;
|
||||
}
|
||||
const controller = this.controllers.get(oldServerId);
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
if (this.controllers.has(newServerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
rekeyMap(this.controllers, oldServerId, newServerId);
|
||||
controller.adoptReconciledServerId(newServerId);
|
||||
|
||||
rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId);
|
||||
rekeyMap(this.agentDirectoryBootstrapInFlight, oldServerId, newServerId);
|
||||
|
||||
const listeners = this.serverListeners.get(oldServerId);
|
||||
if (listeners) {
|
||||
this.serverListeners.delete(oldServerId);
|
||||
const merged = this.serverListeners.get(newServerId) ?? new Set<() => void>();
|
||||
for (const listener of listeners) {
|
||||
merged.add(listener);
|
||||
}
|
||||
this.serverListeners.set(newServerId, merged);
|
||||
}
|
||||
|
||||
this.hosts = this.hosts.map((host) =>
|
||||
host.serverId === oldServerId
|
||||
? { ...host, serverId: newServerId, updatedAt: new Date().toISOString() }
|
||||
: host,
|
||||
);
|
||||
this.emitHostList();
|
||||
this.emit(newServerId);
|
||||
void this.persistHosts();
|
||||
}
|
||||
|
||||
async upsertDirectConnection(input: {
|
||||
@@ -1480,6 +1605,7 @@ export class HostRuntimeStore {
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: this.deps,
|
||||
onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId),
|
||||
});
|
||||
this.controllers.set(host.serverId, controller);
|
||||
this.lastConnectionStatusByServer.set(
|
||||
|
||||
Reference in New Issue
Block a user