From cf9b7a6516b61097053d0960b0d605f2bc3fbb4d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 16 Jan 2026 01:22:43 +0200 Subject: [PATCH] feat: implement process cleanup for OpenCode server and enhance internal function handling --- .../src/components/views/PierreDiffViewer.tsx | 1 - packages/ui/src/hooks/useEventStream.ts | 1 + packages/vscode/src/opencode.ts | 81 +++++++++++++++++-- packages/web/server/index.js | 28 ++++++- 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx index 4ab6d660..95b2b1e6 100644 --- a/packages/ui/src/components/views/PierreDiffViewer.tsx +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -131,7 +131,6 @@ export const PierreDiffViewer: React.FC = ({ wrapLines = false, layout = 'fill', }) => { - const isInlineLayout = layout === 'inline'; const { isMobile } = useDeviceInfo(); const { inputBarOffset, isKeyboardOpen } = useUIStore(); diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index 60b37f0f..5d4981d6 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -1746,6 +1746,7 @@ export const useEventStream = () => { messageCache.clear(); // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time notifiedMessagesRef.current.clear(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time notifiedQuestionsRef.current.clear(); pendingResumeRef.current = false; diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index a2610f37..ff8e8e26 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import * as os from 'os'; +import { execSync } from 'child_process'; import { createOpencodeServer } from '@opencode-ai/sdk/server'; const READY_CHECK_TIMEOUT_MS = 30000; @@ -146,6 +147,8 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo let apiPrefixDetected = false; let cliMissing = false; + let pendingOperation: Promise | null = null; + const config = vscode.workspace.getConfiguration('openchamber'); const configuredApiUrl = config.get('apiUrl') || ''; const useConfiguredUrl = configuredApiUrl && configuredApiUrl.trim().length > 0; @@ -189,7 +192,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo return null; }; - async function start(workdir?: string): Promise { + async function startInternal(workdir?: string): Promise { startCount += 1; lastStartAt = Date.now(); @@ -203,8 +206,16 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo return; } + // If server already running, don't spawn another + if (server) { + if (status !== 'connected') { + setStatus('connected'); + } + return; + } + setStatus('connecting'); - cliMissing = false; // Reset assumption on retry + cliMissing = false; detectedPort = null; apiPrefix = ''; @@ -219,7 +230,6 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo const originalCwd = process.cwd(); try { process.chdir(workingDirectory); - // Let the SDK/OS choose a random available port (port: 0) server = await createOpencodeServer({ hostname: '127.0.0.1', port: 0, @@ -276,7 +286,9 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo } } - async function stop(): Promise { + async function stopInternal(): Promise { + const portToKill = detectedPort; + if (server) { try { server.close(); @@ -286,17 +298,72 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo server = null; } + // SDK's proc.kill() only kills the Node wrapper, not the actual opencode binary. + // Kill any process listening on our port to clean up orphaned children. + if (portToKill) { + try { + execSync(`lsof -ti:${portToKill} | xargs kill -9 2>/dev/null || true`, { + stdio: 'ignore', + timeout: 5000 + }); + } catch { + // Ignore - process may already be dead + } + } managedApiUrlOverride = null; detectedPort = null; setStatus('disconnected'); } - async function restart(): Promise { + async function restartInternal(): Promise { restartCount += 1; - await stop(); + await stopInternal(); await new Promise(r => setTimeout(r, 250)); - await start(); + await startInternal(); + } + + async function start(workdir?: string): Promise { + if (pendingOperation) { + await pendingOperation; + if (server) { + return; + } + } + pendingOperation = startInternal(workdir); + try { + await pendingOperation; + } finally { + pendingOperation = null; + } + } + + async function stop(): Promise { + if (pendingOperation) { + await pendingOperation; + } + // Check if already stopped + if (!server) { + return; + } + pendingOperation = stopInternal(); + try { + await pendingOperation; + } finally { + pendingOperation = null; + } + } + + async function restart(): Promise { + if (pendingOperation) { + await pendingOperation; + } + pendingOperation = restartInternal(); + try { + await pendingOperation; + } finally { + pendingOperation = null; + } } async function setWorkingDirectory(newPath: string): Promise<{ success: boolean; restarted: boolean; path: string }> { diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 917ee994..cea91f90 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1429,6 +1429,20 @@ function parseArgs(argv = process.argv.slice(2)) { return options; } +function killProcessOnPort(port) { + if (!port) return; + try { + // SDK's proc.kill() only kills the Node wrapper, not the actual opencode binary. + // Kill any process listening on our port to clean up orphaned children. + spawnSync('sh', ['-c', `lsof -ti:${port} | xargs kill -9 2>/dev/null || true`], { + stdio: 'ignore', + timeout: 5000 + }); + } catch { + // Ignore - process may already be dead + } +} + async function startOpenCode() { const desiredPort = ENV_CONFIGURED_OPENCODE_PORT ?? 0; console.log( @@ -1496,6 +1510,8 @@ async function restartOpenCode() { openCodeNotReadySince = Date.now(); console.log('Restarting OpenCode process...'); + const portToKill = openCodePort; + if (openCodeProcess) { console.log('Stopping existing OpenCode process...'); try { @@ -1505,11 +1521,13 @@ async function restartOpenCode() { } openCodeProcess = null; syncToHmrState(); - - // Brief delay to allow port release - await new Promise((resolve) => setTimeout(resolve, 250)); } + killProcessOnPort(portToKill); + + // Brief delay to allow port release + await new Promise((resolve) => setTimeout(resolve, 250)); + if (ENV_CONFIGURED_OPENCODE_PORT) { console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`); setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT); @@ -1917,6 +1935,8 @@ async function gracefulShutdown(options = {}) { clearInterval(healthCheckInterval); } + const portToKill = openCodePort; + if (openCodeProcess) { console.log('Stopping OpenCode process...'); try { @@ -1927,6 +1947,8 @@ async function gracefulShutdown(options = {}) { openCodeProcess = null; } + killProcessOnPort(portToKill); + if (server) { await Promise.race([ new Promise((resolve) => {