diff --git a/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts b/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts index bb5e8a0c964..09b48e05c51 100644 --- a/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts +++ b/apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts @@ -10,6 +10,7 @@ import { getActiveView, } from "main/lib/vscode-shim/api/webview"; import { + clearWebviewHtml, getWebviewUrl, hasWebviewHtml, setCustomThemeCss, @@ -343,10 +344,12 @@ export const createVscodeExtensionsRouter = () => { .mutation(({ input }) => { const panel = getActivePanel(input.viewId); if (!panel) { + clearWebviewHtml(input.viewId); return { success: false }; } panel.dispose(); + clearWebviewHtml(input.viewId); return { success: true }; }), diff --git a/apps/desktop/src/main/extension-host-worker/index.ts b/apps/desktop/src/main/extension-host-worker/index.ts index ff4f761318a..548436a7b37 100644 --- a/apps/desktop/src/main/extension-host-worker/index.ts +++ b/apps/desktop/src/main/extension-host-worker/index.ts @@ -161,23 +161,37 @@ async function main() { // If HTML not yet set, wait up to 5s if (!html) { html = await new Promise((resolve) => { + let settled = false; + let interval: ReturnType | null = null; + let timeout: ReturnType | null = null; + + const finish = (value: string | null) => { + if (settled) return; + settled = true; + if (interval !== null) { + clearInterval(interval); + interval = null; + } + if (timeout !== null) { + clearTimeout(timeout); + timeout = null; + } + resolve(value); + }; + const checkHtml = () => (view.webview as { html?: string }).html ?? null; const immediate = checkHtml(); if (immediate) { - resolve(immediate); + finish(immediate); return; } - const interval = setInterval(() => { + interval = setInterval(() => { const h = checkHtml(); - if (h) { - clearInterval(interval); - resolve(h); - } + if (h) finish(h); }, 200); - setTimeout(() => { - clearInterval(interval); - resolve(checkHtml()); + timeout = setTimeout(() => { + finish(checkHtml()); }, 5000); }); } diff --git a/apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts b/apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts index f474ba91b9f..d9e3b49250f 100644 --- a/apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts +++ b/apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts @@ -12,6 +12,7 @@ import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import os from "node:os"; import path from "node:path"; +import { clearWebviewHtml } from "./api/webview-server"; import type { MainToWorkerMessage, WorkerToMainMessage } from "./ipc-types"; const BASE_RESTART_DELAY = 1000; @@ -102,12 +103,14 @@ export class ExtensionHostManager extends EventEmitter { workspaceId: string, workspacePath: string, ): Promise { + // Inherit restartCount from previous instance so MAX_RESTART_ATTEMPTS is respected + const prevRestartCount = this.instances.get(workspaceId)?.restartCount ?? 0; const instance: ExtensionHostProcess = { workspaceId, workspacePath, process: null, status: "starting", - restartCount: 0, + restartCount: prevRestartCount, }; this.instances.set(workspaceId, instance); @@ -133,48 +136,55 @@ export class ExtensionHostManager extends EventEmitter { instance.process = child; // Pipe stdout/stderr with workspace prefix - child.stdout?.on("data", (data: Buffer) => { + const onStdout = (data: Buffer) => { for (const line of data.toString().split("\n").filter(Boolean)) { console.log(line); } - }); - child.stderr?.on("data", (data: Buffer) => { + }; + const onStderr = (data: Buffer) => { for (const line of data.toString().split("\n").filter(Boolean)) { console.error(line); } - }); + }; + child.stdout?.on("data", onStdout); + child.stderr?.on("data", onStderr); // Handle IPC messages from worker child.on("message", (msg: WorkerToMainMessage) => { this.handleWorkerMessage(workspaceId, msg); }); - // Handle exit - child.on("exit", (code) => { - console.log( - `[ext-host-manager] Worker ${workspaceId} exited with code ${code}`, - ); - const wasStopped = instance.status === "stopped"; + // Shared cleanup called from both exit and error handlers. + // Guards against double-execution via instance.process identity check. + const cleanupWorker = (intentional: boolean) => { + if (instance.process !== child) return; + child.stdout?.off("data", onStdout); + child.stderr?.off("data", onStderr); + this.clearTrackedWebviewsForWorkspace(workspaceId); instance.status = "degraded"; instance.process = null; instance.lastCrash = Date.now(); - - // Clean up viewId mappings - for (const [vid, wsId] of this.viewIdToWorkspace) { - if (wsId === workspaceId) { - this.viewIdToWorkspace.delete(vid); - } - } - - // Schedule restart if not intentionally stopped - if (!wasStopped) { + if (!intentional) { this.scheduleRestart(workspaceId); } + }; + + // Handle exit + child.on("exit", (code) => { + console.log( + `[ext-host-manager] Worker ${workspaceId} exited with code ${code}`, + ); + cleanupWorker(instance.status === "stopped"); }); // Wait for ready message await new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.off("message", onMessage); reject( new Error( `Extension host worker ${workspaceId} failed to become ready within ${READY_TIMEOUT}ms`, @@ -184,8 +194,10 @@ export class ExtensionHostManager extends EventEmitter { const onMessage = (msg: WorkerToMainMessage) => { if (msg.type === "ready") { + if (settled) return; + settled = true; clearTimeout(timer); - child.removeListener("message", onMessage); + child.off("message", onMessage); instance.status = "running"; instance.restartCount = 0; resolve(); @@ -194,7 +206,12 @@ export class ExtensionHostManager extends EventEmitter { child.on("message", onMessage); child.on("error", (err) => { + if (settled) return; + settled = true; clearTimeout(timer); + child.off("message", onMessage); + // error may not be followed by exit; run cleanup + restart here as well + cleanupWorker(false); reject(err); }); }); @@ -470,6 +487,14 @@ export class ExtensionHostManager extends EventEmitter { .map(([workspaceId]) => workspaceId); } + private clearTrackedWebviewsForWorkspace(workspaceId: string): void { + for (const [viewId, wsId] of this.viewIdToWorkspace) { + if (wsId !== workspaceId) continue; + this.viewIdToWorkspace.delete(viewId); + clearWebviewHtml(viewId); + } + } + private sendToWorker(workspaceId: string, msg: MainToWorkerMessage): void { const instance = this.instances.get(workspaceId); if (instance?.process?.connected) { @@ -501,8 +526,10 @@ export class ExtensionHostManager extends EventEmitter { const timer = setTimeout(() => { this.scheduledRestarts.delete(workspaceId); - if (instance.status === "degraded") { - this.spawn(workspaceId, instance.workspacePath).catch((err) => { + // Use start() instead of spawn() directly so startPromises dedup is respected + const current = this.instances.get(workspaceId); + if (current?.status === "degraded") { + this.start(workspaceId, current.workspacePath).catch((err) => { console.error( `[ext-host-manager] Restart failed for ${workspaceId}:`, err,