From 40ce317ac2c620277de6f8a81d96af2bdac54aad Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 27 Apr 2026 16:36:07 +0700 Subject: [PATCH] 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. --- packages/app/src/app/_layout.tsx | 180 +++++---- .../src/app/host-runtime-bootstrap.test.ts | 347 ++++++++---------- .../app/src/app/host-runtime-bootstrap.ts | 199 +++------- packages/app/src/app/index.test.tsx | 91 +++-- packages/app/src/app/index.tsx | 53 +-- .../src/runtime/daemon-start-service.test.ts | 210 +++++++++++ .../app/src/runtime/daemon-start-service.ts | 127 +++++++ packages/app/src/runtime/host-runtime.test.ts | 173 --------- packages/app/src/runtime/host-runtime.ts | 307 ++-------------- .../app/src/screens/startup-splash-screen.tsx | 7 +- 10 files changed, 704 insertions(+), 990 deletions(-) create mode 100644 packages/app/src/runtime/daemon-start-service.test.ts create mode 100644 packages/app/src/runtime/daemon-start-service.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 4bd30f0a5..db3d96602 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -19,6 +19,7 @@ import { useMemo, useRef, useState, + useSyncExternalStore, } from "react"; import { View } from "react-native"; import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; @@ -49,7 +50,7 @@ import { import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context"; import { ToastProvider } from "@/contexts/toast-context"; import { VoiceProvider } from "@/contexts/voice-context"; -import { initializeHostRuntime, type StartupNavigationTarget } from "@/app/host-runtime-bootstrap"; +import { startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { listenToDesktopEvent } from "@/desktop/electron/events"; import { updateDesktopWindowControls } from "@/desktop/electron/window"; @@ -60,7 +61,7 @@ import { useColorScheme } from "@/hooks/use-color-scheme"; import { useFaviconStatus } from "@/hooks/use-favicon-status"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useOpenProject } from "@/hooks/use-open-project"; -import { loadSettingsFromStorage, useAppSettings } from "@/hooks/use-settings"; +import { useAppSettings } from "@/hooks/use-settings"; import { useStableEvent } from "@/hooks/use-stable-event"; import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; @@ -72,12 +73,10 @@ import { useHostRuntimeClient, useHosts, } from "@/runtime/host-runtime"; +import { getDaemonStartService } from "@/runtime/daemon-start-service"; import { addBrowserActiveWorkspaceLocationListener, - getLastNavigationWorkspaceRouteSelection, - hydrateLastNavigationWorkspaceRouteSelection, syncNavigationActiveWorkspace, - type ActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { usePanelStore } from "@/stores/panel-store"; import { useSessionStore } from "@/stores/session-store"; @@ -104,24 +103,19 @@ import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; polyfillCrypto(); export interface HostRuntimeBootstrapState { - phase: "starting-daemon" | "connecting" | "online" | "error"; - error: string | null; + splashError: string | null; retry: () => void; - startupNavigation: HostRuntimeStartupNavigation | null; + hasGivenUpWaitingForHost: boolean; + storeReady: boolean; } const HostRuntimeBootstrapContext = createContext({ - phase: "starting-daemon", - error: null, + splashError: null, retry: () => {}, - startupNavigation: null, + hasGivenUpWaitingForHost: false, + storeReady: false, }); -export interface HostRuntimeStartupNavigation { - target: StartupNavigationTarget; - workspaceSelection: ActiveWorkspaceSelection | null; -} - function PushNotificationRouter() { const router = useRouter(); const pathname = usePathname(); @@ -277,90 +271,90 @@ function HostSessionManager() { ); } +export function useEarliestOnlineHostServerId(): string | null { + const store = getHostRuntimeStore(); + const subscribe = useCallback( + (listener: () => void) => { + const unsubscribeAll = store.subscribeAll(listener); + const unsubscribeHostList = store.subscribeHostList(listener); + return () => { + unsubscribeAll(); + unsubscribeHostList(); + }; + }, + [store], + ); + return useSyncExternalStore( + subscribe, + () => store.getEarliestOnlineHostServerId(), + () => store.getEarliestOnlineHostServerId(), + ); +} + +function useDaemonStartLastError(): string | null { + const service = getDaemonStartService({ store: getHostRuntimeStore() }); + return useSyncExternalStore( + (listener) => service.subscribe(listener), + () => service.getLastError(), + () => service.getLastError(), + ); +} + +function useDaemonStartIsRunning(): boolean { + const service = getDaemonStartService({ store: getHostRuntimeStore() }); + return useSyncExternalStore( + (listener) => service.subscribe(listener), + () => service.isRunning(), + () => service.isRunning(), + ); +} + +const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000; + function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { - const [phase, setPhase] = useState(() => - getHostRuntimeStore().getEarliestOnlineHostServerId() !== null ? "online" : "starting-daemon", - ); - const [error, setError] = useState(null); - const [retryToken, setRetryToken] = useState(0); - const [startupNavigation, setStartupNavigation] = useState( - null, - ); - const retry = useCallback(() => { - setPhase("starting-daemon"); - setError(null); - setStartupNavigation(null); - setRetryToken((current) => current + 1); + useEffect(() => { + const store = getHostRuntimeStore(); + const daemonStartService = getDaemonStartService({ store }); + startHostRuntimeBootstrap({ + store, + daemonStartService, + shouldStartDaemon: shouldUseDesktopDaemon(), + }); }, []); + const anyOnlineHostServerId = useEarliestOnlineHostServerId(); + const daemonStartError = useDaemonStartLastError(); + const daemonStartIsRunning = useDaemonStartIsRunning(); + + const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false); useEffect(() => { - let cancelled = false; - let startupWorkspaceSelection: ActiveWorkspaceSelection | null = null; - const abortController = new AbortController(); - const shouldManageDesktop = shouldUseDesktopDaemon(); - const store = getHostRuntimeStore(); - setStartupNavigation(null); - - const loadStartupWorkspaceSelection = async (): Promise => { - await hydrateLastNavigationWorkspaceRouteSelection(); - startupWorkspaceSelection = getLastNavigationWorkspaceRouteSelection(); - return startupWorkspaceSelection; - }; - - void initializeHostRuntime({ - shouldManageDesktop, - loadSettings: loadSettingsFromStorage, - loadStartupWorkspaceSelection, - store, - setPhase, - setError, - isCancelled: () => cancelled, - signal: abortController.signal, - }) - .then((target) => { - if (cancelled) { - return undefined; - } - setStartupNavigation({ - target, - workspaceSelection: startupWorkspaceSelection, - }); - return undefined; - }) - .catch((bootstrapError) => { - console.error("[HostRuntime] Failed to initialize store", bootstrapError); - if (cancelled) { - return; - } - if (shouldManageDesktop) { - setPhase("error"); - setError( - bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError), - ); - return; - } - setPhase("online"); - setError(null); - setStartupNavigation({ - target: null, - workspaceSelection: startupWorkspaceSelection, - }); - }); - + if ( + anyOnlineHostServerId || + daemonStartError || + daemonStartIsRunning || + hasGivenUpWaitingForHost + ) { + return; + } + const handle = setTimeout(() => { + setHasGivenUpWaitingForHost(true); + }, STARTUP_GIVE_UP_TIMEOUT_MS); return () => { - cancelled = true; - abortController.abort(); + clearTimeout(handle); }; - }, [retryToken]); + }, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]); + + const retry = useCallback(() => { + void getDaemonStartService({ store: getHostRuntimeStore() }).start(); + }, []); + + const splashError = !anyOnlineHostServerId ? daemonStartError : null; + const storeReady = + Boolean(anyOnlineHostServerId) || Boolean(splashError) || hasGivenUpWaitingForHost; const state = useMemo( - () => ({ - phase, - error, - retry, - startupNavigation, - }), - [error, phase, retry, startupNavigation], + () => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady }), + [splashError, retry, hasGivenUpWaitingForHost, storeReady], ); return ( @@ -371,7 +365,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { } export function useStoreReady(): boolean { - return useContext(HostRuntimeBootstrapContext).phase === "online"; + return useContext(HostRuntimeBootstrapContext).storeReady; } export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState { diff --git a/packages/app/src/app/host-runtime-bootstrap.test.ts b/packages/app/src/app/host-runtime-bootstrap.test.ts index 71d153443..cf3da544b 100644 --- a/packages/app/src/app/host-runtime-bootstrap.test.ts +++ b/packages/app/src/app/host-runtime-bootstrap.test.ts @@ -1,247 +1,190 @@ import { describe, expect, it, vi } from "vitest"; -import { initializeHostRuntime } from "./host-runtime-bootstrap"; +import { + resolveStartupRedirectRoute, + startHostRuntimeBootstrap, + WELCOME_ROUTE, +} from "./host-runtime-bootstrap"; -type BootstrapStore = Parameters[0]["store"]; +function createFakeStore() { + return { boot: vi.fn() }; +} -function createSettings(input: { manageBuiltInDaemon: boolean }) { +function createFakeDaemonStartService() { return { - theme: "auto" as const, - sendBehavior: "interrupt" as const, - manageBuiltInDaemon: input.manageBuiltInDaemon, - releaseChannel: "stable" as const, + start: vi.fn(async () => ({ ok: true as const })), }; } -function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - const promise = new Promise((res) => { - resolve = res; - }); - return { promise, resolve }; -} - -function createStore(overrides: Partial = {}): BootstrapStore { - return { - loadFromStorage: vi.fn(async () => {}), - bootstrap: vi.fn(async () => {}), - bootstrapDesktop: vi.fn(async () => ({ - ok: true as const, - listenAddress: "127.0.0.1:6767", - serverId: "srv_test", - hostname: "test", - })), - addConnectionFromListenAndWaitForOnline: vi.fn(async () => {}), - waitForAnyConnectionOnline: vi.fn(() => ({ - promise: Promise.resolve({ serverId: "srv_test" }), - cancel: vi.fn(), - })), - ...overrides, - }; -} - -describe("initializeHostRuntime", () => { - it("uses effective desktop settings to skip desktop-managed bootstrap when daemon management is disabled", async () => { - const store = createStore(); - const setPhase = vi.fn(); - const setError = vi.fn(); - - const target = await initializeHostRuntime({ - shouldManageDesktop: true, - loadSettings: async () => createSettings({ manageBuiltInDaemon: false }), - loadStartupWorkspaceSelection: async () => null, - store, - setPhase, - setError, - isCancelled: () => false, - }); - - expect(store.bootstrap).toHaveBeenCalledWith({ manageBuiltInDaemon: false }); - expect(store.bootstrapDesktop).not.toHaveBeenCalled(); - expect(target).toEqual({ serverId: "srv_test" }); - expect(setPhase).toHaveBeenLastCalledWith("online"); - expect(setError).toHaveBeenLastCalledWith(null); - }); - - it("waits for any saved host after non-managed connection setup before setting online", async () => { +describe("startHostRuntimeBootstrap", () => { + it("fires boot and daemon-start without awaiting the daemon-start promise", () => { const events: string[] = []; - const wait = createDeferred<{ serverId: string } | null>(); - const store = createStore({ - bootstrap: vi.fn(async () => { - events.push("bootstrap"); + const store = { + boot: vi.fn(() => { + events.push("boot"); }), - waitForAnyConnectionOnline: vi.fn(() => { - events.push("wait"); - return { - promise: wait.promise, - cancel: vi.fn(), - }; + }; + const daemonStartService = { + start: vi.fn(async () => { + events.push("daemon-start"); + return { ok: true as const }; }), - }); - const setPhase = vi.fn((phase: string) => events.push(`phase:${phase}`)); + }; - const initPromise = initializeHostRuntime({ - shouldManageDesktop: false, - loadSettings: async () => createSettings({ manageBuiltInDaemon: false }), - loadStartupWorkspaceSelection: async () => { - events.push("read-workspace-selection"); - return null; - }, + startHostRuntimeBootstrap({ store, - setPhase, - setError: vi.fn(), - isCancelled: () => false, + daemonStartService, + shouldStartDaemon: true, }); - await vi.waitFor(() => { - expect(events).toEqual(["phase:connecting", "bootstrap", "read-workspace-selection", "wait"]); - }); - expect(setPhase).not.toHaveBeenCalledWith("online"); - - wait.resolve({ serverId: "srv_saved" }); - await expect(initPromise).resolves.toEqual({ serverId: "srv_saved" }); - expect(setPhase).toHaveBeenLastCalledWith("online"); + expect(store.boot).toHaveBeenCalledTimes(1); + expect(daemonStartService.start).toHaveBeenCalledTimes(1); + expect(events).toEqual(["boot", "daemon-start"]); }); - it("passes the startup workspace server id into the shared wait", async () => { - const store = createStore(); + it("skips daemon-start when shouldStartDaemon is false", () => { + const store = createFakeStore(); + const daemonStartService = createFakeDaemonStartService(); - await initializeHostRuntime({ - shouldManageDesktop: false, - loadSettings: async () => createSettings({ manageBuiltInDaemon: false }), - loadStartupWorkspaceSelection: async () => ({ - serverId: "srv_workspace", - workspaceId: "workspace-1", - }), + startHostRuntimeBootstrap({ store, - setPhase: vi.fn(), - setError: vi.fn(), - isCancelled: () => false, + daemonStartService, + shouldStartDaemon: false, }); - expect(store.waitForAnyConnectionOnline).toHaveBeenCalledWith({ - preferredServerId: "srv_workspace", - }); + expect(store.boot).toHaveBeenCalledTimes(1); + expect(daemonStartService.start).not.toHaveBeenCalled(); }); - it("passes a null preference into the shared wait when no startup workspace is persisted", async () => { - const store = createStore(); + it("does not await the daemon-start promise", () => { + const store = createFakeStore(); + let resolveStart: ((value: { ok: true }) => void) | undefined; + const daemonStartService = { + start: vi.fn( + () => + new Promise<{ ok: true }>((resolve) => { + resolveStart = resolve; + }), + ), + }; - await initializeHostRuntime({ - shouldManageDesktop: false, - loadSettings: async () => createSettings({ manageBuiltInDaemon: false }), - loadStartupWorkspaceSelection: async () => null, + startHostRuntimeBootstrap({ store, - setPhase: vi.fn(), - setError: vi.fn(), - isCancelled: () => false, + daemonStartService, + shouldStartDaemon: true, }); - expect(store.waitForAnyConnectionOnline).toHaveBeenCalledWith({ preferredServerId: null }); - }); + expect(store.boot).toHaveBeenCalledTimes(1); + expect(daemonStartService.start).toHaveBeenCalledTimes(1); - it("uses desktop daemon startup only as the managed connection producer before the shared wait", async () => { - const events: string[] = []; - const store = createStore({ - bootstrapDesktop: vi.fn(async () => { - events.push("bootstrap-desktop"); - return { - ok: true as const, - listenAddress: "127.0.0.1:6767", - serverId: "srv_desktop", - hostname: "desktop", - }; + resolveStart?.({ ok: true }); + }); +}); + +describe("resolveStartupRedirectRoute", () => { + const baseInput = { + pathname: "/", + anyOnlineHostServerId: null, + workspaceSelection: null, + isWorkspaceSelectionLoaded: true, + hasGivenUpWaitingForHost: false, + }; + + it("returns null when the pathname is not the index route", () => { + expect( + resolveStartupRedirectRoute({ + ...baseInput, + pathname: "/h/server-1", + anyOnlineHostServerId: "server-1", }), - addConnectionFromListenAndWaitForOnline: vi.fn(async () => { - events.push("add-connection"); + ).toBeNull(); + }); + + it("waits while the persisted workspace selection has not finished loading", () => { + expect( + resolveStartupRedirectRoute({ + ...baseInput, + anyOnlineHostServerId: "server-1", + isWorkspaceSelectionLoaded: false, }), - waitForAnyConnectionOnline: vi.fn(() => { - events.push("wait"); - return { - promise: Promise.resolve({ serverId: "srv_desktop" }), - cancel: vi.fn(), - }; - }), - }); - const setPhase = vi.fn((phase: string) => events.push(`phase:${phase}`)); - - const target = await initializeHostRuntime({ - shouldManageDesktop: true, - loadSettings: async () => createSettings({ manageBuiltInDaemon: true }), - loadStartupWorkspaceSelection: async () => { - events.push("read-workspace-selection"); - return null; - }, - store, - setPhase, - setError: vi.fn(), - isCancelled: () => false, - }); + ).toBeNull(); + }); - expect(target).toEqual({ serverId: "srv_desktop" }); - expect(events).toEqual([ - "phase:starting-daemon", - "bootstrap-desktop", - "phase:connecting", - "add-connection", - "read-workspace-selection", - "wait", - "phase:online", - ]); - expect(store.bootstrap).not.toHaveBeenCalled(); + it("waits while no host is online and the give-up timer has not fired", () => { + expect(resolveStartupRedirectRoute(baseInput)).toBeNull(); }); - it("returns null without waiting when there are no saved hosts", async () => { - const store = createStore({ - waitForAnyConnectionOnline: vi.fn(() => ({ - promise: Promise.resolve(null), - cancel: vi.fn(), - })), + describe("scenario: saved-host-online", () => { + it("redirects to the workspace route when the online host matches the persisted workspace", () => { + const route = resolveStartupRedirectRoute({ + ...baseInput, + anyOnlineHostServerId: "server-1", + workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, + }); + + expect(route).toBe("/h/server-1/workspace/workspace-a"); }); + + it("redirects to the host root when the persisted workspace targets a different server", () => { + const route = resolveStartupRedirectRoute({ + ...baseInput, + anyOnlineHostServerId: "server-2", + workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, + }); - const target = await initializeHostRuntime({ - shouldManageDesktop: false, - loadSettings: async () => createSettings({ manageBuiltInDaemon: false }), - loadStartupWorkspaceSelection: async () => null, - store, - setPhase: vi.fn(), - setError: vi.fn(), - isCancelled: () => false, + expect(route).toBe("/h/server-2"); }); - expect(target).toBeNull(); - expect(store.waitForAnyConnectionOnline).toHaveBeenCalledWith({ preferredServerId: null }); + it("redirects to the host root when no persisted workspace exists", () => { + const route = resolveStartupRedirectRoute({ + ...baseInput, + anyOnlineHostServerId: "server-2", + }); + + expect(route).toBe("/h/server-2"); + }); }); + + describe("scenario: daemon-start-success-only (host comes online via daemon-start upsert)", () => { + it("redirects to the host that came online", () => { + const route = resolveStartupRedirectRoute({ + ...baseInput, + anyOnlineHostServerId: "srv_desktop", + }); - it("cancels the shared wait while initialization is still pending", async () => { - const abortController = new AbortController(); - const wait = createDeferred<{ serverId: string } | null>(); - const cancel = vi.fn(); - const store = createStore({ - waitForAnyConnectionOnline: vi.fn(() => ({ - promise: wait.promise, - cancel, - })), + expect(route).toBe("/h/srv_desktop"); }); - const setPhase = vi.fn(); - - const initPromise = initializeHostRuntime({ - shouldManageDesktop: false, - loadSettings: async () => createSettings({ manageBuiltInDaemon: false }), - loadStartupWorkspaceSelection: async () => null, - store, - setPhase, - setError: vi.fn(), - isCancelled: () => true, - signal: abortController.signal, + }); + + describe("scenario: both-succeed", () => { + it("redirects to the host that the host runtime selects as earliest online", () => { + const route = resolveStartupRedirectRoute({ + ...baseInput, + anyOnlineHostServerId: "server-saved", + workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" }, + }); + + expect(route).toBe("/h/server-saved/workspace/workspace-a"); }); + }); + + describe("scenario: both-fail (no host comes online, give-up timer fires)", () => { + it("redirects to the welcome route", () => { + const route = resolveStartupRedirectRoute({ + ...baseInput, + hasGivenUpWaitingForHost: true, + }); - await vi.waitFor(() => { - expect(store.waitForAnyConnectionOnline).toHaveBeenCalled(); + expect(route).toBe(WELCOME_ROUTE); }); - abortController.abort(); - await expect(initPromise).resolves.toBeNull(); - expect(cancel).toHaveBeenCalledTimes(1); - expect(setPhase).not.toHaveBeenCalledWith("online"); + it("still redirects to the host when one comes online before the timer expires", () => { + const route = resolveStartupRedirectRoute({ + ...baseInput, + anyOnlineHostServerId: "server-saved", + hasGivenUpWaitingForHost: true, + }); + + expect(route).toBe("/h/server-saved"); + }); }); }); diff --git a/packages/app/src/app/host-runtime-bootstrap.ts b/packages/app/src/app/host-runtime-bootstrap.ts index a8edd81ba..c08a7b1ac 100644 --- a/packages/app/src/app/host-runtime-bootstrap.ts +++ b/packages/app/src/app/host-runtime-bootstrap.ts @@ -1,168 +1,63 @@ -import type { HostRuntimeBootstrapResult } from "@/runtime/host-runtime"; -import type { Settings } from "@/hooks/use-settings"; import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -import { - getLastNavigationWorkspaceRouteSelection, - hydrateLastNavigationWorkspaceRouteSelection, -} from "@/stores/navigation-active-workspace-store"; +import type { DaemonStartResult } from "@/runtime/daemon-start-service"; +import type { Href } from "expo-router"; +import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; -type HostRuntimeBootstrapPhase = "starting-daemon" | "connecting" | "online" | "error"; - -export type StartupNavigationTarget = { serverId: string } | null; - -interface HostRuntimeBootstrapStore { - loadFromStorage: () => Promise; - bootstrap: (options?: { manageBuiltInDaemon?: boolean }) => Promise; - bootstrapDesktop: () => Promise; - addConnectionFromListenAndWaitForOnline: (input: { - listenAddress: string; - serverId: string; - hostname: string | null; - }) => Promise; - waitForAnyConnectionOnline: (input?: { preferredServerId?: string | null }) => { - promise: Promise; - cancel: () => void; - }; +export interface HostRuntimeBootstrapStore { + boot: () => void; } -export async function initializeHostRuntime(args: { - shouldManageDesktop: boolean; - loadSettings: () => Promise; - loadStartupWorkspaceSelection?: () => Promise; +export interface HostRuntimeBootstrapDaemonStartService { + start: () => Promise; +} + +export interface StartHostRuntimeBootstrapInput { store: HostRuntimeBootstrapStore; - setPhase: (phase: HostRuntimeBootstrapPhase) => void; - setError: (error: string | null) => void; - isCancelled: () => boolean; - signal?: AbortSignal; -}): Promise { - const { - shouldManageDesktop, - loadSettings, - loadStartupWorkspaceSelection = readStartupWorkspaceSelection, - store, - setPhase, - setError, - isCancelled, - signal, - } = args; - - const settings = await loadSettings(); - const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon; - await store.loadFromStorage(); - - if (!isDesktopManaged) { - setPhase("connecting"); - setError(null); - await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon }); - } else { - const setupResult = await setupDesktopManagedConnection({ - store, - setPhase, - setError, - isCancelled, - }); - if (setupResult.type === "error") { - return null; - } - } - - const startupWorkspaceSelection = await loadStartupWorkspaceSelection(); - - const target = await waitForStartupNavigationTarget({ - store, - preferredServerId: startupWorkspaceSelection?.serverId ?? null, - signal, - }); - - if (!isCancelled()) { - setPhase("online"); - setError(null); - } - - return target; + daemonStartService: HostRuntimeBootstrapDaemonStartService; + shouldStartDaemon: boolean; } -async function setupDesktopManagedConnection(input: { - store: HostRuntimeBootstrapStore; - setPhase: (phase: HostRuntimeBootstrapPhase) => void; - setError: (error: string | null) => void; - isCancelled: () => boolean; -}): Promise { - const { store, setPhase, setError, isCancelled } = input; - - setPhase("starting-daemon"); - setError(null); - - try { - const bootstrapResult = await store.bootstrapDesktop(); - if (!bootstrapResult.ok) { - if (!isCancelled()) { - setPhase("error"); - setError(bootstrapResult.error); - } - return { type: "error", error: bootstrapResult.error }; - } - - if (!isCancelled()) { - setPhase("connecting"); - } - await store.addConnectionFromListenAndWaitForOnline({ - listenAddress: bootstrapResult.listenAddress, - serverId: bootstrapResult.serverId, - hostname: bootstrapResult.hostname, - }); - return { type: "online" }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!isCancelled()) { - setPhase("error"); - setError(message); - } - return { type: "error", error: message }; +export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void { + input.store.boot(); + if (input.shouldStartDaemon) { + void input.daemonStartService.start(); } } -function waitForStartupNavigationTarget(input: { - store: HostRuntimeBootstrapStore; - preferredServerId: string | null; - signal?: AbortSignal; -}): Promise { - const { store, preferredServerId, signal } = input; +export const WELCOME_ROUTE: Href = "/welcome"; - if (signal?.aborted) { - return Promise.resolve(null); +export interface ResolveStartupRedirectInput { + pathname: string; + anyOnlineHostServerId: string | null; + workspaceSelection: ActiveWorkspaceSelection | null; + isWorkspaceSelectionLoaded: boolean; + hasGivenUpWaitingForHost: boolean; +} + +export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput): Href | null { + if (input.pathname !== "/" && input.pathname !== "") { + return null; + } + if (!input.isWorkspaceSelectionLoaded) { + return null; } - const onlineWait = store.waitForAnyConnectionOnline({ preferredServerId }); + if (input.anyOnlineHostServerId) { + if ( + input.workspaceSelection && + input.workspaceSelection.serverId === input.anyOnlineHostServerId + ) { + return buildHostWorkspaceRoute( + input.workspaceSelection.serverId, + input.workspaceSelection.workspaceId, + ); + } + return buildHostRootRoute(input.anyOnlineHostServerId); + } - return new Promise((resolve) => { - let settled = false; + if (input.hasGivenUpWaitingForHost) { + return WELCOME_ROUTE; + } - const settle = (target: StartupNavigationTarget): void => { - if (settled) { - return; - } - settled = true; - signal?.removeEventListener("abort", abort); - onlineWait.cancel(); - resolve(target); - }; - - const abort = (): void => { - settle(null); - }; - - signal?.addEventListener("abort", abort, { once: true }); - onlineWait.promise.then((target) => { - settle(target); - return undefined; - }); - }); -} - -type BootstrapOutcome = { type: "online" } | { type: "error"; error: string }; - -async function readStartupWorkspaceSelection(): Promise { - await hydrateLastNavigationWorkspaceRouteSelection(); - return getLastNavigationWorkspaceRouteSelection(); + return null; } diff --git a/packages/app/src/app/index.test.tsx b/packages/app/src/app/index.test.tsx index 2d9b1122b..c900692fd 100644 --- a/packages/app/src/app/index.test.tsx +++ b/packages/app/src/app/index.test.tsx @@ -6,29 +6,24 @@ import { act } from "@testing-library/react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { HostRuntimeBootstrapState } from "./_layout"; +import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -const { redirectMock, runtimeStoreAccessMock, workspaceStoreAccessMock, state } = vi.hoisted(() => { +const { redirectMock, state } = vi.hoisted(() => { const hoistedState = { pathname: "/", - storeReady: true, bootstrapState: { - phase: "online", - error: null, + splashError: null, retry: vi.fn(), - startupNavigation: null, + hasGivenUpWaitingForHost: false, + storeReady: false, } as HostRuntimeBootstrapState, + anyOnlineHostServerId: null as string | null, + isWorkspaceSelectionLoaded: true, + workspaceSelection: null as ActiveWorkspaceSelection | null, }; return { redirectMock: vi.fn(), - runtimeStoreAccessMock: vi.fn(() => { - throw new Error("index.tsx must not read the host runtime store during startup navigation"); - }), - workspaceStoreAccessMock: vi.fn(() => { - throw new Error( - "index.tsx must not read the navigation workspace store during startup navigation", - ); - }), state: hoistedState, }; }); @@ -43,11 +38,7 @@ vi.mock("expo-router", () => ({ vi.mock("@/app/_layout", () => ({ useHostRuntimeBootstrapState: () => state.bootstrapState, - useStoreReady: () => state.storeReady, -})); - -vi.mock("@/runtime/host-runtime", () => ({ - getHostRuntimeStore: runtimeStoreAccessMock, + useEarliestOnlineHostServerId: () => state.anyOnlineHostServerId, })); vi.mock("@/desktop/daemon/desktop-daemon", () => ({ @@ -59,8 +50,8 @@ vi.mock("@/screens/startup-splash-screen", () => ({ })); vi.mock("@/stores/navigation-active-workspace-store", () => ({ - getLastNavigationWorkspaceRouteSelection: workspaceStoreAccessMock, - useIsLastNavigationWorkspaceRouteSelectionLoaded: workspaceStoreAccessMock, + getLastNavigationWorkspaceRouteSelection: () => state.workspaceSelection, + useIsLastNavigationWorkspaceRouteSelectionLoaded: () => state.isWorkspaceSelectionLoaded, })); describe("Index route startup navigation", () => { @@ -70,16 +61,16 @@ describe("Index route startup navigation", () => { beforeEach(() => { vi.resetModules(); state.pathname = "/"; - state.storeReady = true; state.bootstrapState = { - phase: "online", - error: null, + splashError: null, retry: vi.fn(), - startupNavigation: null, + hasGivenUpWaitingForHost: false, + storeReady: false, }; + state.anyOnlineHostServerId = null; + state.isWorkspaceSelectionLoaded = true; + state.workspaceSelection = null; redirectMock.mockReset(); - runtimeStoreAccessMock.mockClear(); - workspaceStoreAccessMock.mockClear(); container = document.createElement("div"); document.body.appendChild(container); @@ -100,54 +91,54 @@ describe("Index route startup navigation", () => { }); } - it("shows the startup splash until bootstrap has resolved the startup navigation target", async () => { + it("shows the startup splash while no host is online and the welcome timer has not fired", async () => { await renderIndex(); expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull(); expect(redirectMock).not.toHaveBeenCalled(); - expect(runtimeStoreAccessMock).not.toHaveBeenCalled(); - expect(workspaceStoreAccessMock).not.toHaveBeenCalled(); }); - it("restores the persisted workspace when the startup target matches its host", async () => { - state.bootstrapState.startupNavigation = { - target: { serverId: "server-1" }, - workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, - }; + it("shows the startup splash while the workspace selection has not loaded", async () => { + state.anyOnlineHostServerId = "server-1"; + state.isWorkspaceSelectionLoaded = false; + + await renderIndex(); + + expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull(); + expect(redirectMock).not.toHaveBeenCalled(); + }); + + it("restores the persisted workspace when the online host matches its server id", async () => { + state.anyOnlineHostServerId = "server-1"; + state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" }; await renderIndex(); expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); - expect(runtimeStoreAccessMock).not.toHaveBeenCalled(); - expect(workspaceStoreAccessMock).not.toHaveBeenCalled(); }); - it("navigates to the startup host root when it differs from the persisted workspace host", async () => { - state.bootstrapState.startupNavigation = { - target: { serverId: "server-2" }, - workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, - }; + it("navigates to the host root when the persisted workspace targets a different server", async () => { + state.anyOnlineHostServerId = "server-2"; + state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" }; await renderIndex(); expect(redirectMock).toHaveBeenCalledWith("/h/server-2"); }); - it("navigates to the startup host root when no persisted workspace exists", async () => { - state.bootstrapState.startupNavigation = { - target: { serverId: "server-2" }, - workspaceSelection: null, - }; + it("navigates to the host root when no persisted workspace exists", async () => { + state.anyOnlineHostServerId = "server-2"; + state.workspaceSelection = null; await renderIndex(); expect(redirectMock).toHaveBeenCalledWith("/h/server-2"); }); - it("falls back to welcome when bootstrap resolves no startup target", async () => { - state.bootstrapState.startupNavigation = { - target: null, - workspaceSelection: null, + it("falls back to welcome when the give-up timer fires with no host online", async () => { + state.bootstrapState = { + ...state.bootstrapState, + hasGivenUpWaitingForHost: true, }; await renderIndex(); diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 7890aec4c..9e9b537dc 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -1,26 +1,30 @@ import React from "react"; -import { Redirect, usePathname, type Href } from "expo-router"; +import { Redirect, usePathname } from "expo-router"; import { StartupSplashScreen } from "@/screens/startup-splash-screen"; +import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout"; +import { resolveStartupRedirectRoute } from "@/app/host-runtime-bootstrap"; import { - type HostRuntimeBootstrapState, - useHostRuntimeBootstrapState, - useStoreReady, -} from "@/app/_layout"; -import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; + getLastNavigationWorkspaceRouteSelection, + useIsLastNavigationWorkspaceRouteSelectionLoaded, +} from "@/stores/navigation-active-workspace-store"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; -const WELCOME_ROUTE = "/welcome"; - const isDesktop = shouldUseDesktopDaemon(); export default function Index() { const pathname = usePathname(); const bootstrapState = useHostRuntimeBootstrapState(); - const storeReady = useStoreReady(); + const anyOnlineHostServerId = useEarliestOnlineHostServerId(); + const isWorkspaceSelectionLoaded = useIsLastNavigationWorkspaceRouteSelectionLoaded(); + const redirectRoute = resolveStartupRedirectRoute({ - bootstrapState, pathname, - storeReady, + anyOnlineHostServerId, + workspaceSelection: isWorkspaceSelectionLoaded + ? getLastNavigationWorkspaceRouteSelection() + : null, + isWorkspaceSelectionLoaded, + hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, }); if (redirectRoute) { @@ -29,30 +33,3 @@ export default function Index() { return ; } - -function resolveStartupRedirectRoute(input: { - bootstrapState: HostRuntimeBootstrapState; - pathname: string; - storeReady: boolean; -}): Href | null { - const { bootstrapState, pathname, storeReady } = input; - - if (!storeReady || !bootstrapState.startupNavigation) { - return null; - } - if (pathname !== "/" && pathname !== "") { - return null; - } - - const { target, workspaceSelection } = bootstrapState.startupNavigation; - - if (!target) { - return WELCOME_ROUTE; - } - - if (workspaceSelection && target.serverId === workspaceSelection.serverId) { - return buildHostWorkspaceRoute(workspaceSelection.serverId, workspaceSelection.workspaceId); - } - - return buildHostRootRoute(target.serverId); -} diff --git a/packages/app/src/runtime/daemon-start-service.test.ts b/packages/app/src/runtime/daemon-start-service.test.ts new file mode 100644 index 000000000..fc642b639 --- /dev/null +++ b/packages/app/src/runtime/daemon-start-service.test.ts @@ -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; + upserts: RecordedUpsert[]; +} { + const upserts: RecordedUpsert[] = []; + const store = { + upsertConnectionFromListen: async (input: RecordedUpsert) => { + upserts.push(input); + return {} as Awaited>; + }, + }; + return { store, upserts }; +} + +function makeStatus(overrides: Partial = {}): 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>() + .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((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>() + .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 = []; + 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); + }); +}); diff --git a/packages/app/src/runtime/daemon-start-service.ts b/packages/app/src/runtime/daemon-start-service.ts new file mode 100644 index 000000000..b11fbb0b4 --- /dev/null +++ b/packages/app/src/runtime/daemon-start-service.ts @@ -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; + startDesktopDaemon?: () => Promise; +} + +export class DaemonStartService { + private readonly store: Pick; + private readonly invokeStartDesktopDaemon: () => Promise; + 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 { + 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; +} diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index ce28accfa..73a2635be 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -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; - } - ).controllers.get(serverId); - if (!controller) { - throw new Error(`Missing controller for ${serverId}`); - } - ( - store as unknown as { - lastConnectionStatusByServer: Map; - } - ).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 ? 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: [ diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 6a9d2d612..4be14a3a9 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -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>; function setSnapshotPatchField( @@ -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(); private agentDirectoryBootstrapInFlight = new Map>(); - 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 { - if (this.storageLoaded) { + boot(): void { + if (this.bootStarted) { return; } - this.storageLoaded = true; + this.bootStarted = true; + void this.runBoot(); + } + + private async runBoot(): Promise { + 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 { try { const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY); if (!stored) { @@ -1219,67 +1229,6 @@ export class HostRuntimeStore { } } - async bootstrap(options?: { manageBuiltInDaemon?: boolean }): Promise { - 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 { - 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 { 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 { 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 { @@ -1566,97 +1506,6 @@ export class HostRuntimeStore { } } - private waitForConnectionOnline(input: { - serverId: string; - connectionId: string; - timeoutMs?: number; - }): Promise { - const { serverId, connectionId } = input; - const timeoutMs = input.timeoutMs ?? CONNECTION_ONLINE_TIMEOUT_MS; - - return new Promise((resolve, reject) => { - let settled = false; - let timeoutHandle: ReturnType | 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; - cancel: () => void; - } { - let unsubscribe: (() => void) | null = null; - let graceTimeout: ReturnType | null = null; - let noHostOnlineTimeout: ReturnType | 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((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(); diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index a0ae4402d..625a9e6a7 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -24,8 +24,7 @@ import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; interface StartupSplashScreenProps { bootstrapState?: { - phase: "starting-daemon" | "connecting" | "online" | "error"; - error: string | null; + splashError: string | null; retry: () => void; }; } @@ -312,7 +311,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps const [logsError, setLogsError] = useState(null); const [isLoadingLogs, setIsLoadingLogs] = useState(false); - const isError = bootstrapState?.phase === "error"; + const isError = bootstrapState !== undefined && bootstrapState.splashError !== null; useEffect(() => { if (!isError) { @@ -418,7 +417,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps GitHub and include the logs below. - {bootstrapState.error} + {bootstrapState.splashError} {daemonLogs?.logPath ? {daemonLogs.logPath} : null}