diff --git a/packages/dashboard/app/components/DesktopLaunchGate.tsx b/packages/dashboard/app/components/DesktopLaunchGate.tsx index cdfe2d357f..5d6d8faa88 100644 --- a/packages/dashboard/app/components/DesktopLaunchGate.tsx +++ b/packages/dashboard/app/components/DesktopLaunchGate.tsx @@ -98,6 +98,32 @@ export function DesktopLaunchGate({ children }: PropsWithChildren) { return; } setPhase({ kind: "starting-local", message: t("desktop.startingLocalRuntime", "Starting local Fusion runtime…") }); + /* + * FNXC:DesktopLaunchGate 2026-07-02-14:35: + * Self-healing start. Do NOT assume main already started the embedded runtime. + * The gate decides to WAIT from shell `desktopMode:"local"`, but main decides to + * START from a separate launch-mode file; when those desync (a first local + * selection whose runtime start failed/was interrupted), main never starts the + * runtime and this branch would poll a permanently "stopped" runtime until the 30s + * timeout — the "hangs at Starting local runtime" bug. If the runtime is not already + * running or starting, actively (re)start it via setDesktopMode("local") — idempotent + * and awaits startup — before polling, so the gate can never wait for a runtime nobody + * launched. + */ + const rt = state.localRuntime; + if (rt?.state !== "running" && rt?.state !== "starting") { + try { + await shell.setDesktopMode("local"); + } catch (startError) { + if (cancelled) return; + setPhase({ + kind: "local-error", + message: startError instanceof Error ? startError.message : String(startError), + }); + return; + } + if (cancelled) return; + } const { baseUrl } = await waitForLocalRuntime(shell); if (cancelled) return; applyServerBaseUrl(baseUrl); diff --git a/packages/desktop/src/__tests__/main-integration.test.ts b/packages/desktop/src/__tests__/main-integration.test.ts index 945f6fdc51..85cbe0501b 100644 --- a/packages/desktop/src/__tests__/main-integration.test.ts +++ b/packages/desktop/src/__tests__/main-integration.test.ts @@ -231,8 +231,13 @@ async function importMainModule() { } async function flushPromises() { - await Promise.resolve(); - await Promise.resolve(); + // Drain ALL pending microtasks via a macrotask boundary rather than counting a + // fixed number of ticks. initializeApp()'s async chain length is an implementation + // detail (e.g. the launch-mode/shell split-brain reconciliation adds a readShellSettings + // await); a hardcoded tick count silently under-flushes when that chain grows and the + // run()-based tests then observe a half-initialized app. setTimeout(0) waits for the + // whole microtask queue to settle, so these tests assert on a fully-initialized app. + await new Promise((resolve) => setTimeout(resolve, 0)); } describe("main integration", () => { diff --git a/packages/desktop/src/__tests__/main-local-mode.test.ts b/packages/desktop/src/__tests__/main-local-mode.test.ts index 6652046ff1..61334a98df 100644 --- a/packages/desktop/src/__tests__/main-local-mode.test.ts +++ b/packages/desktop/src/__tests__/main-local-mode.test.ts @@ -49,7 +49,19 @@ const mocks = vi.hoisted(() => { return localRuntimeManager; }); - return { app, appHandlers, BrowserWindow, Tray, browserWindow, localRuntimeManager, LocalRuntimeManager, screen }; + const DEFAULT_SHELL_SETTINGS = { + desktopMode: null as "local" | "remote" | null, + hasCompletedModeSelection: false, + activeProfileId: null, + profiles: [], + }; + const readShellSettings = vi.fn(async () => ({ ...DEFAULT_SHELL_SETTINGS })); + const writeShellSettings = vi.fn(async () => undefined); + + const loadDesktopLaunchMode = vi.fn(async () => "choose" as "choose" | "local" | "remote"); + const saveDesktopLaunchMode = vi.fn(async () => undefined); + + return { app, appHandlers, BrowserWindow, Tray, browserWindow, localRuntimeManager, LocalRuntimeManager, screen, readShellSettings, writeShellSettings, DEFAULT_SHELL_SETTINGS, loadDesktopLaunchMode, saveDesktopLaunchMode }; }); vi.mock("electron", () => ({ @@ -67,8 +79,8 @@ vi.mock("../ipc.js", () => ({ registerIpcHandlers: vi.fn() })); vi.mock("../native.js", () => ({ DEFAULT_WINDOW_STATE: { width: 1000, height: 800 }, loadWindowState: vi.fn(async () => null), - loadDesktopLaunchMode: vi.fn(async () => "choose"), - saveDesktopLaunchMode: vi.fn(async () => undefined), + loadDesktopLaunchMode: mocks.loadDesktopLaunchMode, + saveDesktopLaunchMode: mocks.saveDesktopLaunchMode, saveWindowState: vi.fn(), setupAutoUpdater: vi.fn(), startUpdateCheckInterval: vi.fn(() => vi.fn()), @@ -76,12 +88,18 @@ vi.mock("../native.js", () => ({ })); vi.mock("../deep-link.js", () => ({ registerDeepLinkProtocol: vi.fn(), setupDeepLinkHandler: vi.fn() })); vi.mock("../local-runtime.js", () => ({ LocalRuntimeManager: mocks.LocalRuntimeManager })); +vi.mock("../shell-settings.js", () => ({ + readShellSettings: mocks.readShellSettings, + writeShellSettings: mocks.writeShellSettings, +})); describe("main local mode", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); mocks.appHandlers.clear(); + mocks.readShellSettings.mockResolvedValue({ ...mocks.DEFAULT_SHELL_SETTINGS }); + mocks.loadDesktopLaunchMode.mockResolvedValue("choose"); delete process.env.FUSION_DESKTOP_MODE; }); @@ -95,4 +113,41 @@ describe("main local mode", () => { expect(mocks.localRuntimeManager.startLocal).toHaveBeenCalled(); delete process.env.FUSION_DESKTOP_MODE; }); + + /* + * FNXC:DesktopRuntimeMode 2026-07-02-14:35 — regression for the "hangs at Starting + * local Fusion runtime" bug. Split-brain persisted state: the launch-mode file says + * "choose" (so main would not start the runtime) while shell-connections.json records a + * completed "local" selection (so the renderer gate waits for the runtime). Before the + * fix the runtime was never started and the gate polled until a 30s timeout on EVERY + * launch. initializeApp must reconcile: treat the completed shell "local" as authoritative, + * heal the launch-mode file, and start the runtime. + */ + it("reconciles a launch-mode/shell split-brain and starts the runtime (no FUSION_DESKTOP_MODE)", async () => { + mocks.loadDesktopLaunchMode.mockResolvedValue("choose"); + mocks.readShellSettings.mockResolvedValue({ + desktopMode: "local", + hasCompletedModeSelection: true, + activeProfileId: null, + profiles: [], + }); + + const { initializeApp } = await import("../main.ts"); + await initializeApp(); + + // Runtime is started despite launch-mode being "choose" ... + expect(mocks.localRuntimeManager.startLocal).toHaveBeenCalled(); + // ... and the launch-mode file is healed to "local" so both sources agree next launch. + expect(mocks.saveDesktopLaunchMode).toHaveBeenCalledWith("local"); + }); + + it("does NOT start the runtime when both sources agree on choose (first run / no selection)", async () => { + mocks.loadDesktopLaunchMode.mockResolvedValue("choose"); + mocks.readShellSettings.mockResolvedValue({ ...mocks.DEFAULT_SHELL_SETTINGS }); + + const { initializeApp } = await import("../main.ts"); + await initializeApp(); + + expect(mocks.localRuntimeManager.startLocal).not.toHaveBeenCalled(); + }); }); diff --git a/packages/desktop/src/local-runtime.ts b/packages/desktop/src/local-runtime.ts index fb5d654f19..2a8bf4db6e 100644 --- a/packages/desktop/src/local-runtime.ts +++ b/packages/desktop/src/local-runtime.ts @@ -1,9 +1,30 @@ import { once } from "node:events"; +import { appendFileSync } from "node:fs"; import type { Server } from "node:http"; import type { AddressInfo } from "node:net"; import { ensureDesktopRuntimeProject } from "./engine-runtime.js"; +/* + * FNXC:DesktopRuntime 2026-07-02-14:35: + * Env-gated startup trace. Packaged desktop builds have no file logging, so a stalled + * or failed embedded-runtime start is invisible to operators (the symptom is only a + * spinner that times out). Setting FUSION_STARTUP_TRACE= appends a timestamped + * step-by-step trace of startLocal()/startEmbedded()/createDashboardServer() to that + * file — the diagnostic that pinpointed the launch-mode split-brain hang. Zero cost + * when unset; keep it so this class of hang is diagnosable in the field. + */ +const STARTUP_TRACE_FILE = process.env.FUSION_STARTUP_TRACE; +const __traceStart = Date.now(); +function strace(msg: string): void { + if (!STARTUP_TRACE_FILE) return; + try { + appendFileSync(STARTUP_TRACE_FILE, `[+${((Date.now() - __traceStart) / 1000).toFixed(2)}s] ${msg}\n`); + } catch { + // best-effort + } +} + export type RuntimeSource = "embedded-local" | "external-cli" | "none"; export type RuntimeState = "stopped" | "starting" | "running" | "error"; @@ -60,11 +81,17 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin }; try { + strace("createDashboardServer: centralCore.init"); await centralCore.init(); + strace("createDashboardServer: ensureDesktopRuntimeProject"); const rootProject = await ensureDesktopRuntimeProject(centralCore, rootDir); + strace(`createDashboardServer: startAll (rootProject=${rootProject.id})`); await engineManager.startAll(); + strace("createDashboardServer: startAll DONE; startReconciliation"); engineManager.startReconciliation(); + strace("createDashboardServer: ensureEngine(rootProject)"); const primaryEngine = await engineManager.ensureEngine(rootProject.id); + strace("createDashboardServer: createServer"); const app = createServer(store as never, { engine: primaryEngine, engineManager, @@ -72,11 +99,15 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId), }); + strace("createDashboardServer: app.listen(0)"); + const server = app.listen(0); + strace("createDashboardServer: returning server object"); return { - server: app.listen(0), + server, cleanup, }; } catch (error) { + strace(`createDashboardServer: THREW ${error instanceof Error ? error.stack : String(error)}`); await cleanup(); throw error; } @@ -148,8 +179,10 @@ export class LocalRuntimeManager { } async startLocal(): Promise { + strace("startLocal: ENTER"); const externalPort = this.getExternalPort(); if (externalPort) { + strace(`startLocal: external-cli branch (FUSION_SERVER_PORT=${externalPort}) — NOT starting embedded`); this.status = { source: "external-cli", state: "running", @@ -183,13 +216,18 @@ export class LocalRuntimeManager { let cleanup: RuntimeCleanup | undefined; try { + strace(`startEmbedded: BEGIN rootDir=${this.options.rootDir}`); store = await this.createStore(this.options.rootDir); + strace("startEmbedded: store.init"); await store.init(); + strace("startEmbedded: store.watch"); await store.watch(); + strace("startEmbedded: createDashboardServer()"); const dashboardServer = await this.createDashboardServer(store, this.options.rootDir); cleanup = "server" in dashboardServer ? dashboardServer.cleanup : undefined; server = "server" in dashboardServer ? dashboardServer.server : dashboardServer; + strace("startEmbedded: awaiting server 'listening' | 'error'"); await Promise.race([ once(server, "listening"), once(server, "error").then(([error]) => { @@ -201,6 +239,7 @@ export class LocalRuntimeManager { const baseUrl = `http://127.0.0.1:${port}`; this.runtime = { store, server, port, baseUrl, cleanup }; this.status = { source: "embedded-local", state: "running", port, baseUrl }; + strace(`startEmbedded: RUNNING port=${port}`); return this.status; } catch (error) { if (server) { @@ -218,6 +257,7 @@ export class LocalRuntimeManager { state: "error", error: error instanceof Error ? error.message : String(error), }; + strace(`startEmbedded: CATCH/ERROR ${error instanceof Error ? error.stack : String(error)}`); throw error; } } diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 84b8cb58cf..75f3bc2524 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -181,7 +181,33 @@ export function resolveLocalRuntimeRoot(): string { export async function initializeApp(): Promise { const state = await loadWindowState(); - const rememberedLaunchMode = await loadDesktopLaunchMode(); + let rememberedLaunchMode = await loadDesktopLaunchMode(); + + /* + * FNXC:DesktopRuntimeMode 2026-07-02-14:35: + * Split-brain reconciliation. Desktop startup has TWO persisted sources of truth + * that must agree: `desktop-launch-mode.json` (loadDesktopLaunchMode) decides whether + * THIS function STARTS the embedded local runtime, while `shell-connections.json` + * (readShellSettings().desktopMode) decides whether the renderer launch gate WAITS + * for it. They desync because shell:setDesktopMode persists shell settings BEFORE the + * fallible startLocalRuntimeOnce()/saveDesktopLaunchMode() — so a first local selection + * whose runtime start throws or is interrupted leaves shell=`local` but launch-mode=`choose`. + * The gate then shows "Starting local Fusion runtime…" and polls forever for a runtime + * nobody started, timing out after 30s on EVERY launch. Treat a completed shell "local" + * selection as authoritative and heal the launch-mode file so both halves agree. + */ + const reconcileShellSettings = await readShellSettings(); + if ( + rememberedLaunchMode !== "local" && + reconcileShellSettings.desktopMode === "local" && + reconcileShellSettings.hasCompletedModeSelection === true + ) { + console.warn( + `[desktop/main] Healing launch-mode split-brain: shell desktopMode="local" but launch-mode="${rememberedLaunchMode}"; adopting "local"`, + ); + rememberedLaunchMode = "local"; + await saveDesktopLaunchMode("local"); + } localRuntimeManager = new LocalRuntimeManager({ rootDir: resolveLocalRuntimeRoot() }); currentDesktopLaunchMode = rememberedLaunchMode; @@ -274,13 +300,23 @@ export async function initializeApp(): Promise { if (mode === "local") { currentRemoteLaunch = null; localRuntimeStartupAttempted = false; + /* + * FNXC:DesktopRuntimeMode 2026-07-02-14:35: + * Persist launch-mode BEFORE the fallible startLocalRuntimeOnce(). shell:setDesktopMode + * already wrote shell-connections.json `desktopMode:"local"` before invoking this callback; + * if the runtime start throws/is interrupted and we saved launch-mode only afterward, the two + * files desync (shell=local, launch-mode=choose) and every future launch hangs at "Starting + * local runtime". Saving first keeps both sources in agreement so a failed start simply retries + * on next launch instead of deadlocking. + */ + await saveDesktopLaunchMode(mode); await startLocalRuntimeOnce(); - } else { - localRuntimeStartupAttempted = false; - await localRuntimeManager.stopLocal(); - const shellSettings = await readShellSettings(); - currentRemoteLaunch = normalizeDesktopRemoteLaunch({ ...shellSettings, desktopMode: "remote" }); + return; } + localRuntimeStartupAttempted = false; + await localRuntimeManager.stopLocal(); + const shellSettings = await readShellSettings(); + currentRemoteLaunch = normalizeDesktopRemoteLaunch({ ...shellSettings, desktopMode: "remote" }); await saveDesktopLaunchMode(mode); }, onDesktopLaunchModeChange: async (mode) => { @@ -291,12 +327,14 @@ export async function initializeApp(): Promise { localRuntimeStartupAttempted = false; if (mode === "local") { currentRemoteLaunch = null; + // FNXC:DesktopRuntimeMode 2026-07-02-14:35: persist before the fallible start (see onDesktopModeChange). + await saveDesktopLaunchMode(mode); await startLocalRuntimeOnce(); - } else { - await localRuntimeManager.stopLocal(); - const shellSettings = await readShellSettings(); - currentRemoteLaunch = normalizeDesktopRemoteLaunch({ ...shellSettings, desktopMode: "remote" }); + return; } + await localRuntimeManager.stopLocal(); + const shellSettings = await readShellSettings(); + currentRemoteLaunch = normalizeDesktopRemoteLaunch({ ...shellSettings, desktopMode: "remote" }); await saveDesktopLaunchMode(mode); }, getRuntimeStatus: () => localRuntimeManager?.getStatus() ?? { source: "none", state: "stopped" },