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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user