From 9532400053fcd4b7deb046f17aa7f0a42654d2b6 Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Mon, 2 Mar 2026 21:28:01 +0800 Subject: [PATCH] fix(win): Gateway restart win terminal open error (#265) Co-authored-by: Cursor Agent Co-authored-by: Haze --- electron/gateway/clawhub.ts | 1 + electron/gateway/manager.ts | 81 ++++++++++++++++++----- electron/gateway/process-policy.ts | 23 +++++++ electron/utils/channel-config.ts | 1 + electron/utils/openclaw-cli.ts | 4 +- electron/utils/uv-setup.ts | 9 ++- package.json | 2 +- tests/unit/gateway-process-policy.test.ts | 52 +++++++++++++++ 8 files changed, 150 insertions(+), 23 deletions(-) create mode 100644 electron/gateway/process-policy.ts create mode 100644 tests/unit/gateway-process-policy.test.ts diff --git a/electron/gateway/clawhub.ts b/electron/gateway/clawhub.ts index 150455761..25cb2686a 100644 --- a/electron/gateway/clawhub.ts +++ b/electron/gateway/clawhub.ts @@ -106,6 +106,7 @@ export class ClawHubService { ...env, CLAWHUB_WORKDIR: this.workDir, }, + windowsHide: true, }); let stdout = ''; diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index c1a902815..cf96d3c25 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -35,6 +35,11 @@ import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, sanitizeOpenClaw import { buildProxyEnv, resolveProxySettings } from '../utils/proxy'; import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy'; import { shouldAttemptConfigAutoRepair } from './startup-recovery'; +import { + getReconnectSkipReason, + isLifecycleSuperseded, + nextLifecycleEpoch, +} from './process-policy'; /** * Gateway connection status @@ -162,6 +167,13 @@ function ensureGatewayFetchPreload(): string { return dest; } +class LifecycleSupersededError extends Error { + constructor(message: string) { + super(message); + this.name = 'LifecycleSupersededError'; + } +} + /** * Gateway Manager * Handles starting, stopping, and communicating with the OpenClaw Gateway @@ -187,6 +199,7 @@ export class GatewayManager extends EventEmitter { }> = new Map(); private deviceIdentity: DeviceIdentity | null = null; private restartDebounceTimer: NodeJS.Timeout | null = null; + private lifecycleEpoch = 0; constructor(config?: Partial) { super(); @@ -247,6 +260,20 @@ export class GatewayManager extends EventEmitter { } } + private bumpLifecycleEpoch(reason: string): number { + this.lifecycleEpoch = nextLifecycleEpoch(this.lifecycleEpoch); + logger.debug(`Gateway lifecycle epoch advanced to ${this.lifecycleEpoch} (${reason})`); + return this.lifecycleEpoch; + } + + private assertLifecycleEpoch(expectedEpoch: number, phase: string): void { + if (isLifecycleSuperseded(expectedEpoch, this.lifecycleEpoch)) { + throw new LifecycleSupersededError( + `Gateway ${phase} superseded (expectedEpoch=${expectedEpoch}, currentEpoch=${this.lifecycleEpoch})` + ); + } + } + /** * Get current Gateway status */ @@ -276,6 +303,7 @@ export class GatewayManager extends EventEmitter { } this.startLock = true; + const startEpoch = this.bumpLifecycleEpoch('start'); logger.info(`Gateway start requested (port=${this.status.port})`); this.lastSpawnSummary = null; this.shouldReconnect = true; @@ -310,14 +338,17 @@ export class GatewayManager extends EventEmitter { try { while (true) { + this.assertLifecycleEpoch(startEpoch, 'start'); this.recentStartupStderrLines = []; try { // Check if Gateway is already running logger.debug('Checking for existing Gateway...'); const existing = await this.findExistingGateway(); + this.assertLifecycleEpoch(startEpoch, 'start/find-existing'); if (existing) { logger.debug(`Found existing Gateway on port ${existing.port}`); await this.connect(existing.port, existing.externalToken); + this.assertLifecycleEpoch(startEpoch, 'start/connect-existing'); this.ownsProcess = false; this.setStatus({ pid: undefined }); this.startHealthCheck(); @@ -328,18 +359,24 @@ export class GatewayManager extends EventEmitter { // Start new Gateway process await this.startProcess(); + this.assertLifecycleEpoch(startEpoch, 'start/start-process'); // Wait for Gateway to be ready await this.waitForReady(); + this.assertLifecycleEpoch(startEpoch, 'start/wait-ready'); // Connect WebSocket await this.connect(this.status.port); + this.assertLifecycleEpoch(startEpoch, 'start/connect'); // Start health monitoring this.startHealthCheck(); logger.debug('Gateway started successfully'); return; } catch (error) { + if (error instanceof LifecycleSupersededError) { + throw error; + } if (shouldAttemptConfigAutoRepair(error, this.recentStartupStderrLines, configRepairAttempted)) { configRepairAttempted = true; logger.warn( @@ -358,6 +395,10 @@ export class GatewayManager extends EventEmitter { } } catch (error) { + if (error instanceof LifecycleSupersededError) { + logger.debug(error.message); + return; + } logger.error( `Gateway start failed (port=${this.status.port}, reconnectAttempts=${this.reconnectAttempts}, spawn=${this.lastSpawnSummary ?? 'n/a'})`, error @@ -374,6 +415,7 @@ export class GatewayManager extends EventEmitter { */ async stop(): Promise { logger.info('Gateway stop requested'); + this.bumpLifecycleEpoch('stop'); // Disable auto-reconnect this.shouldReconnect = false; @@ -666,7 +708,7 @@ export class GatewayManager extends EventEmitter { const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => { import('child_process').then(cp => { - cp.exec(cmd, { timeout: 5000 }, (err, stdout) => { + cp.exec(cmd, { timeout: 5000, windowsHide: true }, (err, stdout) => { if (err) resolve({ stdout: '' }); else resolve({ stdout }); }); @@ -694,7 +736,11 @@ export class GatewayManager extends EventEmitter { if (process.platform === 'win32') { // On Windows, use taskkill for reliable process group termination import('child_process').then(cp => { - cp.exec(`taskkill /PID ${pid} /T /F`, { timeout: 5000 }, () => { }); + cp.exec( + `taskkill /PID ${pid} /T /F`, + { timeout: 5000, windowsHide: true }, + () => { } + ); }).catch(() => { }); } else { // SIGTERM first so the gateway can clean up its lock file. @@ -797,6 +843,7 @@ export class GatewayManager extends EventEmitter { stdio: ['ignore', 'pipe', 'pipe'], detached: false, shell: false, + windowsHide: true, env: spawnEnv, }); @@ -1050,6 +1097,7 @@ export class GatewayManager extends EventEmitter { stdio: ['ignore', 'pipe', 'pipe'], detached: false, shell: useShell, + windowsHide: true, env: spawnEnv, }); const child = this.process; @@ -1557,27 +1605,24 @@ export class GatewayManager extends EventEmitter { state: 'reconnecting', reconnectAttempts: this.reconnectAttempts }); + const scheduledEpoch = this.lifecycleEpoch; this.reconnectTimer = setTimeout(async () => { this.reconnectTimer = null; + const skipReason = getReconnectSkipReason({ + scheduledEpoch, + currentEpoch: this.lifecycleEpoch, + shouldReconnect: this.shouldReconnect, + }); + if (skipReason) { + logger.debug(`Skipping reconnect attempt: ${skipReason}`); + return; + } try { - // Try to find existing Gateway first - const existing = await this.findExistingGateway(); - if (existing) { - await this.connect(existing.port, existing.externalToken); - this.ownsProcess = false; - this.setStatus({ pid: undefined }); - this.reconnectAttempts = 0; - this.startHealthCheck(); - return; - } - - // Otherwise restart the process - await this.startProcess(); - await this.waitForReady(); - await this.connect(this.status.port); + // Use the guarded start() flow so reconnect attempts cannot bypass + // lifecycle locking and accidentally start duplicate Gateway processes. + await this.start(); this.reconnectAttempts = 0; - this.startHealthCheck(); } catch (error) { logger.error('Gateway reconnection attempt failed:', error); this.scheduleReconnect(); diff --git a/electron/gateway/process-policy.ts b/electron/gateway/process-policy.ts new file mode 100644 index 000000000..ea8b9c222 --- /dev/null +++ b/electron/gateway/process-policy.ts @@ -0,0 +1,23 @@ +export function nextLifecycleEpoch(currentEpoch: number): number { + return currentEpoch + 1; +} + +export function isLifecycleSuperseded(expectedEpoch: number, currentEpoch: number): boolean { + return expectedEpoch !== currentEpoch; +} + +export interface ReconnectAttemptContext { + scheduledEpoch: number; + currentEpoch: number; + shouldReconnect: boolean; +} + +export function getReconnectSkipReason(context: ReconnectAttemptContext): string | null { + if (!context.shouldReconnect) { + return 'auto-reconnect disabled'; + } + if (isLifecycleSuperseded(context.scheduledEpoch, context.currentEpoch)) { + return `stale reconnect callback (scheduledEpoch=${context.scheduledEpoch}, currentEpoch=${context.currentEpoch})`; + } + return null; +} diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index eba0897fa..1732e414b 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -526,6 +526,7 @@ export async function validateChannelConfig(channelType: string): Promise { if (err) reject(err); diff --git a/electron/utils/openclaw-cli.ts b/electron/utils/openclaw-cli.ts index 9305e1b4f..9d9e9f5f6 100644 --- a/electron/utils/openclaw-cli.ts +++ b/electron/utils/openclaw-cli.ts @@ -269,6 +269,7 @@ export function generateCompletionCache(): void { }, stdio: 'ignore', detached: false, + windowsHide: true, }); child.on('close', (code) => { @@ -305,7 +306,8 @@ export function installCompletionToProfile(): void { }, stdio: 'ignore', detached: false, - }, + windowsHide: true, + } ); child.on('close', (code) => { diff --git a/electron/utils/uv-setup.ts b/electron/utils/uv-setup.ts index c16fcd951..9a70a700c 100644 --- a/electron/utils/uv-setup.ts +++ b/electron/utils/uv-setup.ts @@ -14,7 +14,7 @@ function getBundledUvPath(): string { const arch = process.arch; const target = `${platform}-${arch}`; const binName = platform === 'win32' ? 'uv.exe' : 'uv'; - + if (app.isPackaged) { return join(process.resourcesPath, 'bin', binName); } else { @@ -53,7 +53,7 @@ function resolveUvBin(): { bin: string; source: 'bundled' | 'path' | 'bundled-fa function findUvInPathSync(): boolean { try { const cmd = process.platform === 'win32' ? 'where.exe uv' : 'which uv'; - execSync(cmd, { stdio: 'ignore', timeout: 5000 }); + execSync(cmd, { stdio: 'ignore', timeout: 5000, windowsHide: true }); return true; } catch { return false; @@ -95,6 +95,7 @@ export async function isPythonReady(): Promise { try { const child = spawn(useShell ? quoteForCmd(uvBin) : uvBin, ['python', 'find', '3.12'], { shell: useShell, + windowsHide: true, }); child.on('close', (code) => resolve(code === 0)); child.on('error', () => resolve(false)); @@ -121,6 +122,7 @@ async function runPythonInstall( const child = spawn(useShell ? quoteForCmd(uvBin) : uvBin, ['python', 'install', '3.12'], { shell: useShell, env, + windowsHide: true, }); child.stdout?.on('data', (data) => { @@ -210,12 +212,13 @@ export async function setupManagedPython(): Promise { const child = spawn(verifyShell ? quoteForCmd(uvBin) : uvBin, ['python', 'find', '3.12'], { shell: verifyShell, env: { ...process.env, ...uvEnv }, + windowsHide: true, }); let output = ''; child.stdout?.on('data', (data) => { output += data; }); child.on('close', () => resolve(output.trim())); }); - + if (findPath) { logger.info(`Managed Python 3.12 installed at: ${findPath}`); } diff --git a/package.json b/package.json index a9b270561..0175a9f8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawx", - "version": "0.1.21-alpha.3", + "version": "0.1.21-alpha.5", "pnpm": { "onlyBuiltDependencies": [ "@whiskeysockets/baileys", diff --git a/tests/unit/gateway-process-policy.test.ts b/tests/unit/gateway-process-policy.test.ts new file mode 100644 index 000000000..1b7e001cb --- /dev/null +++ b/tests/unit/gateway-process-policy.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { + getReconnectSkipReason, + isLifecycleSuperseded, + nextLifecycleEpoch, +} from '@electron/gateway/process-policy'; + +describe('gateway process policy helpers', () => { + describe('lifecycle epoch helpers', () => { + it('increments lifecycle epoch by one', () => { + expect(nextLifecycleEpoch(0)).toBe(1); + expect(nextLifecycleEpoch(5)).toBe(6); + }); + + it('detects superseded lifecycle epochs', () => { + expect(isLifecycleSuperseded(3, 4)).toBe(true); + expect(isLifecycleSuperseded(8, 8)).toBe(false); + }); + }); + + describe('getReconnectSkipReason', () => { + it('skips reconnect when auto-reconnect is disabled', () => { + expect( + getReconnectSkipReason({ + scheduledEpoch: 10, + currentEpoch: 10, + shouldReconnect: false, + }) + ).toBe('auto-reconnect disabled'); + }); + + it('skips stale reconnect callbacks when lifecycle epoch changed', () => { + expect( + getReconnectSkipReason({ + scheduledEpoch: 11, + currentEpoch: 12, + shouldReconnect: true, + }) + ).toContain('stale reconnect callback'); + }); + + it('allows reconnect when callback is current and reconnect enabled', () => { + expect( + getReconnectSkipReason({ + scheduledEpoch: 7, + currentEpoch: 7, + shouldReconnect: true, + }) + ).toBeNull(); + }); + }); +});