diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index d75ad1550..47b989309 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -230,6 +230,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { useEffect(() => { let cancelled = false; + let cancelAnyOnline: (() => void) | null = null; const shouldManageDesktop = shouldUseDesktopDaemon(); const store = getHostRuntimeStore(); @@ -240,28 +241,53 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { if (isDesktopManaged) { setPhase("starting-daemon"); setError(null); - const bootstrapResult = await store.bootstrapDesktop(); - if (!bootstrapResult.ok) { - if (!cancelled) { - setPhase("error"); - setError(bootstrapResult.error); + + let raceSettled = false; + + const anyOnline = store.waitForAnyConnectionOnline(); + cancelAnyOnline = anyOnline.cancel; + + const bootstrapPromise = (async (): Promise< + { type: "online" } | { type: "error"; error: string } + > => { + try { + const bootstrapResult = await store.bootstrapDesktop(); + if (!bootstrapResult.ok) { + return { type: "error", error: bootstrapResult.error }; + } + if (!cancelled && !raceSettled) { + setPhase("connecting"); + } + await store.addConnectionFromListenAndWaitForOnline({ + listenAddress: bootstrapResult.listenAddress, + serverId: bootstrapResult.serverId, + hostname: bootstrapResult.hostname, + }); + return { type: "online" }; + } catch (err) { + return { + type: "error", + error: err instanceof Error ? err.message : String(err), + }; } - return; - } + })(); - if (cancelled) { - return; - } + const result = await Promise.race([ + anyOnline.promise.then((): { type: "online" } => ({ type: "online" })), + bootstrapPromise, + ]); + + raceSettled = true; + anyOnline.cancel(); - setPhase("connecting"); - await store.addConnectionFromListenAndWaitForOnline({ - listenAddress: bootstrapResult.listenAddress, - serverId: bootstrapResult.serverId, - hostname: bootstrapResult.hostname, - }); if (!cancelled) { - setPhase("online"); - setError(null); + if (result.type === "online") { + setPhase("online"); + setError(null); + } else { + setPhase("error"); + setError(result.error); + } } } else { void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon }); @@ -288,6 +314,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { return () => { cancelled = true; + cancelAnyOnline?.(); }; }, [retryToken]); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 74c3eb08c..f5405857c 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1681,6 +1681,41 @@ export class HostRuntimeStore { }; } + waitForAnyConnectionOnline(): { promise: Promise; cancel: () => void } { + let unsubscribe: (() => void) | null = null; + + const isAnyOnline = (): boolean => { + for (const host of this.hosts) { + const snapshot = this.getSnapshot(host.serverId); + if (snapshot?.connectionStatus === "online") return true; + } + return false; + }; + + const promise = new Promise((resolve) => { + if (isAnyOnline()) { + resolve(); + return; + } + + unsubscribe = this.subscribeAll(() => { + if (isAnyOnline()) { + unsubscribe?.(); + unsubscribe = null; + resolve(); + } + }); + }); + + return { + promise, + cancel: () => { + unsubscribe?.(); + unsubscribe = null; + }, + }; + } + ensureConnectedAll(): void { for (const controller of this.controllers.values()) { controller.ensureConnected(); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 98e48f4ea..d1b1091a4 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -186,11 +186,14 @@ function resolveDesktopAppVersion(): string { // Daemon lifecycle // --------------------------------------------------------------------------- -function resolveStatus(): DesktopDaemonStatus { +async function resolveStatus(): Promise { const home = getPaseoHome(); try { - const payload = runCliJsonCommand(["daemon", "status", "--json"]) as Record; + const payload = (await runCliJsonCommand(["daemon", "status", "--json"])) as Record< + string, + unknown + >; const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped"; const running = localDaemon === "running"; @@ -227,7 +230,7 @@ function normalizeVersion(version: string | null): string | null { } async function startDaemon(): Promise { - const current = resolveStatus(); + const current = await resolveStatus(); if (current.status === "running") { const appVersion = normalizeVersion(resolveDesktopAppVersion()); const daemonVersion = normalizeVersion(current.version); @@ -320,7 +323,7 @@ async function startDaemon(): Promise { // Poll for PID file with server ID for (let attempt = 0; attempt < STARTUP_POLL_MAX_ATTEMPTS; attempt++) { - const status = resolveStatus(); + const status = await resolveStatus(); if (attempt === 0 || attempt === STARTUP_POLL_MAX_ATTEMPTS - 1 || attempt % 10 === 9) { logDesktopDaemonLifecycle("polling daemon status after detached start", { attempt: attempt + 1, @@ -334,11 +337,11 @@ async function startDaemon(): Promise { await sleep(STARTUP_POLL_INTERVAL_MS); } - return resolveStatus(); + return await resolveStatus(); } async function stopDaemon(): Promise { - const status = resolveStatus(); + const status = await resolveStatus(); if (status.status !== "running" || !status.pid) return status; const pid = status.pid; @@ -354,7 +357,7 @@ async function stopDaemon(): Promise { throw new Error(`Timed out waiting for daemon PID ${pid} to stop`); } - return resolveStatus(); + return await resolveStatus(); } async function restartDaemon(): Promise { @@ -370,12 +373,12 @@ function getDaemonLogs(): DesktopDaemonLogs { }; } -function getCliDaemonStatus(): string { - return runCliTextCommand(["daemon", "status"]); +async function getCliDaemonStatus(): Promise { + return await runCliTextCommand(["daemon", "status"]); } async function getDaemonPairing(): Promise { - const status = resolveStatus(); + const status = await resolveStatus(); if (status.status !== "running") { return { relayEnabled: false, @@ -385,7 +388,7 @@ async function getDaemonPairing(): Promise { } try { - const payload = runCliJsonCommand(["daemon", "pair", "--json"]); + const payload = await runCliJsonCommand(["daemon", "pair", "--json"]); if (!isRecord(payload)) { throw new Error("Daemon pairing response was not an object."); } @@ -404,8 +407,8 @@ async function getDaemonPairing(): Promise { } } -function getLocalDaemonVersion(): { version: string | null; error: string | null } { - const status = resolveStatus(); +async function getLocalDaemonVersion(): Promise<{ version: string | null; error: string | null }> { + const status = await resolveStatus(); if (status.status !== "running") { return { version: null, error: "Daemon is not running." }; } diff --git a/packages/desktop/src/daemon/runtime-paths.ts b/packages/desktop/src/daemon/runtime-paths.ts index e56f47bc2..d464ddc22 100644 --- a/packages/desktop/src/daemon/runtime-paths.ts +++ b/packages/desktop/src/daemon/runtime-paths.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { createRequire } from "node:module"; import path from "node:path"; import { app } from "electron"; @@ -246,44 +246,60 @@ export function runCliPassthroughCommand(args: string[]): number { return result.signal ? 1 : 0; } -export function runCliTextCommand(args: string[]): string { - const invocation = createCliInvocation(args); - const result = spawnSync(invocation.command, invocation.args, { - env: invocation.env, - encoding: "utf-8", - stdio: ["ignore", "pipe", "pipe"], +function spawnAsync( + command: string, + args: string[], + options: { env: NodeJS.ProcessEnv }, +): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + child.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + child.on("error", reject); + child.on("close", (exitCode) => { + resolve({ stdout, stderr, exitCode }); + }); }); - - if (result.error) { - throw result.error; - } - - if (result.status !== 0) { - const stderr = typeof result.stderr === "string" ? result.stderr.trim() : ""; - throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.status}`); - } - - return typeof result.stdout === "string" ? result.stdout.trimEnd() : ""; } -export function runCliJsonCommand(args: string[]): unknown { +export async function runCliTextCommand(args: string[]): Promise { const invocation = createCliInvocation(args); - const result = spawnSync(invocation.command, invocation.args, { + const result = await spawnAsync(invocation.command, invocation.args, { env: invocation.env, - encoding: "utf-8", - stdio: ["ignore", "pipe", "pipe"], }); - if (result.error) { - throw result.error; + if (result.exitCode !== 0) { + const stderr = result.stderr.trim(); + throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.exitCode}`); } - if (result.status !== 0) { - const stderr = typeof result.stderr === "string" ? result.stderr.trim() : ""; - throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.status}`); + return result.stdout.trimEnd(); +} + +export async function runCliJsonCommand(args: string[]): Promise { + const invocation = createCliInvocation(args); + const result = await spawnAsync(invocation.command, invocation.args, { + env: invocation.env, + }); + + if (result.exitCode !== 0) { + const stderr = result.stderr.trim(); + throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.exitCode}`); } - const stdout = typeof result.stdout === "string" ? result.stdout.trim() : ""; + const stdout = result.stdout.trim(); if (stdout.length === 0) { throw new Error("CLI command did not produce JSON output."); }