From c254586d5ff526831c69ce3d09005bd7787842f2 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Thu, 9 Apr 2026 03:22:32 +0900 Subject: [PATCH 1/4] fix(desktop): resolve extension host memory leaks causing session resets - Name stdout/stderr data handlers and remove them on worker exit to prevent listener accumulation across restarts (P1) - Explicitly off() the startup message listener after ready/error/timeout so it does not persist alongside the permanent handleWorkerMessage listener (P2) - Add clearTrackedWebviewsForWorkspace() helper; call it on worker exit to purge htmlStore entries for the workspace (P3) - Add clearWebviewHtml() call in disposeWebview tRPC mutation (P3) - Consolidate HTML-wait timer cleanup into a single finish() function with a settled guard to prevent double-resolve (P5) Closes #118 --- .../trpc/routers/vscode-extensions/index.ts | 3 ++ .../src/main/extension-host-worker/index.ts | 32 ++++++++++---- .../lib/vscode-shim/extension-host-manager.ts | 43 ++++++++++++++----- 3 files changed, 58 insertions(+), 20 deletions(-) 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..bc54a241b61 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; @@ -133,16 +134,18 @@ 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) => { @@ -159,12 +162,12 @@ export class ExtensionHostManager extends EventEmitter { 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); - } - } + // Release stdout/stderr listeners to prevent accumulation + child.stdout?.off("data", onStdout); + child.stderr?.off("data", onStderr); + + // Clean up viewId mappings and htmlStore for this workspace + this.clearTrackedWebviewsForWorkspace(workspaceId); // Schedule restart if not intentionally stopped if (!wasStopped) { @@ -174,7 +177,12 @@ export class ExtensionHostManager extends EventEmitter { // 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 +192,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 +204,10 @@ export class ExtensionHostManager extends EventEmitter { child.on("message", onMessage); child.on("error", (err) => { + if (settled) return; + settled = true; clearTimeout(timer); + child.off("message", onMessage); reject(err); }); }); @@ -470,6 +483,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) { From a52dfba8ef44410280b1b9163b04ecabdb68bd85 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Thu, 9 Apr 2026 03:36:57 +0900 Subject: [PATCH 2/4] fix(desktop): clean up listeners on worker spawn error --- .../src/main/lib/vscode-shim/extension-host-manager.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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 bc54a241b61..a9a1d64a945 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 @@ -208,6 +208,12 @@ export class ExtensionHostManager extends EventEmitter { settled = true; clearTimeout(timer); child.off("message", onMessage); + // error event may not be followed by exit; clean up here as well + child.stdout?.off("data", onStdout); + child.stderr?.off("data", onStderr); + this.clearTrackedWebviewsForWorkspace(workspaceId); + instance.status = "degraded"; + instance.process = null; reject(err); }); }); From f7f6335c3f7d554c4430b3ec71a4cf083860fd05 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Thu, 9 Apr 2026 03:41:46 +0900 Subject: [PATCH 3/4] fix(desktop): unify worker cleanup into shared cleanupWorker helper --- .../lib/vscode-shim/extension-host-manager.ts | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) 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 a9a1d64a945..1a53f2ece60 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 @@ -152,27 +152,27 @@ export class ExtensionHostManager extends EventEmitter { 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"; - instance.status = "degraded"; - instance.process = null; - instance.lastCrash = Date.now(); - - // Release stdout/stderr listeners to prevent accumulation + // 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); - - // Clean up viewId mappings and htmlStore for this workspace this.clearTrackedWebviewsForWorkspace(workspaceId); - - // Schedule restart if not intentionally stopped - if (!wasStopped) { + instance.status = "degraded"; + instance.process = null; + instance.lastCrash = Date.now(); + 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 @@ -208,12 +208,8 @@ export class ExtensionHostManager extends EventEmitter { settled = true; clearTimeout(timer); child.off("message", onMessage); - // error event may not be followed by exit; clean up here as well - child.stdout?.off("data", onStdout); - child.stderr?.off("data", onStderr); - this.clearTrackedWebviewsForWorkspace(workspaceId); - instance.status = "degraded"; - instance.process = null; + // error may not be followed by exit; run cleanup + restart here as well + cleanupWorker(false); reject(err); }); }); From c007145b039483d232dbd1b3fb4f1e0199c35b10 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Thu, 9 Apr 2026 03:55:09 +0900 Subject: [PATCH 4/4] fix(desktop): inherit restartCount across spawns and route restarts through start() --- .../src/main/lib/vscode-shim/extension-host-manager.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 1a53f2ece60..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 @@ -103,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); @@ -524,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,