diff --git a/electron-builder.yml b/electron-builder.yml index 6ddc885ef..88899dbc1 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -157,10 +157,7 @@ linux: maintainer: ClawX Team vendor: ClawX synopsis: AI Assistant powered by OpenClaw - description: | - ClawX is a graphical AI assistant application that integrates with - OpenClaw Gateway to provide intelligent automation and assistance - across multiple messaging platforms. + description: ClawX is a graphical AI assistant application that integrates with OpenClaw Gateway to provide intelligent automation and assistance across multiple messaging platforms. desktop: entry: Name: ClawX diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index b83cfbe9a..610d63fbf 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -436,6 +436,14 @@ export class GatewayManager extends EventEmitter { logger.debug('No existing Gateway found, starting new process...'); + // On Windows, TCP TIME_WAIT can hold the port for up to 2 minutes + // after the previous Gateway process exits, preventing the new one + // from binding. Wait for the port to be free before proceeding. + if (process.platform === 'win32') { + await this.waitForPortFree(this.status.port); + this.assertLifecycleEpoch(startEpoch, 'start/wait-port'); + } + // Start new Gateway process await this.startProcess(); this.assertLifecycleEpoch(startEpoch, 'start/start-process'); @@ -1015,6 +1023,45 @@ export class GatewayManager extends EventEmitter { * Start Gateway process * Uses OpenClaw npm package from node_modules (dev) or resources (production) */ + /** + * Wait until the gateway port is no longer held by the OS. + * On Windows, TCP TIME_WAIT can keep a port occupied for up to 2 minutes + * after the owning process exits, causing the new Gateway to hang on bind. + */ + private async waitForPortFree(port: number, timeoutMs = 30000): Promise { + const net = await import('net'); + const start = Date.now(); + const pollInterval = 500; + let logged = false; + + while (Date.now() - start < timeoutMs) { + const available = await new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => resolve(false)); + server.once('listening', () => { + server.close(() => resolve(true)); + }); + server.listen(port, '127.0.0.1'); + }); + + if (available) { + const elapsed = Date.now() - start; + if (elapsed > pollInterval) { + logger.info(`Port ${port} became available after ${elapsed}ms`); + } + return; + } + + if (!logged) { + logger.info(`Waiting for port ${port} to become available (Windows TCP TIME_WAIT)...`); + logged = true; + } + await new Promise(r => setTimeout(r, pollInterval)); + } + + logger.warn(`Port ${port} still occupied after ${timeoutMs}ms, proceeding anyway`); + } + private async startProcess(): Promise { // Ensure no system-managed gateway service will compete with our process. await this.unloadLaunchctlService(); diff --git a/electron/gateway/process-policy.ts b/electron/gateway/process-policy.ts index c693a14c0..ece3b6596 100644 --- a/electron/gateway/process-policy.ts +++ b/electron/gateway/process-policy.ts @@ -46,11 +46,16 @@ export type DeferredRestartAction = 'none' | 'wait' | 'drop' | 'execute'; /** * Decide what to do with a pending deferred restart once lifecycle changes. + * + * A deferred restart is an explicit restart() call that was postponed because + * the manager was mid-startup/reconnect. When the in-flight operation settles + * we must honour the request — even if the gateway is now running — because + * the caller may have changed config (e.g. provider switch) that the current + * process hasn't picked up. */ export function getDeferredRestartAction(context: DeferredRestartActionContext): DeferredRestartAction { if (!context.hasPendingRestart) return 'none'; if (shouldDeferRestart(context)) return 'wait'; if (!context.shouldReconnect) return 'drop'; - if (context.state === 'running') return 'drop'; return 'execute'; } diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 424e94e08..ced2a1b26 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -995,10 +995,14 @@ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void { * Provider-related IPC handlers */ function registerProviderHandlers(gatewayManager: GatewayManager): void { - // Listen for OAuth success to automatically restart the Gateway with new tokens/configs + // Listen for OAuth success to automatically restart the Gateway with new tokens/configs. + // Use a longer debounce (8s) so that provider:setDefault — which writes the full config + // and then calls debouncedRestart(2s) — has time to fire and coalesce into a single + // restart. Without this, the OAuth restart fires first with stale config, and the + // subsequent provider:setDefault restart is deferred and dropped. deviceOAuthManager.on('oauth:success', (providerType) => { logger.info(`[IPC] Scheduling Gateway restart after ${providerType} OAuth success...`); - gatewayManager.debouncedRestart(); + gatewayManager.debouncedRestart(8000); }); // Get all providers with key info diff --git a/tests/unit/gateway-process-policy.test.ts b/tests/unit/gateway-process-policy.test.ts index 04089b20d..9392f9d20 100644 --- a/tests/unit/gateway-process-policy.test.ts +++ b/tests/unit/gateway-process-policy.test.ts @@ -65,7 +65,7 @@ describe('gateway process policy helpers', () => { expect(shouldDeferRestart({ state: 'error', startLock: false })).toBe(false); }); - it('drops deferred restart once lifecycle recovers to running', () => { + it('executes deferred restart even after lifecycle recovers to running', () => { expect( getDeferredRestartAction({ hasPendingRestart: true, @@ -73,7 +73,7 @@ describe('gateway process policy helpers', () => { startLock: false, shouldReconnect: true, }) - ).toBe('drop'); + ).toBe('execute'); }); it('waits deferred restart while lifecycle is still busy', () => {