From f236ef901ae6e679b17b77591f64c423d7d4ee34 Mon Sep 17 00:00:00 2001 From: Gerkinfeltser Date: Tue, 17 Feb 2026 16:20:51 -0600 Subject: [PATCH] fix: properly cleanup Windows process tree on Obsidian exit On Windows, when Obsidian closes with the OpenCode panel open, the server process (node.exe) becomes orphaned because: 1. shell: true spawns cmd.exe -> node.exe 2. The PID captured is cmd.exe, not the actual node.exe 3. Killing cmd.exe leaves node.exe running Changes: - WindowsProcess.stop(): Use PowerShell to find and kill child processes first - ServerManager: Added getPid() method - main.ts: Store serverPid separately for cleanup during window close - main.ts: Added killPidSync() with PowerShell child lookup (Windows) - main.ts: Added beforeunload handler for reliable cleanup on window close - main.ts: Updated onunload() to use sync cleanup with stored PID This ensures the actual node.exe process is killed when Obsidian closes. --- src/main.ts | 76 +++++++++++++++++++++++++--- src/server/ServerManager.ts | 4 ++ src/server/process/WindowsProcess.ts | 33 +++++++++++- 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/main.ts b/src/main.ts index 6475111..6a0dfd7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ export default class OpenCodePlugin extends Plugin { private viewManager: ViewManager; private cachedIframeUrl: string | null = null; private lastBaseUrl: string | null = null; + private serverPid: number | null = null; async onload(): Promise { console.log("Loading OpenCode plugin"); @@ -149,10 +150,62 @@ export default class OpenCodePlugin extends Plugin { async onunload(): Promise { this.contextManager.destroy(); - await this.stopServer(); + // Use sync cleanup with stored PID - processManager may already be destroyed + if (this.serverPid) { + this.killPidSync(this.serverPid); + this.serverPid = null; + } else { + await this.stopServer(); + } this.app.workspace.detachLeavesOfType(OPENCODE_VIEW_TYPE); } + private killPidSync(pid: number): void { + try { + if (process.platform === "win32") { + const { execSync } = require("child_process"); + + // Method 1: Kill child processes (actual node.exe) using PowerShell + try { + const output = execSync( + `powershell -Command "Get-CimInstance Win32_Process -Filter \\"ParentProcessId=${pid}\\" | Select-Object ProcessId"`, + { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] } + ); + + const lines = output.split("\n").slice(3); + for (const line of lines) { + const childPid = line.trim(); + if (childPid && !isNaN(parseInt(childPid))) { + try { + execSync(`taskkill /F /PID ${childPid}`, { stdio: "ignore" }); + } catch { + // Child may already be gone + } + } + } + } catch { + // PowerShell lookup failed, continue to other methods + } + + // Method 2: Kill the parent process (cmd.exe) + try { + execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore" }); + } catch { + // Parent may already be gone + } + } else { + // Unix: kill the process group + try { + process.kill(-pid, "SIGTERM"); + } catch { + process.kill(pid, "SIGTERM"); + } + } + } catch { + // Process may already be gone + } + } + async loadSettings(): Promise { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } @@ -194,6 +247,7 @@ export default class OpenCodePlugin extends Plugin { async startServer(): Promise { const success = await this.processManager.start(); if (success) { + this.serverPid = this.processManager.getPid(); new Notice("OpenCode server started"); } else { const error = this.processManager.getLastError(); @@ -208,6 +262,7 @@ export default class OpenCodePlugin extends Plugin { async stopServer(): Promise { await this.processManager.stop(); + this.serverPid = null; new Notice("OpenCode server stopped"); } @@ -287,11 +342,18 @@ export default class OpenCodePlugin extends Plugin { } private registerCleanupHandlers(): void { - this.registerEvent( - this.app.workspace.on("quit", () => { - console.log("[OpenCode] Obsidian quitting - performing sync cleanup"); - this.stopServer(); - }) - ); + // Hook into window close event for cleanup (more reliable than onunload on Windows) + const cleanupHandler = () => { + if (this.serverPid) { + this.killPidSync(this.serverPid); + this.serverPid = null; + } + }; + window.addEventListener("beforeunload", cleanupHandler); + + // Register for cleanup when plugin unloads + this.register(() => { + window.removeEventListener("beforeunload", cleanupHandler); + }); } } diff --git a/src/server/ServerManager.ts b/src/server/ServerManager.ts index f4aa368..9966d82 100644 --- a/src/server/ServerManager.ts +++ b/src/server/ServerManager.ts @@ -43,6 +43,10 @@ export class ServerManager extends EventEmitter { return this.lastError; } + getPid(): number | null { + return this.process?.pid ?? null; + } + getUrl(): string { const encodedPath = btoa(this.projectDirectory); return `http://${this.settings.hostname}:${this.settings.port}/${encodedPath}`; diff --git a/src/server/process/WindowsProcess.ts b/src/server/process/WindowsProcess.ts index f905500..1f6ed05 100644 --- a/src/server/process/WindowsProcess.ts +++ b/src/server/process/WindowsProcess.ts @@ -22,8 +22,37 @@ export class WindowsProcess implements OpenCodeProcess { console.log("[OpenCode] Stopping server process tree, PID:", pid); - // Use taskkill with /T flag to kill process tree - await this.execAsync(`taskkill /T /F /PID ${pid}`); + // Method 1: Find and kill child processes (actual node.exe) using PowerShell + // This is necessary because shell: true spawns cmd.exe -> node.exe, and + // killing cmd.exe leaves node.exe orphaned + try { + const { execSync } = require("child_process"); + const output = execSync( + `powershell -Command "Get-CimInstance Win32_Process -Filter \\"ParentProcessId=${pid}\\" | Select-Object ProcessId"`, + { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] } + ); + + const lines = output.split("\n").slice(3); // Skip headers + for (const line of lines) { + const childPid = line.trim(); + if (childPid && !isNaN(parseInt(childPid))) { + try { + execSync(`taskkill /F /PID ${childPid}`, { stdio: "ignore" }); + } catch { + // Child may already be gone + } + } + } + } catch { + // PowerShell lookup failed, continue to other methods + } + + // Method 2: Kill the parent process (cmd.exe) + try { + await this.execAsync(`taskkill /F /PID ${pid}`); + } catch { + // Parent may already be gone + } // Wait for process to exit await this.waitForExit(process, 5000);