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:
Bohdan Triapitsyn
2026-06-03 14:51:17 +03:00
parent a649ba8df4
commit 2b098d36f5
5 changed files with 159 additions and 50 deletions
+126 -40
View File
@@ -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) => {
+5
View File
@@ -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 })
};
+18 -8
View File
@@ -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,
};