Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/desktop/src/lib/trpc/routers/vscode-extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getActiveView,
} from "main/lib/vscode-shim/api/webview";
import {
clearWebviewHtml,
getWebviewUrl,
hasWebviewHtml,
setCustomThemeCss,
Expand Down Expand Up @@ -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 };
}),

Expand Down
32 changes: 23 additions & 9 deletions apps/desktop/src/main/extension-host-worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,23 +161,37 @@ async function main() {
// If HTML not yet set, wait up to 5s
if (!html) {
html = await new Promise<string | null>((resolve) => {
let settled = false;
let interval: ReturnType<typeof setInterval> | null = null;
let timeout: ReturnType<typeof setTimeout> | 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);
});
}
Expand Down
75 changes: 51 additions & 24 deletions apps/desktop/src/main/lib/vscode-shim/extension-host-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -102,12 +103,14 @@ export class ExtensionHostManager extends EventEmitter {
workspaceId: string,
workspacePath: string,
): Promise<void> {
// 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);

Expand All @@ -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<void>((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`,
Expand All @@ -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();
Expand All @@ -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);
});
});
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down