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
This commit is contained in:
@@ -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:").
|
||||
|
||||
+126
-40
@@ -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) => {
|
||||
|
||||
@@ -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 })
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user