From 2dc1070213d7bab2c5f8b8fe3332a3a86d43ebde Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:34:03 +0800 Subject: [PATCH] fix: prevent orphaned gateway processes on Windows causing port conflcts and UI freeze (#570) --- electron/gateway/supervisor.ts | 37 ++++++++++++++++++++++++++-------- electron/main/index.ts | 31 ++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/electron/gateway/supervisor.ts b/electron/gateway/supervisor.ts index 04391d944..f8da21faa 100644 --- a/electron/gateway/supervisor.ts +++ b/electron/gateway/supervisor.ts @@ -32,20 +32,38 @@ export async function terminateOwnedGatewayProcess(child: Electron.UtilityProces const pid = child.pid; logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`); - try { - child.kill(); - } catch { - // ignore if already exited + + // On Windows, use taskkill /F /T to kill the entire process tree. + // child.kill() only terminates the direct utilityProcess; grandchild + // processes (Python/uv) survive and keep port 18789 occupied. + if (process.platform === 'win32' && pid) { + import('child_process').then((cp) => { + cp.exec(`taskkill /F /PID ${pid} /T`, { timeout: 5000, windowsHide: true }, () => { + // best-effort; fall through to timeout if taskkill fails + }); + }).catch(() => { /* ignore */ }); + } else { + try { + child.kill(); + } catch { + // ignore if already exited + } } const timeout = setTimeout(() => { if (!exited) { logger.warn(`Gateway did not exit in time, force-killing (pid=${pid ?? 'unknown'})`); if (pid) { - try { - process.kill(pid, 'SIGKILL'); - } catch { - // ignore + if (process.platform === 'win32') { + import('child_process').then((cp) => { + cp.exec(`taskkill /F /PID ${pid} /T`, { timeout: 5000, windowsHide: true }, () => {}); + }).catch(() => {}); + } else { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // ignore + } } } } @@ -226,6 +244,9 @@ export async function findExistingGatewayProcess(options: { const pids = await getListeningProcessIds(port); if (pids.length > 0 && (!ownedPid || !pids.includes(String(ownedPid)))) { await terminateOrphanedProcessIds(port, pids); + // Verify the port is actually free after killing orphans. + // On Windows, TCP TIME_WAIT can hold the port for up to 120s. + await waitForPortFree(port, 10000); return null; } } catch (err) { diff --git a/electron/main/index.ts b/electron/main/index.ts index 3748461c6..5a4607ac5 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -459,15 +459,30 @@ if (gotTheLock) { } }); - app.on('before-quit', () => { + let gatewayCleanedUp = false; + + app.on('before-quit', (event) => { setQuitting(); - hostEventBus.closeAll(); - hostApiServer?.close(); - // Fire-and-forget: do not await gatewayManager.stop() here. - // Awaiting inside before-quit can stall Electron's quit sequence. - void gatewayManager.stop().catch((err) => { - logger.warn('gatewayManager.stop() error during quit:', err); - }); + + // On first before-quit, block the quit so we can await gateway cleanup. + // On Windows, fire-and-forget leaves orphaned Python/uv processes that + // hold port 18789, causing port conflicts on next launch. + if (!gatewayCleanedUp) { + gatewayCleanedUp = true; + event.preventDefault(); + + hostEventBus.closeAll(); + hostApiServer?.close(); + + const stopPromise = gatewayManager.stop().catch((err) => { + logger.warn('gatewayManager.stop() error during quit:', err); + }); + const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 5000)); + + void Promise.race([stopPromise, timeoutPromise]).then(() => { + app.exit(0); + }); + } }); }