From 2b098d36f5b3657edb0eb6c5e12c49bd995cd227 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 3 Jun 2026 14:51:10 +0300 Subject: [PATCH] fix: stop orphaned opencode processes on desktop quit Exit the desktop app without waiting on background cleanup Kill managed OpenCode by process group with a port fallback Make OpenCode shutdown reuse the active shutdown promise --- .opencode/commands/changelog.md | 2 +- packages/electron/main.mjs | 166 +++++++++++++----- packages/web/server/index.js | 5 + packages/web/server/lib/opencode/lifecycle.js | 26 ++- .../server/lib/opencode/shutdown-runtime.js | 10 +- 5 files changed, 159 insertions(+), 50 deletions(-) diff --git a/.opencode/commands/changelog.md b/.opencode/commands/changelog.md index d3fb3293..385445ab 100644 --- a/.opencode/commands/changelog.md +++ b/.opencode/commands/changelog.md @@ -15,7 +15,7 @@ Style rules: - Avoid internal implementation details, but do not replace them with vague benefits. If a technical change has no clear user-visible effect, omit it or group it under a plain reliability bullet. - Avoid internal component names unless users see them (ex: "VS Code extension", "Desktop app", "Web app"). - For @packages/vscode/CHANGELOG.md: Craft entries specifically for behavior that is present in the VS Code extension. Exclude Desktop app, Web app, Mobile/PWA, and main-app-only UI. Do not copy shared/main changelog bullets into this file unless changed files or code paths show the feature exists in the extension. Focus on core UI improvements and VS Code integration. Do NOT use "VSCode:" or "VS Code:" prefixes in this file. -- Prefer 5-9 bullets; group by platform only if it reads better. +- Prefer grouping by platform only if it reads better. - No new release header; only update the `[Unreleased]` bullets. - Don't include implementation notes, commit hashes, or file paths in the changelog text. - Use area prefixes when helpful for grouping in the main @CHANGELOG.md (e.g., "Chat:", "VSCode:", "Settings:", "Git:", "Terminal:", "Mobile:", "UI:"). diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 47a0d821..f44d58a7 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -157,6 +157,7 @@ const GITHUB_FEATURE_REQUEST_URL = 'https://github.com/openchamber/openchamber/i const DISCORD_INVITE_URL = 'https://discord.gg/ZYRSdnwwKA'; const INSTALLED_APPS_CACHE_TTL_SECS = 60 * 60 * 24; const INSTALLED_APPS_CACHE_FILE = 'discovered-apps.json'; +const OPENCODE_SHUTDOWN_GRACE_MS = 100; const { autoUpdater } = updaterPkg; @@ -171,7 +172,10 @@ const state = { mainWindow: null, quitRequested: false, quitConfirmed: false, + quitInProgress: false, quitConfirmationPending: false, + backgroundShutdownComplete: false, + sshShutdownPromise: null, installingUpdate: false, pendingUpdate: null, unreachableHosts: new Set(), @@ -213,6 +217,31 @@ const quitConfirmationMessage = () => { return `OpenChamber detected ${reasons.join(', ')}. Quitting now will stop sidecar/background processes and may interrupt pending work.`; }; +const shutdownBackgroundServices = () => { + if (state.backgroundShutdownComplete) return; + state.backgroundShutdownComplete = true; + if (state.installingUpdate) return; + killSidecar(); + setImmediate(() => { + void shutdownSshSessions(); + }); +}; + +const shutdownSshSessions = async () => { + if (state.sshShutdownPromise) { + await state.sshShutdownPromise; + return; + } + + state.sshShutdownPromise = sshManager.shutdownAll().catch((error) => { + log.warn('[electron] failed to stop SSH sessions:', error); + }).finally(() => { + state.sshShutdownPromise = null; + }); + + await state.sshShutdownPromise; +}; + const prepareForQuit = ({ installingUpdate = false } = {}) => { state.quitRequested = true; state.quitConfirmed = true; @@ -226,27 +255,20 @@ const prepareForQuit = ({ installingUpdate = false } = {}) => { } } - if (!installingUpdate) { - try { - killSidecar(); - } catch { - } - void sshManager.shutdownAll().catch(() => {}); + if (installingUpdate) { + state.backgroundShutdownComplete = true; + return; } + + shutdownBackgroundServices(); }; const performConfirmedQuit = () => { - if (state.quitConfirmed) return; + if (state.quitInProgress) return; + state.quitInProgress = true; + prepareForQuit(); - - // Safety net: force-exit if normal quit sequence stalls (e.g. background - // handles in electron-updater / fetch refs) after a short grace period. - const safety = setTimeout(() => { - app.exit(0); - }, 1500); - if (typeof safety?.unref === 'function') safety.unref(); - - app.quit(); + app.exit(0); }; const requestQuitWithConfirmation = async () => { @@ -1083,18 +1105,71 @@ const spawnLocalServer = async () => { return url; }; -const killSidecar = () => { - if (state.serverHandle) { +const launchDetachedOpenCodeKiller = (processInfo) => { + if (!processInfo?.managed) return; + const pid = Number(processInfo.pid); + const port = Number(processInfo.port); + const hasPid = Number.isFinite(pid) && pid > 0; + const hasPort = Number.isFinite(port) && port > 0; + if (!hasPid && !hasPort) return; + const normalizedPid = hasPid ? String(Math.trunc(pid)) : '0'; + const normalizedPort = Number.isFinite(port) && port > 0 ? String(Math.trunc(port)) : '0'; + + if (process.platform === 'win32') { + if (!hasPid) return; + const command = [ + `taskkill /pid ${normalizedPid} /t >nul 2>nul`, + `powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Sleep -Milliseconds ${OPENCODE_SHUTDOWN_GRACE_MS}" >nul 2>nul`, + `taskkill /pid ${normalizedPid} /f /t >nul 2>nul`, + ].join(' & '); + const child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', command], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }); + child.unref(); + return; + } + + if (hasPid) { try { - const result = state.serverHandle.stop({ exitProcess: false }); - if (result && typeof result.then === 'function') { - result.catch(() => {}); - } + process.kill(-pid, 'SIGTERM'); + } catch { + } + try { + process.kill(pid, 'SIGTERM'); } catch { } - state.serverHandle = null; } + + const script = [ + 'pid="$1"', + 'port="$2"', + 'grace="$3"', + 'if [ "$pid" -gt 0 ] 2>/dev/null; then kill -TERM "$pid" 2>/dev/null; kill -TERM "-$pid" 2>/dev/null; fi', + 'sleep "$grace"', + 'if [ "$pid" -gt 0 ] 2>/dev/null; then kill -KILL "-$pid" 2>/dev/null; kill -KILL "$pid" 2>/dev/null; fi', + 'if [ "$port" -gt 0 ] 2>/dev/null && command -v lsof >/dev/null 2>&1; then for target in $(lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null; lsof -ti ":$port" 2>/dev/null); do [ "$target" = "$$" ] || kill -KILL "$target" 2>/dev/null; done; fi', + ].join('; '); + const child = spawn('/bin/sh', ['-c', script, 'openchamber-opencode-killer', normalizedPid, normalizedPort, String(OPENCODE_SHUTDOWN_GRACE_MS / 1000)], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }); + child.unref(); +}; + +const killSidecar = () => { + const handle = state.serverHandle; + state.serverHandle = null; state.sidecarUrl = null; + if (!handle) return; + + try { + launchDetachedOpenCodeKiller(handle.getOpenCodeProcessInfo?.()); + } catch (error) { + log.warn('[electron] failed to launch OpenCode killer:', error); + } }; const macosMajorVersion = () => { @@ -1612,10 +1687,8 @@ const reloadMenuTargetWindow = () => { const relaunchFromMenu = () => { prepareForQuit(); - setImmediate(() => { - app.relaunch(); - app.exit(0); - }); + app.relaunch(); + app.exit(0); }; const nextWindowLabel = () => { @@ -1784,11 +1857,12 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } state.mainWindow = null; } if (BrowserWindow.getAllWindows().length === 0) { - if (!state.installingUpdate) { - killSidecar(); - } if (process.platform !== 'darwin') { - app.quit(); + if (state.installingUpdate) { + app.quit(); + } else { + performConfirmedQuit(); + } } } }); @@ -3140,8 +3214,10 @@ const handleInvoke = async (browserWindow, command, args = {}) => { setImmediate(() => { try { if (applyUpdate) { + killSidecar(); autoUpdater.quitAndInstall(); } else { + prepareForQuit(); app.relaunch(); app.exit(0); } @@ -3659,22 +3735,32 @@ app.on('window-all-closed', () => { return; } - if (!state.installingUpdate) { - killSidecar(); - void sshManager.shutdownAll(); - } if (process.platform !== 'darwin') { - app.quit(); + if (state.installingUpdate) { + app.quit(); + } else { + performConfirmedQuit(); + } } }); app.on('before-quit', (event) => { - if (state.quitConfirmed || state.installingUpdate || process.platform !== 'darwin') { - state.quitRequested = true; + state.quitRequested = true; + + if (state.installingUpdate) { return; } - event.preventDefault(); - void requestQuitWithConfirmation(); + + if (process.platform === 'darwin' && !state.quitConfirmed) { + event.preventDefault(); + void requestQuitWithConfirmation(); + return; + } + + if (!state.backgroundShutdownComplete) { + event.preventDefault(); + performConfirmedQuit(); + } }); app.on('second-instance', (_event, argv) => { diff --git a/packages/web/server/index.js b/packages/web/server/index.js index e8556f88..66e19d11 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1313,6 +1313,11 @@ async function main(options = {}) { }), isReady: () => isOpenCodeReady, restartOpenCode: () => restartOpenCode(), + getOpenCodeProcessInfo: () => ({ + managed: Boolean((openCodeProcess || openCodePort) && !ENV_SKIP_OPENCODE_START && !isExternalOpenCode), + pid: typeof openCodeProcess?.pid === 'number' ? openCodeProcess.pid : null, + port: openCodePort, + }), stop: (shutdownOptions = {}) => gracefulShutdown({ exitProcess: shutdownOptions.exitProcess ?? false }) }; diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index 8b5eb5ef..3e028f47 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -152,6 +152,20 @@ export const createOpenCodeLifecycleRuntime = (deps) => { return; } + const signalProcessTree = (signal) => { + if (process.platform !== 'win32') { + try { + process.kill(-pid, signal); + } catch { + } + } + + try { + child.kill(signal); + } catch { + } + }; + if (process.platform === 'win32') { try { child.kill(); @@ -188,19 +202,13 @@ export const createOpenCodeLifecycleRuntime = (deps) => { return; } - try { - child.kill('SIGTERM'); - } catch { - } + signalProcessTree('SIGTERM'); if (await waitForChildProcessClose(child, 2500)) { return; } - try { - child.kill('SIGKILL'); - } catch { - } + signalProcessTree('SIGKILL'); await waitForChildProcessClose(child, 1000); }; @@ -274,6 +282,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { const child = spawn(binary, args, { cwd, env: processEnv, + detached: process.platform !== 'win32', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -336,6 +345,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { return { url, + pid: child.pid || null, async close() { await closeManagedOpenCodeChild(child); }, diff --git a/packages/web/server/lib/opencode/shutdown-runtime.js b/packages/web/server/lib/opencode/shutdown-runtime.js index 3d4ca294..6f568649 100644 --- a/packages/web/server/lib/opencode/shutdown-runtime.js +++ b/packages/web/server/lib/opencode/shutdown-runtime.js @@ -29,7 +29,9 @@ export const createGracefulShutdownRuntime = (dependencies) => { tunnelAuthController, } = dependencies; - const gracefulShutdown = async (options = {}) => { + let shutdownPromise = null; + + const runShutdown = async (options = {}) => { if (getIsShuttingDown()) return; setIsShuttingDown(true); @@ -133,6 +135,12 @@ export const createGracefulShutdownRuntime = (dependencies) => { } }; + const gracefulShutdown = (options = {}) => { + if (shutdownPromise) return shutdownPromise; + shutdownPromise = runShutdown(options); + return shutdownPromise; + }; + return { gracefulShutdown, };