From f87e11387f860c4aab15a2dadaad689dac6ab352 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 5 Jul 2026 21:17:26 -0700 Subject: [PATCH] FN-7617: retry embedded desktop runtime startup to fix transient Windows launch failure Adds bounded internal retry to the desktop embedded-start path so a transient first-attempt failure self-heals before the operator ever sees the "Couldn't start local Fusion" error screen. - LocalRuntimeManager.startEmbedded() now retries startEmbeddedAttempt() up to startupRetries (default 3) total attempts with a startupRetryDelayMs (default 150ms) delay between attempts, both overridable via constructor options for deterministic zero-delay tests. - status.state stays "starting" across retried attempts; only the final attempt's real error sets state "error" and is thrown/surfaced, so genuine failures still report their real message unchanged. - Only affects the embedded-start path (never external-cli or already-running paths). - Adds regression tests covering retry-then-success, exhausted-retries-surfaces-final-error, and status transitions across attempts. Files changed: .../desktop/src/__tests__/local-runtime.test.ts | 158 ++++++++++++++++++++- packages/desktop/src/local-runtime.ts | 76 +++++++++- 2 files changed, 228 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-7617 Fusion-Task-Lineage: 2baf90f0-052c-42ff-a5b1-bc0b802d0ddc Co-authored-by: Fusion (runfusion.ai) --- .../src/__tests__/local-runtime.test.ts | 158 +++++++++++++++++- packages/desktop/src/local-runtime.ts | 76 ++++++++- 2 files changed, 228 insertions(+), 6 deletions(-) diff --git a/packages/desktop/src/__tests__/local-runtime.test.ts b/packages/desktop/src/__tests__/local-runtime.test.ts index f5ab008748..af16fe2a19 100644 --- a/packages/desktop/src/__tests__/local-runtime.test.ts +++ b/packages/desktop/src/__tests__/local-runtime.test.ts @@ -97,12 +97,15 @@ describe("LocalRuntimeManager", () => { expect(store.init).not.toHaveBeenCalled(); }); - it("rolls back and exposes error status when startup fails", async () => { + it("rolls back and exposes error status when startup fails (single attempt, no retry)", async () => { const { LocalRuntimeManager } = await import("../local-runtime.ts"); store.init.mockRejectedValueOnce(new Error("init failed")); const manager = new LocalRuntimeManager({ rootDir: "/repo", createStore: async () => store, + // Pin to a single attempt — this test asserts pre-retry single-attempt rollback + // semantics; the multi-attempt self-heal/persistent-failure behavior is covered below. + startupRetries: 1, }); await expect(manager.startLocal()).rejects.toThrow("init failed"); @@ -123,6 +126,9 @@ describe("LocalRuntimeManager", () => { createDashboardServer: async () => { throw importError; }, + // Isolate the message-preservation assertion from retry mechanics (covered separately below). + startupRetries: 1, + startupRetryDelayMs: 0, }); await expect(manager.startLocal()).rejects.toThrow("ERR_IMPORT_ATTRIBUTE_MISSING"); @@ -134,6 +140,156 @@ describe("LocalRuntimeManager", () => { }); }); + it("self-heals a transient store.init failure on attempt 1 (createStore path)", async () => { + const { LocalRuntimeManager } = await import("../local-runtime.ts"); + store.init.mockRejectedValueOnce(new Error("transient windows init failure")); + const server = new FakeServer(4545); + const manager = new LocalRuntimeManager({ + rootDir: "/repo", + createStore: async () => store, + createDashboardServer: async () => { + setTimeout(() => server.emit("listening"), 0); + return server as unknown as Server; + }, + startupRetries: 3, + startupRetryDelayMs: 0, + }); + + const status = await manager.startLocal(); + + expect(status).toMatchObject({ source: "embedded-local", state: "running", port: 4545 }); + expect(manager.getStatus().state).toBe("running"); + expect(store.init).toHaveBeenCalledTimes(2); + }); + + it("self-heals a transient createDashboardServer failure on attempt 1", async () => { + const { LocalRuntimeManager } = await import("../local-runtime.ts"); + const server = new FakeServer(4545); + let calls = 0; + const manager = new LocalRuntimeManager({ + rootDir: "/repo", + createStore: async () => store, + createDashboardServer: async () => { + calls += 1; + if (calls === 1) { + throw new Error("transient dashboard server boot failure"); + } + setTimeout(() => server.emit("listening"), 0); + return server as unknown as Server; + }, + startupRetries: 3, + startupRetryDelayMs: 0, + }); + + const status = await manager.startLocal(); + + expect(status).toMatchObject({ source: "embedded-local", state: "running", port: 4545 }); + expect(calls).toBe(2); + }); + + it("exhausts retries and surfaces the final real error when every attempt fails", async () => { + const { LocalRuntimeManager } = await import("../local-runtime.ts"); + const errors = [new Error("attempt 1 failed"), new Error("attempt 2 failed"), new Error("attempt 3 failed — real cause")]; + let calls = 0; + const manager = new LocalRuntimeManager({ + rootDir: "/repo", + createStore: async () => store, + createDashboardServer: async () => { + const error = errors[calls]; + calls += 1; + throw error; + }, + startupRetries: 3, + startupRetryDelayMs: 0, + }); + + await expect(manager.startLocal()).rejects.toThrow("attempt 3 failed — real cause"); + expect(calls).toBe(3); + expect(manager.getStatus()).toMatchObject({ + source: "embedded-local", + state: "error", + error: "attempt 3 failed — real cause", + }); + }); + + it("fully cleans up store/server/cleanup between a failed attempt and the retry that follows", async () => { + const { LocalRuntimeManager } = await import("../local-runtime.ts"); + const cleanupFns: Array> = []; + const servers: FakeServer[] = []; + let calls = 0; + const manager = new LocalRuntimeManager({ + rootDir: "/repo", + createStore: async () => store, + createDashboardServer: async () => { + calls += 1; + const cleanup = vi.fn(async () => undefined); + cleanupFns.push(cleanup); + if (calls === 1) { + const failingServer = new FakeServer(0); + servers.push(failingServer); + setTimeout(() => failingServer.emit("error", new Error("transient listen error")), 0); + return { server: failingServer as unknown as Server, cleanup }; + } + const server = new FakeServer(4545); + servers.push(server); + setTimeout(() => server.emit("listening"), 0); + return { server: server as unknown as Server, cleanup }; + }, + startupRetries: 3, + startupRetryDelayMs: 0, + }); + + const closeSpies = servers.map(() => vi.fn()); + + const status = await manager.startLocal(); + + expect(status.state).toBe("running"); + expect(cleanupFns[0]).toHaveBeenCalledTimes(1); + expect(cleanupFns[1]).toHaveBeenCalledTimes(0); + expect(store.close).toHaveBeenCalledTimes(1); // only the failed attempt's store was closed + void closeSpies; + }); + + it("does not retry the external-cli branch", async () => { + const { LocalRuntimeManager } = await import("../local-runtime.ts"); + const createStore = vi.fn(async () => store); + const manager = new LocalRuntimeManager({ + rootDir: "/repo", + getExternalPort: () => 7777, + createStore, + createDashboardServer: async () => new FakeServer(4545) as unknown as Server, + startupRetries: 3, + startupRetryDelayMs: 0, + }); + + const status = await manager.startLocal(); + + expect(status).toMatchObject({ source: "external-cli", state: "running", port: 7777 }); + expect(createStore).not.toHaveBeenCalled(); + }); + + it("does not retry the already-running branch", async () => { + const { LocalRuntimeManager } = await import("../local-runtime.ts"); + const server = new FakeServer(4545); + const createStore = vi.fn(async () => store); + const manager = new LocalRuntimeManager({ + rootDir: "/repo", + createStore, + createDashboardServer: async () => { + setTimeout(() => server.emit("listening"), 0); + return server as unknown as Server; + }, + startupRetries: 3, + startupRetryDelayMs: 0, + }); + + await manager.startLocal(); + const secondStatus = await manager.startLocal(); + + expect(secondStatus).toMatchObject({ source: "embedded-local", state: "running", port: 4545 }); + expect(createStore).toHaveBeenCalledTimes(1); + }); + it("stopLocal is idempotent and no-op when inactive", async () => { const { LocalRuntimeManager } = await import("../local-runtime.ts"); const server = new FakeServer(4545); diff --git a/packages/desktop/src/local-runtime.ts b/packages/desktop/src/local-runtime.ts index b6d7fc3d38..8fa5eb7f06 100644 --- a/packages/desktop/src/local-runtime.ts +++ b/packages/desktop/src/local-runtime.ts @@ -57,6 +57,28 @@ export interface LocalRuntimeManagerOptions { getExternalPort?: () => number | undefined; createStore?: (rootDir: string) => Promise; createDashboardServer?: (store: TaskStoreLike, rootDir: string) => Promise; + /** + * FNXC:DesktopRuntime 2026-07-05-00:00: + * Total attempts (including the first) for embedded startup. Field reports (FN-7617) + * show Windows first-launch embedded starts intermittently throw once (during store + * init/watch or dashboard-server boot) and then succeed immediately on a manual + * Retry (a renderer reload that re-invokes startLocal()). Default 3 total attempts + * so that self-heal happens inside the manager before the operator ever sees the + * "Couldn't start local Fusion" error screen. Only applies to the embedded-start + * path (never external-cli / already-running). Overridable so tests can drive + * deterministic attempt counts with zero delay. + */ + startupRetries?: number; + /** Delay between failed embedded-start attempts, in ms. Overridable (use 0 in tests). */ + startupRetryDelayMs?: number; +} + +const DEFAULT_STARTUP_RETRIES = 3; +const DEFAULT_STARTUP_RETRY_DELAY_MS = 150; + +function delay(ms: number): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve) => setTimeout(resolve, ms)); } async function createStoreDefault(rootDir: string): Promise { @@ -167,11 +189,15 @@ export class LocalRuntimeManager { private readonly getExternalPort: () => number | undefined; private readonly createStore: (rootDir: string) => Promise; private readonly createDashboardServer: (store: TaskStoreLike, rootDir: string) => Promise; + private readonly startupRetries: number; + private readonly startupRetryDelayMs: number; constructor(private readonly options: LocalRuntimeManagerOptions) { this.getExternalPort = options.getExternalPort ?? (() => parsePort(process.env.FUSION_SERVER_PORT)); this.createStore = options.createStore ?? createStoreDefault; this.createDashboardServer = options.createDashboardServer ?? createDashboardServerDefault; + this.startupRetries = Math.max(1, options.startupRetries ?? DEFAULT_STARTUP_RETRIES); + this.startupRetryDelayMs = options.startupRetryDelayMs ?? DEFAULT_STARTUP_RETRY_DELAY_MS; } getStatus(): DesktopRuntimeStatus { @@ -233,7 +259,52 @@ export class LocalRuntimeManager { } } + /* + * FNXC:DesktopRuntime 2026-07-05-00:00: + * Windows first-launch field report (FN-7617): the embedded runtime start intermittently + * throws on its very first attempt (store init/watch or dashboard-server boot), but a + * manual Retry (a renderer reload that re-invokes startLocal()) always succeeds — proving + * the failure is transient rather than a hard misconfiguration. Rather than surface that + * transient to the renderer (which renders the scary "Couldn't start local Fusion" + * local-error phase in DesktopLaunchGate.tsx), retry the embedded attempt internally, + * bounded and with full cleanup between attempts, so a healthy install self-heals before + * the operator ever sees an error screen. `status.state` stays "starting" across retries; + * only the LAST attempt's real error sets state "error" and is thrown, so genuine failures + * (e.g. a bad dashboard import) still surface their real message unchanged. + */ private async startEmbedded(): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= this.startupRetries; attempt++) { + strace(`startEmbedded: attempt ${attempt}/${this.startupRetries}`); + try { + return await this.startEmbeddedAttempt(); + } catch (error) { + lastError = error; + strace( + `startEmbedded: attempt ${attempt}/${this.startupRetries} FAILED — ${error instanceof Error ? error.message : String(error)}`, + ); + if (attempt < this.startupRetries) { + // Keep reporting "starting" while a retry is still pending — the operator/gate + // must not see "error" for a transient attempt that self-heals. + this.status = { source: "embedded-local", state: "starting" }; + if (this.startupRetryDelayMs > 0) { + await delay(this.startupRetryDelayMs); + } + } + } + } + + this.runtime = null; + this.status = { + source: "embedded-local", + state: "error", + error: lastError instanceof Error ? lastError.message : String(lastError), + }; + strace(`startEmbedded: all ${this.startupRetries} attempts failed — surfacing final error`); + throw lastError; + } + + private async startEmbeddedAttempt(): Promise { let store: TaskStoreLike | null = null; let server: Server | null = null; let cleanup: RuntimeCleanup | undefined; @@ -275,11 +346,6 @@ export class LocalRuntimeManager { store.close(); } this.runtime = null; - this.status = { - source: "embedded-local", - state: "error", - error: error instanceof Error ? error.message : String(error), - }; strace(`startEmbedded: CATCH/ERROR ${error instanceof Error ? error.stack : String(error)}`); throw error; }