fix(desktop): heal launch-mode/shell split-brain that hung local runtime on Windows
Desktop startup used two independent persisted sources of truth that could
disagree: desktop-launch-mode.json decided whether main STARTS the embedded
local runtime, while shell-connections.json (desktopMode) decided whether the
renderer launch gate WAITS for it. shell:setDesktopMode persists shell settings
before the fallible startLocalRuntimeOnce()/saveDesktopLaunchMode(), so a first
"local" selection whose runtime start failed or was interrupted left
shell=local / launch-mode=choose permanently. Every subsequent launch then sat
at "Starting local Fusion runtime…" polling a runtime nobody started, timing out
after 30s.
Fix (defense in depth):
- initializeApp reconciles: a completed shell "local" selection is authoritative;
it heals the launch-mode file and starts the runtime.
- onDesktopModeChange/onDesktopLaunchModeChange persist launch-mode BEFORE the
fallible start so an interrupted start cannot re-create the desync.
- DesktopLaunchGate no longer assumes main started the runtime; if it is not
running/starting it actively (re)starts via setDesktopMode("local") before polling.
- Add env-gated startup trace (FUSION_STARTUP_TRACE) so packaged builds, which
otherwise log nothing, can diagnose this class of stall.
Regression tests assert the invariant (split-brain -> runtime starts + file heals;
agreement-on-choose -> no start). flushPromises now drains via a macrotask so
run()-based tests observe a fully-initialized app regardless of async chain length.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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=<path> 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<DesktopRuntimeStatus> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,33 @@ export function resolveLocalRuntimeRoot(): string {
|
||||
|
||||
export async function initializeApp(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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" },
|
||||
|
||||
Reference in New Issue
Block a user