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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 21:17:26 -07:00
parent 5631c88d54
commit f87e11387f
2 changed files with 228 additions and 6 deletions

View File

@@ -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<ReturnType<typeof vi.fn>> = [];
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);

View File

@@ -57,6 +57,28 @@ export interface LocalRuntimeManagerOptions {
getExternalPort?: () => number | undefined;
createStore?: (rootDir: string) => Promise<TaskStoreLike>;
createDashboardServer?: (store: TaskStoreLike, rootDir: string) => Promise<Server | { server: Server; cleanup?: RuntimeCleanup }>;
/**
* 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<void> {
if (ms <= 0) return Promise.resolve();
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function createStoreDefault(rootDir: string): Promise<TaskStoreLike> {
@@ -167,11 +189,15 @@ export class LocalRuntimeManager {
private readonly getExternalPort: () => number | undefined;
private readonly createStore: (rootDir: string) => Promise<TaskStoreLike>;
private readonly createDashboardServer: (store: TaskStoreLike, rootDir: string) => Promise<Server | { server: Server; cleanup?: RuntimeCleanup }>;
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<DesktopRuntimeStatus> {
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<DesktopRuntimeStatus> {
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;
}