From 560ae95611e8b87d03725346c41d189d5a57d3ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=99=E4=BA=86=E5=90=97=EF=BC=9F?= <30821268+shuang5725@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:11:03 +0800 Subject: [PATCH] fix(gateway): terminate owned process before retry to prevent port conflict on Windows (#724) --- electron/gateway/manager.ts | 27 ++++++++++++++++++-- electron/gateway/startup-orchestrator.ts | 32 +++++++++++++++++++++++- electron/gateway/supervisor.ts | 14 ++++++++--- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 7d23cdd77..415274a6a 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -259,8 +259,8 @@ export class GatewayManager extends EventEmitter { this.startHealthCheck(); }, - waitForPortFree: async (port) => { - await waitForPortFree(port); + waitForPortFree: async (port, signal) => { + await waitForPortFree(port, 30000, signal); }, startProcess: async () => { await this.startProcess(); @@ -282,6 +282,29 @@ export class GatewayManager extends EventEmitter { delay: async (ms) => { await new Promise((resolve) => setTimeout(resolve, ms)); }, + terminateOwnedProcess: async () => { + if (this.process && this.ownsProcess) { + logger.info('Terminating owned Gateway process before retry...'); + const proc = this.process; + const pid = proc.pid; + await terminateOwnedGatewayProcess(proc).catch(() => {}); + // Only clear the handle if the process has actually exited. + // terminateOwnedGatewayProcess may resolve via its timeout path + // while the child is still alive; in that case keep the reference + // so subsequent retries and stop() can still target it. + if (pid != null) { + try { + process.kill(pid, 0); + // Still alive — keep this.process so later cleanup can reach it + } catch { + // Process is gone — safe to clear the handle + this.process = null; + } + } else { + this.process = null; + } + } + }, }); } catch (error) { if (error instanceof LifecycleSupersededError) { diff --git a/electron/gateway/startup-orchestrator.ts b/electron/gateway/startup-orchestrator.ts index ae2c3ee13..102618554 100644 --- a/electron/gateway/startup-orchestrator.ts +++ b/electron/gateway/startup-orchestrator.ts @@ -18,13 +18,15 @@ type StartupHooks = { findExistingGateway: (port: number) => Promise; connect: (port: number, externalToken?: string) => Promise; onConnectedToExistingGateway: () => void; - waitForPortFree: (port: number) => Promise; + waitForPortFree: (port: number, signal?: AbortSignal) => Promise; startProcess: () => Promise; waitForReady: (port: number) => Promise; onConnectedToManagedGateway: () => void; runDoctorRepair: () => Promise; onDoctorRepairSuccess: () => void; delay: (ms: number) => Promise; + /** Called before a retry to terminate the previously spawned process if still running */ + terminateOwnedProcess?: () => Promise; }; export async function runGatewayStartupSequence(hooks: StartupHooks): Promise { @@ -97,6 +99,34 @@ export async function runGatewayStartupSequence(hooks: StartupHooks): Promise { + logger.warn('Failed to terminate owned process before retry:', err); + }); + } + hooks.assertLifecycle('start/retry-pre-port-wait'); + // Wait for port to become free before retrying (handles lingering processes). + // Use a short-polling AbortController so that a superseding stop()/restart() + // can cancel the wait promptly instead of blocking for the full 30s timeout. + if (hooks.shouldWaitForPortFree) { + const abortController = new AbortController(); + // Poll lifecycle every 500ms and abort the port-wait if superseded + const lifecyclePollInterval = setInterval(() => { + try { + hooks.assertLifecycle('start/retry-port-wait-poll'); + } catch { + abortController.abort(); + } + }, 500); + try { + await hooks.waitForPortFree(hooks.port, abortController.signal); + } finally { + clearInterval(lifecyclePollInterval); + } + } + hooks.assertLifecycle('start/retry-post-port-wait'); continue; } diff --git a/electron/gateway/supervisor.ts b/electron/gateway/supervisor.ts index 36122fbb2..272380348 100644 --- a/electron/gateway/supervisor.ts +++ b/electron/gateway/supervisor.ts @@ -34,11 +34,12 @@ export async function terminateOwnedGatewayProcess(child: Electron.UtilityProces // Register a single exit listener before any kill attempt to avoid // the race where exit fires between two separate `once('exit')` calls. - child.once('exit', () => { + const exitListener = () => { exited = true; clearTimeout(timeout); resolve(); - }); + }; + child.once('exit', exitListener); const pid = child.pid; logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`); @@ -72,6 +73,8 @@ export async function terminateOwnedGatewayProcess(child: Electron.UtilityProces } } } + // Clean up the exit listener on timeout to prevent listener leaks + child.off('exit', exitListener); resolve(); }, 5000); }); @@ -125,13 +128,18 @@ export async function unloadLaunchctlGatewayService(): Promise { } } -export async function waitForPortFree(port: number, timeoutMs = 30000): Promise { +export async function waitForPortFree(port: number, timeoutMs = 30000, signal?: AbortSignal): Promise { const net = await import('net'); const start = Date.now(); const pollInterval = 500; let logged = false; while (Date.now() - start < timeoutMs) { + if (signal?.aborted) { + logger.debug(`waitForPortFree: aborted while waiting for port ${port}`); + return; + } + const available = await new Promise((resolve) => { const server = net.createServer(); server.once('error', () => resolve(false));