diff --git a/packages/app/src/runtime/daemon-start-service.test.ts b/packages/app/src/runtime/daemon-start-service.test.ts index f1cae10118..88fda398bb 100644 --- a/packages/app/src/runtime/daemon-start-service.test.ts +++ b/packages/app/src/runtime/daemon-start-service.test.ts @@ -64,6 +64,81 @@ function makeRelayOnlyHost(serverId: string): HostProfile { } describe("DaemonStartService", () => { + it("keeps startup pending until a slow daemon becomes ready without restarting it", async () => { + vi.useFakeTimers(); + try { + const fake = createFakeStore(); + const starting = makeStatus({ status: "starting", serverId: "", listen: null }); + const startDesktopDaemon = vi.fn(async () => starting); + const getDesktopDaemonStatus = vi + .fn() + .mockResolvedValueOnce(starting) + .mockResolvedValueOnce(makeStatus()); + const service = new DaemonStartService({ + store: fake.store, + startDesktopDaemon, + getDesktopDaemonStatus, + }); + const result = service.start(); + await vi.advanceTimersByTimeAsync(1000); + expect(service.isRunning()).toBe(true); + expect(service.getLastError()).toBeNull(); + expect(fake.upserts).toEqual([]); + await vi.advanceTimersByTimeAsync(1000); + expect(await result).toEqual({ ok: true }); + expect(startDesktopDaemon).toHaveBeenCalledTimes(1); + expect(fake.upserts).toEqual([ + { listenAddress: "127.0.0.1:6767", serverId: "srv_desktop", hostname: "desktop" }, + ]); + expect(service.isRunning()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("shows the daemon error if it exits while startup is pending", async () => { + vi.useFakeTimers(); + try { + const fake = createFakeStore(); + const service = new DaemonStartService({ + store: fake.store, + startDesktopDaemon: async () => makeStatus({ status: "starting", serverId: "" }), + getDesktopDaemonStatus: async () => + makeStatus({ status: "errored", error: "provider startup failed" }), + }); + const result = service.start(); + await vi.advanceTimersByTimeAsync(1000); + expect(await result).toEqual({ ok: false, error: "provider startup failed" }); + expect(fake.upserts).toEqual([]); + expect(service.isRunning()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("bounds the readiness wait and leaves a retryable error", async () => { + vi.useFakeTimers(); + try { + const fake = createFakeStore(); + const starting = makeStatus({ status: "starting", serverId: "" }); + const service = new DaemonStartService({ + store: fake.store, + startDesktopDaemon: async () => starting, + getDesktopDaemonStatus: async () => starting, + }); + const result = service.start(); + await vi.advanceTimersByTimeAsync(120000); + expect(await result).toEqual({ + ok: false, + error: "The local daemon is still starting. Wait a moment and retry.", + }); + expect(fake.upserts).toEqual([]); + expect(service.isRunning()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + it("upserts the connection on a successful daemon start", async () => { const fake = createFakeStore(); const service = new DaemonStartService({ diff --git a/packages/app/src/runtime/daemon-start-service.ts b/packages/app/src/runtime/daemon-start-service.ts index b00e3a380c..1c07ecf8c4 100644 --- a/packages/app/src/runtime/daemon-start-service.ts +++ b/packages/app/src/runtime/daemon-start-service.ts @@ -1,4 +1,8 @@ -import { startDesktopDaemon, type DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; +import { + getDesktopDaemonStatus, + startDesktopDaemon, + type DesktopDaemonStatus, +} from "@/desktop/daemon/desktop-daemon"; import { connectionFromListen } from "@/types/host-connection"; import type { HostRuntimeStore } from "@/runtime/host-runtime"; @@ -14,6 +18,7 @@ type DaemonConnectionStore = Pick Promise; + getDesktopDaemonStatus?: () => Promise; } export async function upsertDesktopDaemonConnection( @@ -48,6 +53,7 @@ export async function upsertDesktopDaemonConnection( export class DaemonStartService { private readonly store: DaemonConnectionStore; private readonly invokeStartDesktopDaemon: () => Promise; + private readonly invokeGetDesktopDaemonStatus: () => Promise; private readonly listeners = new Set<() => void>(); private lastError: string | null = null; private inFlightCount = 0; @@ -55,6 +61,7 @@ export class DaemonStartService { constructor(deps: DaemonStartServiceDeps) { this.store = deps.store; this.invokeStartDesktopDaemon = deps.startDesktopDaemon ?? startDesktopDaemon; + this.invokeGetDesktopDaemonStatus = deps.getDesktopDaemonStatus ?? getDesktopDaemonStatus; } async start(): Promise { @@ -79,7 +86,21 @@ export class DaemonStartService { return { ok: true }; } - const daemon = await this.invokeStartDesktopDaemon(); + let daemon = await this.invokeStartDesktopDaemon(); + // The desktop start command can return after its readiness deadline while + // the owned process keeps initializing. Keep the startup UI pending until + // that process can supply its host identity; do not launch it again. + const readinessDeadline = Date.now() + 120_000; + while (daemon.status === "starting") { + if (Date.now() >= readinessDeadline) { + return this.fail("The local daemon is still starting. Wait a moment and retry."); + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + daemon = await this.invokeGetDesktopDaemonStatus(); + } + if (daemon.status !== "running") { + return this.fail(daemon.error ?? "The local daemon stopped before startup completed."); + } const result = await upsertDesktopDaemonConnection(this.store, daemon); return result.ok ? result : this.fail(result.error); } catch (error) {