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:
gsxdsm
2026-07-02 15:20:15 -07:00
parent f33b701eee
commit 78f0bc3190
5 changed files with 180 additions and 16 deletions

View File

@@ -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);

View File

@@ -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", () => {

View File

@@ -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();
});
});

View File

@@ -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;
}
}

View File

@@ -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" },