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;
|
return;
|
||||||
}
|
}
|
||||||
setPhase({ kind: "starting-local", message: t("desktop.startingLocalRuntime", "Starting local Fusion runtime…") });
|
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);
|
const { baseUrl } = await waitForLocalRuntime(shell);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
applyServerBaseUrl(baseUrl);
|
applyServerBaseUrl(baseUrl);
|
||||||
|
|||||||
@@ -231,8 +231,13 @@ async function importMainModule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function flushPromises() {
|
async function flushPromises() {
|
||||||
await Promise.resolve();
|
// Drain ALL pending microtasks via a macrotask boundary rather than counting a
|
||||||
await Promise.resolve();
|
// 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", () => {
|
describe("main integration", () => {
|
||||||
|
|||||||
@@ -49,7 +49,19 @@ const mocks = vi.hoisted(() => {
|
|||||||
return localRuntimeManager;
|
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", () => ({
|
vi.mock("electron", () => ({
|
||||||
@@ -67,8 +79,8 @@ vi.mock("../ipc.js", () => ({ registerIpcHandlers: vi.fn() }));
|
|||||||
vi.mock("../native.js", () => ({
|
vi.mock("../native.js", () => ({
|
||||||
DEFAULT_WINDOW_STATE: { width: 1000, height: 800 },
|
DEFAULT_WINDOW_STATE: { width: 1000, height: 800 },
|
||||||
loadWindowState: vi.fn(async () => null),
|
loadWindowState: vi.fn(async () => null),
|
||||||
loadDesktopLaunchMode: vi.fn(async () => "choose"),
|
loadDesktopLaunchMode: mocks.loadDesktopLaunchMode,
|
||||||
saveDesktopLaunchMode: vi.fn(async () => undefined),
|
saveDesktopLaunchMode: mocks.saveDesktopLaunchMode,
|
||||||
saveWindowState: vi.fn(),
|
saveWindowState: vi.fn(),
|
||||||
setupAutoUpdater: vi.fn(),
|
setupAutoUpdater: vi.fn(),
|
||||||
startUpdateCheckInterval: vi.fn(() => 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("../deep-link.js", () => ({ registerDeepLinkProtocol: vi.fn(), setupDeepLinkHandler: vi.fn() }));
|
||||||
vi.mock("../local-runtime.js", () => ({ LocalRuntimeManager: mocks.LocalRuntimeManager }));
|
vi.mock("../local-runtime.js", () => ({ LocalRuntimeManager: mocks.LocalRuntimeManager }));
|
||||||
|
vi.mock("../shell-settings.js", () => ({
|
||||||
|
readShellSettings: mocks.readShellSettings,
|
||||||
|
writeShellSettings: mocks.writeShellSettings,
|
||||||
|
}));
|
||||||
|
|
||||||
describe("main local mode", () => {
|
describe("main local mode", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mocks.appHandlers.clear();
|
mocks.appHandlers.clear();
|
||||||
|
mocks.readShellSettings.mockResolvedValue({ ...mocks.DEFAULT_SHELL_SETTINGS });
|
||||||
|
mocks.loadDesktopLaunchMode.mockResolvedValue("choose");
|
||||||
delete process.env.FUSION_DESKTOP_MODE;
|
delete process.env.FUSION_DESKTOP_MODE;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,4 +113,41 @@ describe("main local mode", () => {
|
|||||||
expect(mocks.localRuntimeManager.startLocal).toHaveBeenCalled();
|
expect(mocks.localRuntimeManager.startLocal).toHaveBeenCalled();
|
||||||
delete process.env.FUSION_DESKTOP_MODE;
|
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 { once } from "node:events";
|
||||||
|
import { appendFileSync } from "node:fs";
|
||||||
import type { Server } from "node:http";
|
import type { Server } from "node:http";
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
|
|
||||||
import { ensureDesktopRuntimeProject } from "./engine-runtime.js";
|
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 RuntimeSource = "embedded-local" | "external-cli" | "none";
|
||||||
export type RuntimeState = "stopped" | "starting" | "running" | "error";
|
export type RuntimeState = "stopped" | "starting" | "running" | "error";
|
||||||
|
|
||||||
@@ -60,11 +81,17 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
strace("createDashboardServer: centralCore.init");
|
||||||
await centralCore.init();
|
await centralCore.init();
|
||||||
|
strace("createDashboardServer: ensureDesktopRuntimeProject");
|
||||||
const rootProject = await ensureDesktopRuntimeProject(centralCore, rootDir);
|
const rootProject = await ensureDesktopRuntimeProject(centralCore, rootDir);
|
||||||
|
strace(`createDashboardServer: startAll (rootProject=${rootProject.id})`);
|
||||||
await engineManager.startAll();
|
await engineManager.startAll();
|
||||||
|
strace("createDashboardServer: startAll DONE; startReconciliation");
|
||||||
engineManager.startReconciliation();
|
engineManager.startReconciliation();
|
||||||
|
strace("createDashboardServer: ensureEngine(rootProject)");
|
||||||
const primaryEngine = await engineManager.ensureEngine(rootProject.id);
|
const primaryEngine = await engineManager.ensureEngine(rootProject.id);
|
||||||
|
strace("createDashboardServer: createServer");
|
||||||
const app = createServer(store as never, {
|
const app = createServer(store as never, {
|
||||||
engine: primaryEngine,
|
engine: primaryEngine,
|
||||||
engineManager,
|
engineManager,
|
||||||
@@ -72,11 +99,15 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin
|
|||||||
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
|
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
strace("createDashboardServer: app.listen(0)");
|
||||||
|
const server = app.listen(0);
|
||||||
|
strace("createDashboardServer: returning server object");
|
||||||
return {
|
return {
|
||||||
server: app.listen(0),
|
server,
|
||||||
cleanup,
|
cleanup,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
strace(`createDashboardServer: THREW ${error instanceof Error ? error.stack : String(error)}`);
|
||||||
await cleanup();
|
await cleanup();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -148,8 +179,10 @@ export class LocalRuntimeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async startLocal(): Promise<DesktopRuntimeStatus> {
|
async startLocal(): Promise<DesktopRuntimeStatus> {
|
||||||
|
strace("startLocal: ENTER");
|
||||||
const externalPort = this.getExternalPort();
|
const externalPort = this.getExternalPort();
|
||||||
if (externalPort) {
|
if (externalPort) {
|
||||||
|
strace(`startLocal: external-cli branch (FUSION_SERVER_PORT=${externalPort}) — NOT starting embedded`);
|
||||||
this.status = {
|
this.status = {
|
||||||
source: "external-cli",
|
source: "external-cli",
|
||||||
state: "running",
|
state: "running",
|
||||||
@@ -183,13 +216,18 @@ export class LocalRuntimeManager {
|
|||||||
let cleanup: RuntimeCleanup | undefined;
|
let cleanup: RuntimeCleanup | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
strace(`startEmbedded: BEGIN rootDir=${this.options.rootDir}`);
|
||||||
store = await this.createStore(this.options.rootDir);
|
store = await this.createStore(this.options.rootDir);
|
||||||
|
strace("startEmbedded: store.init");
|
||||||
await store.init();
|
await store.init();
|
||||||
|
strace("startEmbedded: store.watch");
|
||||||
await store.watch();
|
await store.watch();
|
||||||
|
strace("startEmbedded: createDashboardServer()");
|
||||||
|
|
||||||
const dashboardServer = await this.createDashboardServer(store, this.options.rootDir);
|
const dashboardServer = await this.createDashboardServer(store, this.options.rootDir);
|
||||||
cleanup = "server" in dashboardServer ? dashboardServer.cleanup : undefined;
|
cleanup = "server" in dashboardServer ? dashboardServer.cleanup : undefined;
|
||||||
server = "server" in dashboardServer ? dashboardServer.server : dashboardServer;
|
server = "server" in dashboardServer ? dashboardServer.server : dashboardServer;
|
||||||
|
strace("startEmbedded: awaiting server 'listening' | 'error'");
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
once(server, "listening"),
|
once(server, "listening"),
|
||||||
once(server, "error").then(([error]) => {
|
once(server, "error").then(([error]) => {
|
||||||
@@ -201,6 +239,7 @@ export class LocalRuntimeManager {
|
|||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
this.runtime = { store, server, port, baseUrl, cleanup };
|
this.runtime = { store, server, port, baseUrl, cleanup };
|
||||||
this.status = { source: "embedded-local", state: "running", port, baseUrl };
|
this.status = { source: "embedded-local", state: "running", port, baseUrl };
|
||||||
|
strace(`startEmbedded: RUNNING port=${port}`);
|
||||||
return this.status;
|
return this.status;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (server) {
|
if (server) {
|
||||||
@@ -218,6 +257,7 @@ export class LocalRuntimeManager {
|
|||||||
state: "error",
|
state: "error",
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
};
|
};
|
||||||
|
strace(`startEmbedded: CATCH/ERROR ${error instanceof Error ? error.stack : String(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,33 @@ export function resolveLocalRuntimeRoot(): string {
|
|||||||
|
|
||||||
export async function initializeApp(): Promise<void> {
|
export async function initializeApp(): Promise<void> {
|
||||||
const state = await loadWindowState();
|
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() });
|
localRuntimeManager = new LocalRuntimeManager({ rootDir: resolveLocalRuntimeRoot() });
|
||||||
currentDesktopLaunchMode = rememberedLaunchMode;
|
currentDesktopLaunchMode = rememberedLaunchMode;
|
||||||
@@ -274,13 +300,23 @@ export async function initializeApp(): Promise<void> {
|
|||||||
if (mode === "local") {
|
if (mode === "local") {
|
||||||
currentRemoteLaunch = null;
|
currentRemoteLaunch = null;
|
||||||
localRuntimeStartupAttempted = false;
|
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();
|
await startLocalRuntimeOnce();
|
||||||
} else {
|
return;
|
||||||
localRuntimeStartupAttempted = false;
|
|
||||||
await localRuntimeManager.stopLocal();
|
|
||||||
const shellSettings = await readShellSettings();
|
|
||||||
currentRemoteLaunch = normalizeDesktopRemoteLaunch({ ...shellSettings, desktopMode: "remote" });
|
|
||||||
}
|
}
|
||||||
|
localRuntimeStartupAttempted = false;
|
||||||
|
await localRuntimeManager.stopLocal();
|
||||||
|
const shellSettings = await readShellSettings();
|
||||||
|
currentRemoteLaunch = normalizeDesktopRemoteLaunch({ ...shellSettings, desktopMode: "remote" });
|
||||||
await saveDesktopLaunchMode(mode);
|
await saveDesktopLaunchMode(mode);
|
||||||
},
|
},
|
||||||
onDesktopLaunchModeChange: async (mode) => {
|
onDesktopLaunchModeChange: async (mode) => {
|
||||||
@@ -291,12 +327,14 @@ export async function initializeApp(): Promise<void> {
|
|||||||
localRuntimeStartupAttempted = false;
|
localRuntimeStartupAttempted = false;
|
||||||
if (mode === "local") {
|
if (mode === "local") {
|
||||||
currentRemoteLaunch = null;
|
currentRemoteLaunch = null;
|
||||||
|
// FNXC:DesktopRuntimeMode 2026-07-02-14:35: persist before the fallible start (see onDesktopModeChange).
|
||||||
|
await saveDesktopLaunchMode(mode);
|
||||||
await startLocalRuntimeOnce();
|
await startLocalRuntimeOnce();
|
||||||
} else {
|
return;
|
||||||
await localRuntimeManager.stopLocal();
|
|
||||||
const shellSettings = await readShellSettings();
|
|
||||||
currentRemoteLaunch = normalizeDesktopRemoteLaunch({ ...shellSettings, desktopMode: "remote" });
|
|
||||||
}
|
}
|
||||||
|
await localRuntimeManager.stopLocal();
|
||||||
|
const shellSettings = await readShellSettings();
|
||||||
|
currentRemoteLaunch = normalizeDesktopRemoteLaunch({ ...shellSettings, desktopMode: "remote" });
|
||||||
await saveDesktopLaunchMode(mode);
|
await saveDesktopLaunchMode(mode);
|
||||||
},
|
},
|
||||||
getRuntimeStatus: () => localRuntimeManager?.getStatus() ?? { source: "none", state: "stopped" },
|
getRuntimeStatus: () => localRuntimeManager?.getStatus() ?? { source: "none", state: "stopped" },
|
||||||
|
|||||||
Reference in New Issue
Block a user