Reshape desktop bootstrap into independent services
HostRuntime self-boots via boot() and emits state through existing subscribeAll plumbing. DaemonStartService runs in parallel: on success it upserts the connection, which HostRuntime's syncHosts already wires into a controller; on failure it emits an error. The bootstrap no longer awaits daemon-start before letting saved-host controllers connect. index.tsx resolves the redirect from useEarliestOnlineHostServerId + useDaemonStartLastError subscriptions, not from a procedural promise. The splash error is derived state: splashError = !anyOnlineHostServerId ? daemonStartError : null. Retry calls only DaemonStartService.start(). Deletes initializeHostRuntime, setupDesktopManagedConnection, addConnectionFromListenAndWaitForOnline, bootstrapDesktop on the store, waitForAnyConnectionOnline (now unused in production), and the phase enum. Net -623 lines.
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DaemonStartService } from "./daemon-start-service";
|
||||
import type { HostRuntimeStore } from "./host-runtime";
|
||||
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
interface RecordedUpsert {
|
||||
listenAddress: string;
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
}
|
||||
|
||||
function createFakeStore(): {
|
||||
store: Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
||||
upserts: RecordedUpsert[];
|
||||
} {
|
||||
const upserts: RecordedUpsert[] = [];
|
||||
const store = {
|
||||
upsertConnectionFromListen: async (input: RecordedUpsert) => {
|
||||
upserts.push(input);
|
||||
return {} as Awaited<ReturnType<HostRuntimeStore["upsertConnectionFromListen"]>>;
|
||||
},
|
||||
};
|
||||
return { store, upserts };
|
||||
}
|
||||
|
||||
function makeStatus(overrides: Partial<DesktopDaemonStatus> = {}): DesktopDaemonStatus {
|
||||
return {
|
||||
serverId: "srv_desktop",
|
||||
status: "running",
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "desktop",
|
||||
pid: 1234,
|
||||
home: "/home",
|
||||
version: "0.0.0",
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DaemonStartService", () => {
|
||||
it("upserts the connection on a successful daemon start", async () => {
|
||||
const fake = createFakeStore();
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => makeStatus(),
|
||||
});
|
||||
|
||||
const result = await service.start();
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fake.upserts).toEqual([
|
||||
{ listenAddress: "127.0.0.1:6767", serverId: "srv_desktop", hostname: "desktop" },
|
||||
]);
|
||||
expect(service.getLastError()).toBeNull();
|
||||
expect(service.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it("reports lastError after a missing listen address and clears running state when done", async () => {
|
||||
const fake = createFakeStore();
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => makeStatus({ listen: null }),
|
||||
});
|
||||
|
||||
const result = await service.start();
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: "Desktop daemon did not return a listen address.",
|
||||
});
|
||||
expect(service.getLastError()).toBe("Desktop daemon did not return a listen address.");
|
||||
expect(service.isRunning()).toBe(false);
|
||||
expect(fake.upserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports lastError when the daemon does not return a server id", async () => {
|
||||
const fake = createFakeStore();
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => makeStatus({ serverId: "" }),
|
||||
});
|
||||
|
||||
const result = await service.start();
|
||||
|
||||
expect(result).toEqual({ ok: false, error: "Desktop daemon did not return a server id." });
|
||||
expect(service.getLastError()).toBe("Desktop daemon did not return a server id.");
|
||||
expect(fake.upserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports lastError when the listen address is unsupported", async () => {
|
||||
const fake = createFakeStore();
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => makeStatus({ listen: "???" }),
|
||||
});
|
||||
|
||||
const result = await service.start();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(service.getLastError()).toContain("unsupported listen address");
|
||||
expect(fake.upserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports lastError when the underlying start call throws", async () => {
|
||||
const fake = createFakeStore();
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => {
|
||||
throw new Error("ipc broke");
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.start();
|
||||
|
||||
expect(result).toEqual({ ok: false, error: "ipc broke" });
|
||||
expect(service.getLastError()).toBe("ipc broke");
|
||||
});
|
||||
|
||||
it("clears lastError on retry entry and reports null after subsequent success", async () => {
|
||||
const fake = createFakeStore();
|
||||
const startMock = vi
|
||||
.fn<() => Promise<DesktopDaemonStatus>>()
|
||||
.mockRejectedValueOnce(new Error("ipc broke"))
|
||||
.mockResolvedValueOnce(makeStatus());
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: () => startMock(),
|
||||
});
|
||||
|
||||
const failure = await service.start();
|
||||
expect(failure.ok).toBe(false);
|
||||
expect(service.getLastError()).toBe("ipc broke");
|
||||
|
||||
const success = await service.start();
|
||||
expect(success).toEqual({ ok: true });
|
||||
expect(service.getLastError()).toBeNull();
|
||||
});
|
||||
|
||||
it("notifies subscribers when isRunning toggles between calls", async () => {
|
||||
const fake = createFakeStore();
|
||||
let resolveStart: ((value: DesktopDaemonStatus) => void) | undefined;
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: () =>
|
||||
new Promise<DesktopDaemonStatus>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
}),
|
||||
});
|
||||
|
||||
const runningSnapshots: boolean[] = [];
|
||||
service.subscribe(() => {
|
||||
runningSnapshots.push(service.isRunning());
|
||||
});
|
||||
|
||||
const startPromise = service.start();
|
||||
expect(service.isRunning()).toBe(true);
|
||||
expect(runningSnapshots).toEqual([true]);
|
||||
|
||||
resolveStart?.(makeStatus());
|
||||
await startPromise;
|
||||
|
||||
expect(service.isRunning()).toBe(false);
|
||||
expect(runningSnapshots).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("clears the error and notifies subscribers when retry begins", async () => {
|
||||
const fake = createFakeStore();
|
||||
const startMock = vi
|
||||
.fn<() => Promise<DesktopDaemonStatus>>()
|
||||
.mockRejectedValueOnce(new Error("ipc broke"))
|
||||
.mockResolvedValueOnce(makeStatus());
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: () => startMock(),
|
||||
});
|
||||
|
||||
await service.start();
|
||||
expect(service.getLastError()).toBe("ipc broke");
|
||||
|
||||
const errorSnapshots: Array<string | null> = [];
|
||||
service.subscribe(() => {
|
||||
errorSnapshots.push(service.getLastError());
|
||||
});
|
||||
|
||||
await service.start();
|
||||
expect(errorSnapshots[0]).toBeNull();
|
||||
expect(service.getLastError()).toBeNull();
|
||||
});
|
||||
|
||||
it("stops notifying after a subscriber unsubscribes", async () => {
|
||||
const fake = createFakeStore();
|
||||
let notifications = 0;
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => makeStatus({ listen: null }),
|
||||
});
|
||||
const unsubscribe = service.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
await service.start();
|
||||
const countAfterFirst = notifications;
|
||||
expect(countAfterFirst).toBeGreaterThan(0);
|
||||
|
||||
unsubscribe();
|
||||
await service.start();
|
||||
expect(notifications).toBe(countAfterFirst);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { startDesktopDaemon, type DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
import { connectionFromListen } from "@/types/host-connection";
|
||||
import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
export type DaemonStartResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export interface DaemonStartServiceDeps {
|
||||
store: Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
||||
startDesktopDaemon?: () => Promise<DesktopDaemonStatus>;
|
||||
}
|
||||
|
||||
export class DaemonStartService {
|
||||
private readonly store: Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
||||
private readonly invokeStartDesktopDaemon: () => Promise<DesktopDaemonStatus>;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private lastError: string | null = null;
|
||||
private inFlightCount = 0;
|
||||
|
||||
constructor(deps: DaemonStartServiceDeps) {
|
||||
this.store = deps.store;
|
||||
this.invokeStartDesktopDaemon = deps.startDesktopDaemon ?? startDesktopDaemon;
|
||||
}
|
||||
|
||||
async start(): Promise<DaemonStartResult> {
|
||||
this.beginRequest();
|
||||
try {
|
||||
const daemon = await this.invokeStartDesktopDaemon();
|
||||
const listenAddress = daemon.listen?.trim() ?? "";
|
||||
const serverId = daemon.serverId.trim();
|
||||
if (!listenAddress) {
|
||||
return this.fail("Desktop daemon did not return a listen address.");
|
||||
}
|
||||
if (!serverId) {
|
||||
return this.fail("Desktop daemon did not return a server id.");
|
||||
}
|
||||
if (!connectionFromListen(listenAddress)) {
|
||||
return this.fail(`Desktop daemon returned an unsupported listen address: ${listenAddress}`);
|
||||
}
|
||||
await this.store.upsertConnectionFromListen({
|
||||
listenAddress,
|
||||
serverId,
|
||||
hostname: daemon.hostname,
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return this.fail(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
this.endRequest();
|
||||
}
|
||||
}
|
||||
|
||||
getLastError(): string | null {
|
||||
return this.lastError;
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.inFlightCount > 0;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private fail(message: string): DaemonStartResult {
|
||||
this.setLastError(message);
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
|
||||
private setLastError(value: string | null): void {
|
||||
if (this.lastError === value) {
|
||||
return;
|
||||
}
|
||||
this.lastError = value;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private beginRequest(): void {
|
||||
const becameRunning = this.inFlightCount === 0;
|
||||
this.inFlightCount += 1;
|
||||
const errorChanged = this.lastError !== null;
|
||||
this.lastError = null;
|
||||
if (becameRunning || errorChanged) {
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
private endRequest(): void {
|
||||
this.inFlightCount = Math.max(0, this.inFlightCount - 1);
|
||||
if (this.inFlightCount === 0) {
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let singletonDaemonStartService: DaemonStartService | null = null;
|
||||
const DAEMON_START_SERVICE_GLOBAL_KEY = "__paseoDaemonStartService";
|
||||
|
||||
type DaemonStartServiceGlobal = typeof globalThis & {
|
||||
[DAEMON_START_SERVICE_GLOBAL_KEY]?: DaemonStartService;
|
||||
};
|
||||
|
||||
export function getDaemonStartService(deps: DaemonStartServiceDeps): DaemonStartService {
|
||||
if (singletonDaemonStartService) {
|
||||
return singletonDaemonStartService;
|
||||
}
|
||||
|
||||
const runtimeGlobal = globalThis as DaemonStartServiceGlobal;
|
||||
if (runtimeGlobal[DAEMON_START_SERVICE_GLOBAL_KEY]) {
|
||||
singletonDaemonStartService = runtimeGlobal[DAEMON_START_SERVICE_GLOBAL_KEY] ?? null;
|
||||
if (singletonDaemonStartService) {
|
||||
return singletonDaemonStartService;
|
||||
}
|
||||
}
|
||||
|
||||
singletonDaemonStartService = new DaemonStartService(deps);
|
||||
runtimeGlobal[DAEMON_START_SERVICE_GLOBAL_KEY] = singletonDaemonStartService;
|
||||
return singletonDaemonStartService;
|
||||
}
|
||||
@@ -282,72 +282,6 @@ function updateControllerSnapshot(
|
||||
).updateSnapshot(patch);
|
||||
}
|
||||
|
||||
function makeSingleConnectionHost(serverId: string): HostProfile {
|
||||
return makeHost({
|
||||
serverId,
|
||||
connections: [
|
||||
{
|
||||
id: `direct:${serverId}:6767`,
|
||||
type: "directTcp",
|
||||
endpoint: `${serverId}:6767`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:${serverId}:6767`,
|
||||
});
|
||||
}
|
||||
|
||||
function createOfflineOnlyStore(): HostRuntimeStore {
|
||||
return new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => {
|
||||
throw new Error("startup host tests drive snapshots directly");
|
||||
},
|
||||
connectToDaemon: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setStoreHosts(store: HostRuntimeStore, hosts: HostProfile[]): void {
|
||||
(
|
||||
store as unknown as {
|
||||
setHostsAndSync: (hosts: HostProfile[]) => void;
|
||||
}
|
||||
).setHostsAndSync(hosts);
|
||||
}
|
||||
|
||||
function markStoreHostOnline(
|
||||
store: HostRuntimeStore,
|
||||
serverId: string,
|
||||
lastOnlineAt: string,
|
||||
): void {
|
||||
const controller = (
|
||||
store as unknown as {
|
||||
controllers: Map<string, HostRuntimeController>;
|
||||
}
|
||||
).controllers.get(serverId);
|
||||
if (!controller) {
|
||||
throw new Error(`Missing controller for ${serverId}`);
|
||||
}
|
||||
(
|
||||
store as unknown as {
|
||||
lastConnectionStatusByServer: Map<string, HostRuntimeSnapshot["connectionStatus"]>;
|
||||
}
|
||||
).lastConnectionStatusByServer.set(serverId, "online");
|
||||
updateControllerSnapshot(controller, {
|
||||
connectionStatus: "online",
|
||||
hasEverLoadedAgentDirectory: true,
|
||||
lastOnlineAt,
|
||||
});
|
||||
(
|
||||
store as unknown as {
|
||||
emit: (serverId: string) => void;
|
||||
}
|
||||
).emit(serverId);
|
||||
}
|
||||
|
||||
function makeProbeMap(
|
||||
entries: Array<
|
||||
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
|
||||
@@ -1224,113 +1158,6 @@ describe("HostRuntimeController", () => {
|
||||
});
|
||||
|
||||
describe("HostRuntimeStore", () => {
|
||||
it("resolves startup host immediately when the preferred host is first online", async () => {
|
||||
const store = createOfflineOnlyStore();
|
||||
setStoreHosts(store, [
|
||||
makeSingleConnectionHost("srv_preferred"),
|
||||
makeSingleConnectionHost("srv_other"),
|
||||
]);
|
||||
|
||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
||||
markStoreHostOnline(store, "srv_preferred", "2026-04-26T10:00:00.000Z");
|
||||
|
||||
await expect(startup.promise).resolves.toEqual({ serverId: "srv_preferred" });
|
||||
startup.cancel();
|
||||
setStoreHosts(store, []);
|
||||
});
|
||||
|
||||
it("waits through grace for the preferred host after a different host comes online first", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = createOfflineOnlyStore();
|
||||
setStoreHosts(store, [
|
||||
makeSingleConnectionHost("srv_preferred"),
|
||||
makeSingleConnectionHost("srv_other"),
|
||||
]);
|
||||
|
||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
||||
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
markStoreHostOnline(store, "srv_preferred", "2026-04-26T10:00:01.000Z");
|
||||
|
||||
await expect(startup.promise).resolves.toEqual({ serverId: "srv_preferred" });
|
||||
startup.cancel();
|
||||
setStoreHosts(store, []);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the first online host when preferred does not arrive during grace", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = createOfflineOnlyStore();
|
||||
setStoreHosts(store, [
|
||||
makeSingleConnectionHost("srv_preferred"),
|
||||
makeSingleConnectionHost("srv_other"),
|
||||
]);
|
||||
|
||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
||||
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
await expect(startup.promise).resolves.toEqual({ serverId: "srv_other" });
|
||||
startup.cancel();
|
||||
setStoreHosts(store, []);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the first online host immediately when no preferred host is set", async () => {
|
||||
const store = createOfflineOnlyStore();
|
||||
setStoreHosts(store, [makeSingleConnectionHost("srv_other")]);
|
||||
|
||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: null });
|
||||
markStoreHostOnline(store, "srv_other", "2026-04-26T10:00:00.000Z");
|
||||
|
||||
await expect(startup.promise).resolves.toEqual({ serverId: "srv_other" });
|
||||
startup.cancel();
|
||||
setStoreHosts(store, []);
|
||||
});
|
||||
|
||||
it("resolves null when startup has no hosts to wait for", async () => {
|
||||
const store = createOfflineOnlyStore();
|
||||
|
||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
||||
|
||||
await expect(startup.promise).resolves.toBeNull();
|
||||
startup.cancel();
|
||||
});
|
||||
|
||||
it("resolves null using the connection online timeout when hosts exist but none come online", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = createOfflineOnlyStore();
|
||||
setStoreHosts(store, [
|
||||
makeSingleConnectionHost("srv_preferred"),
|
||||
makeSingleConnectionHost("srv_other"),
|
||||
]);
|
||||
|
||||
let resolved = false;
|
||||
const startup = store.waitForAnyConnectionOnline({ preferredServerId: "srv_preferred" });
|
||||
startup.promise.then(() => {
|
||||
resolved = true;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_999);
|
||||
expect(resolved).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
await expect(startup.promise).resolves.toBeNull();
|
||||
startup.cancel();
|
||||
setStoreHosts(store, []);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("bootstraps agent directory subscription when host transitions online", async () => {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
|
||||
import { shouldUseDesktopDaemon, startDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||
import { getOrCreateClientId } from "@/utils/client-id";
|
||||
@@ -37,10 +37,6 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||
|
||||
export type HostRuntimeBootstrapResult =
|
||||
| { ok: true; listenAddress: string; serverId: string; hostname: string | null }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type ActiveConnection =
|
||||
| { type: "directTcp"; endpoint: string; display: string }
|
||||
| { type: "directSocket"; endpoint: string; display: "socket" }
|
||||
@@ -70,10 +66,6 @@ export interface HostRuntimeSnapshot {
|
||||
clientGeneration: number;
|
||||
}
|
||||
|
||||
export interface StartupHostResolution {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
|
||||
|
||||
function setSnapshotPatchField<Key extends keyof HostRuntimeSnapshotPatch>(
|
||||
@@ -1154,9 +1146,6 @@ export class HostRuntimeController {
|
||||
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
||||
const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
|
||||
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
|
||||
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
|
||||
const STARTUP_HOST_GRACE_MS = 2_000;
|
||||
const STARTUP_NO_HOST_ONLINE_TIMEOUT_MS = 5_000;
|
||||
const E2E_STORAGE_KEY = "@paseo:e2e";
|
||||
|
||||
export class HostRuntimeStore {
|
||||
@@ -1170,8 +1159,7 @@ export class HostRuntimeStore {
|
||||
private deps: HostRuntimeControllerDeps;
|
||||
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
|
||||
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
|
||||
private storageLoaded = false;
|
||||
private bootstrapAttempted = false;
|
||||
private bootStarted = false;
|
||||
|
||||
constructor(input?: { deps?: HostRuntimeControllerDeps }) {
|
||||
this.deps = input?.deps ?? createDefaultDeps();
|
||||
@@ -1194,11 +1182,33 @@ export class HostRuntimeStore {
|
||||
return this.hostListVersion;
|
||||
}
|
||||
|
||||
async loadFromStorage(): Promise<void> {
|
||||
if (this.storageLoaded) {
|
||||
boot(): void {
|
||||
if (this.bootStarted) {
|
||||
return;
|
||||
}
|
||||
this.storageLoaded = true;
|
||||
this.bootStarted = true;
|
||||
void this.runBoot();
|
||||
}
|
||||
|
||||
private async runBoot(): Promise<void> {
|
||||
await this.loadFromStorage();
|
||||
|
||||
let isE2E: string | null = null;
|
||||
try {
|
||||
isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (isE2E) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldUseDesktopDaemon()) {
|
||||
await this.bootstrapLocalhost();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFromStorage(): Promise<void> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
@@ -1219,67 +1229,6 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
async bootstrap(options?: { manageBuiltInDaemon?: boolean }): Promise<void> {
|
||||
if (this.bootstrapAttempted) {
|
||||
return;
|
||||
}
|
||||
this.bootstrapAttempted = true;
|
||||
|
||||
try {
|
||||
const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY);
|
||||
if (isE2E) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldUseDesktopDaemon()) {
|
||||
if (options?.manageBuiltInDaemon ?? true) {
|
||||
await this.bootstrapDesktop();
|
||||
}
|
||||
} else {
|
||||
await this.bootstrapLocalhost();
|
||||
}
|
||||
}
|
||||
|
||||
async bootstrapDesktop(): Promise<HostRuntimeBootstrapResult> {
|
||||
try {
|
||||
const daemon = await startDesktopDaemon();
|
||||
const listenAddress = daemon.listen?.trim() ?? "";
|
||||
const serverId = daemon.serverId.trim();
|
||||
if (!listenAddress) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Desktop daemon did not return a listen address.",
|
||||
};
|
||||
}
|
||||
if (!serverId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Desktop daemon did not return a server id.",
|
||||
};
|
||||
}
|
||||
if (!connectionFromListen(listenAddress)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Desktop daemon returned an unsupported listen address: ${listenAddress}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
listenAddress,
|
||||
serverId,
|
||||
hostname: daemon.hostname,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: toErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async bootstrapLocalhost(): Promise<void> {
|
||||
try {
|
||||
const connection = connectionFromListen(DEFAULT_LOCALHOST_ENDPOINT);
|
||||
@@ -1375,11 +1324,10 @@ export class HostRuntimeStore {
|
||||
return this.upsertConnectionFromOffer(offer, label);
|
||||
}
|
||||
|
||||
async addConnectionFromListenAndWaitForOnline(input: {
|
||||
async upsertConnectionFromListen(input: {
|
||||
listenAddress: string;
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
timeoutMs?: number;
|
||||
}): Promise<HostProfile> {
|
||||
const normalizedListenAddress = input.listenAddress.trim();
|
||||
const serverId = input.serverId.trim();
|
||||
@@ -1390,19 +1338,11 @@ export class HostRuntimeStore {
|
||||
if (!serverId) {
|
||||
throw new Error("Desktop daemon did not return a server id.");
|
||||
}
|
||||
const profile = await this.upsertHostConnection({
|
||||
return this.upsertHostConnection({
|
||||
serverId,
|
||||
label: input.hostname ?? undefined,
|
||||
connection,
|
||||
});
|
||||
|
||||
await this.waitForConnectionOnline({
|
||||
serverId,
|
||||
connectionId: connection.id,
|
||||
timeoutMs: input.timeoutMs,
|
||||
});
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
async renameHost(serverId: string, label: string): Promise<void> {
|
||||
@@ -1566,97 +1506,6 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
private waitForConnectionOnline(input: {
|
||||
serverId: string;
|
||||
connectionId: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const { serverId, connectionId } = input;
|
||||
const timeoutMs = input.timeoutMs ?? CONNECTION_ONLINE_TIMEOUT_MS;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cleanup = (unsubscribe: (() => void) | null): void => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
unsubscribe?.();
|
||||
};
|
||||
|
||||
const settle = (
|
||||
unsubscribe: (() => void) | null,
|
||||
outcome: { ok: true } | { ok: false; error: Error },
|
||||
): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup(unsubscribe);
|
||||
if (outcome.ok) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(outcome.error);
|
||||
}
|
||||
};
|
||||
|
||||
const readSnapshot = (): { ok: true } | { ok: false; error: Error } | null => {
|
||||
const snapshot = this.getSnapshot(serverId);
|
||||
if (!snapshot) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`Unknown host runtime for serverId ${serverId}`),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
snapshot.activeConnectionId === connectionId &&
|
||||
snapshot.connectionStatus === "online"
|
||||
) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (snapshot.activeConnectionId === connectionId && snapshot.connectionStatus === "error") {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(snapshot.lastError ?? "Connection failed before coming online."),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const unsubscribe = this.subscribe(serverId, () => {
|
||||
const outcome = readSnapshot();
|
||||
if (outcome) {
|
||||
settle(unsubscribe, outcome);
|
||||
}
|
||||
});
|
||||
|
||||
timeoutHandle = setTimeout(() => {
|
||||
settle(unsubscribe, {
|
||||
ok: false,
|
||||
error: new Error(`Timed out waiting for connection ${connectionId} to come online.`),
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
const initialOutcome = readSnapshot();
|
||||
if (initialOutcome) {
|
||||
settle(unsubscribe, initialOutcome);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.runProbeCycleNow(serverId).catch((error) => {
|
||||
settle(unsubscribe, {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private maybeAutoBootstrapAgentDirectory(serverId: string): void {
|
||||
const controller = this.controllers.get(serverId);
|
||||
if (!controller) {
|
||||
@@ -1759,104 +1608,6 @@ export class HostRuntimeStore {
|
||||
return earliestServerId;
|
||||
}
|
||||
|
||||
waitForAnyConnectionOnline(input?: { preferredServerId?: string | null; graceMs?: number }): {
|
||||
promise: Promise<StartupHostResolution | null>;
|
||||
cancel: () => void;
|
||||
} {
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let graceTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let noHostOnlineTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let fallbackServerId: string | null = null;
|
||||
let settled = false;
|
||||
|
||||
const normalizedPreferredServerId = input?.preferredServerId?.trim() || null;
|
||||
const normalizedGraceMs = Math.max(0, input?.graceMs ?? STARTUP_HOST_GRACE_MS);
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (graceTimeout) {
|
||||
clearTimeout(graceTimeout);
|
||||
graceTimeout = null;
|
||||
}
|
||||
if (noHostOnlineTimeout) {
|
||||
clearTimeout(noHostOnlineTimeout);
|
||||
noHostOnlineTimeout = null;
|
||||
}
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
};
|
||||
|
||||
const promise = new Promise<StartupHostResolution | null>((resolve) => {
|
||||
const settle = (result: StartupHostResolution | null): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const getPreferredOnlineServerId = (): string | null => {
|
||||
if (!normalizedPreferredServerId) {
|
||||
return null;
|
||||
}
|
||||
return isHostRuntimeConnected(this.getSnapshot(normalizedPreferredServerId))
|
||||
? normalizedPreferredServerId
|
||||
: null;
|
||||
};
|
||||
|
||||
const evaluate = (): void => {
|
||||
if (this.hosts.length === 0) {
|
||||
settle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredOnlineServerId = getPreferredOnlineServerId();
|
||||
if (preferredOnlineServerId) {
|
||||
settle({ serverId: preferredOnlineServerId });
|
||||
return;
|
||||
}
|
||||
|
||||
const earliestOnlineServerId = this.getEarliestOnlineHostServerId();
|
||||
if (!earliestOnlineServerId) {
|
||||
if (!noHostOnlineTimeout) {
|
||||
noHostOnlineTimeout = setTimeout(() => {
|
||||
settle(null);
|
||||
}, STARTUP_NO_HOST_ONLINE_TIMEOUT_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (noHostOnlineTimeout) {
|
||||
clearTimeout(noHostOnlineTimeout);
|
||||
noHostOnlineTimeout = null;
|
||||
}
|
||||
|
||||
if (!normalizedPreferredServerId) {
|
||||
settle({ serverId: earliestOnlineServerId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fallbackServerId) {
|
||||
fallbackServerId = earliestOnlineServerId;
|
||||
graceTimeout = setTimeout(() => {
|
||||
const latestPreferredOnlineServerId = getPreferredOnlineServerId();
|
||||
settle({
|
||||
serverId: latestPreferredOnlineServerId ?? fallbackServerId ?? earliestOnlineServerId,
|
||||
});
|
||||
}, normalizedGraceMs);
|
||||
}
|
||||
};
|
||||
|
||||
unsubscribe = this.subscribeAll(evaluate);
|
||||
evaluate();
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
cancel: cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
ensureConnectedAll(): void {
|
||||
for (const controller of this.controllers.values()) {
|
||||
controller.ensureConnected();
|
||||
|
||||
Reference in New Issue
Block a user