fix(win): Gateway restart win terminal open error (#265)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
Haze
2026-03-02 21:28:01 +08:00
committed by GitHub
Unverified
parent 382d737fa7
commit 9532400053
8 changed files with 150 additions and 23 deletions

View File

@@ -106,6 +106,7 @@ export class ClawHubService {
...env, ...env,
CLAWHUB_WORKDIR: this.workDir, CLAWHUB_WORKDIR: this.workDir,
}, },
windowsHide: true,
}); });
let stdout = ''; let stdout = '';

View File

@@ -35,6 +35,11 @@ import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, sanitizeOpenClaw
import { buildProxyEnv, resolveProxySettings } from '../utils/proxy'; import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy'; import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
import { shouldAttemptConfigAutoRepair } from './startup-recovery'; import { shouldAttemptConfigAutoRepair } from './startup-recovery';
import {
getReconnectSkipReason,
isLifecycleSuperseded,
nextLifecycleEpoch,
} from './process-policy';
/** /**
* Gateway connection status * Gateway connection status
@@ -162,6 +167,13 @@ function ensureGatewayFetchPreload(): string {
return dest; return dest;
} }
class LifecycleSupersededError extends Error {
constructor(message: string) {
super(message);
this.name = 'LifecycleSupersededError';
}
}
/** /**
* Gateway Manager * Gateway Manager
* Handles starting, stopping, and communicating with the OpenClaw Gateway * Handles starting, stopping, and communicating with the OpenClaw Gateway
@@ -187,6 +199,7 @@ export class GatewayManager extends EventEmitter {
}> = new Map(); }> = new Map();
private deviceIdentity: DeviceIdentity | null = null; private deviceIdentity: DeviceIdentity | null = null;
private restartDebounceTimer: NodeJS.Timeout | null = null; private restartDebounceTimer: NodeJS.Timeout | null = null;
private lifecycleEpoch = 0;
constructor(config?: Partial<ReconnectConfig>) { constructor(config?: Partial<ReconnectConfig>) {
super(); 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 * Get current Gateway status
*/ */
@@ -276,6 +303,7 @@ export class GatewayManager extends EventEmitter {
} }
this.startLock = true; this.startLock = true;
const startEpoch = this.bumpLifecycleEpoch('start');
logger.info(`Gateway start requested (port=${this.status.port})`); logger.info(`Gateway start requested (port=${this.status.port})`);
this.lastSpawnSummary = null; this.lastSpawnSummary = null;
this.shouldReconnect = true; this.shouldReconnect = true;
@@ -310,14 +338,17 @@ export class GatewayManager extends EventEmitter {
try { try {
while (true) { while (true) {
this.assertLifecycleEpoch(startEpoch, 'start');
this.recentStartupStderrLines = []; this.recentStartupStderrLines = [];
try { try {
// Check if Gateway is already running // Check if Gateway is already running
logger.debug('Checking for existing Gateway...'); logger.debug('Checking for existing Gateway...');
const existing = await this.findExistingGateway(); const existing = await this.findExistingGateway();
this.assertLifecycleEpoch(startEpoch, 'start/find-existing');
if (existing) { if (existing) {
logger.debug(`Found existing Gateway on port ${existing.port}`); logger.debug(`Found existing Gateway on port ${existing.port}`);
await this.connect(existing.port, existing.externalToken); await this.connect(existing.port, existing.externalToken);
this.assertLifecycleEpoch(startEpoch, 'start/connect-existing');
this.ownsProcess = false; this.ownsProcess = false;
this.setStatus({ pid: undefined }); this.setStatus({ pid: undefined });
this.startHealthCheck(); this.startHealthCheck();
@@ -328,18 +359,24 @@ export class GatewayManager extends EventEmitter {
// Start new Gateway process // Start new Gateway process
await this.startProcess(); await this.startProcess();
this.assertLifecycleEpoch(startEpoch, 'start/start-process');
// Wait for Gateway to be ready // Wait for Gateway to be ready
await this.waitForReady(); await this.waitForReady();
this.assertLifecycleEpoch(startEpoch, 'start/wait-ready');
// Connect WebSocket // Connect WebSocket
await this.connect(this.status.port); await this.connect(this.status.port);
this.assertLifecycleEpoch(startEpoch, 'start/connect');
// Start health monitoring // Start health monitoring
this.startHealthCheck(); this.startHealthCheck();
logger.debug('Gateway started successfully'); logger.debug('Gateway started successfully');
return; return;
} catch (error) { } catch (error) {
if (error instanceof LifecycleSupersededError) {
throw error;
}
if (shouldAttemptConfigAutoRepair(error, this.recentStartupStderrLines, configRepairAttempted)) { if (shouldAttemptConfigAutoRepair(error, this.recentStartupStderrLines, configRepairAttempted)) {
configRepairAttempted = true; configRepairAttempted = true;
logger.warn( logger.warn(
@@ -358,6 +395,10 @@ export class GatewayManager extends EventEmitter {
} }
} catch (error) { } catch (error) {
if (error instanceof LifecycleSupersededError) {
logger.debug(error.message);
return;
}
logger.error( logger.error(
`Gateway start failed (port=${this.status.port}, reconnectAttempts=${this.reconnectAttempts}, spawn=${this.lastSpawnSummary ?? 'n/a'})`, `Gateway start failed (port=${this.status.port}, reconnectAttempts=${this.reconnectAttempts}, spawn=${this.lastSpawnSummary ?? 'n/a'})`,
error error
@@ -374,6 +415,7 @@ export class GatewayManager extends EventEmitter {
*/ */
async stop(): Promise<void> { async stop(): Promise<void> {
logger.info('Gateway stop requested'); logger.info('Gateway stop requested');
this.bumpLifecycleEpoch('stop');
// Disable auto-reconnect // Disable auto-reconnect
this.shouldReconnect = false; this.shouldReconnect = false;
@@ -666,7 +708,7 @@ export class GatewayManager extends EventEmitter {
const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => { const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => {
import('child_process').then(cp => { 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: '' }); if (err) resolve({ stdout: '' });
else resolve({ stdout }); else resolve({ stdout });
}); });
@@ -694,7 +736,11 @@ export class GatewayManager extends EventEmitter {
if (process.platform === 'win32') { if (process.platform === 'win32') {
// On Windows, use taskkill for reliable process group termination // On Windows, use taskkill for reliable process group termination
import('child_process').then(cp => { 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(() => { }); }).catch(() => { });
} else { } else {
// SIGTERM first so the gateway can clean up its lock file. // SIGTERM first so the gateway can clean up its lock file.
@@ -797,6 +843,7 @@ export class GatewayManager extends EventEmitter {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
detached: false, detached: false,
shell: false, shell: false,
windowsHide: true,
env: spawnEnv, env: spawnEnv,
}); });
@@ -1050,6 +1097,7 @@ export class GatewayManager extends EventEmitter {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
detached: false, detached: false,
shell: useShell, shell: useShell,
windowsHide: true,
env: spawnEnv, env: spawnEnv,
}); });
const child = this.process; const child = this.process;
@@ -1557,27 +1605,24 @@ export class GatewayManager extends EventEmitter {
state: 'reconnecting', state: 'reconnecting',
reconnectAttempts: this.reconnectAttempts reconnectAttempts: this.reconnectAttempts
}); });
const scheduledEpoch = this.lifecycleEpoch;
this.reconnectTimer = setTimeout(async () => { this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null; this.reconnectTimer = null;
const skipReason = getReconnectSkipReason({
scheduledEpoch,
currentEpoch: this.lifecycleEpoch,
shouldReconnect: this.shouldReconnect,
});
if (skipReason) {
logger.debug(`Skipping reconnect attempt: ${skipReason}`);
return;
}
try { try {
// Try to find existing Gateway first // Use the guarded start() flow so reconnect attempts cannot bypass
const existing = await this.findExistingGateway(); // lifecycle locking and accidentally start duplicate Gateway processes.
if (existing) { await this.start();
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);
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
this.startHealthCheck();
} catch (error) { } catch (error) {
logger.error('Gateway reconnection attempt failed:', error); logger.error('Gateway reconnection attempt failed:', error);
this.scheduleReconnect(); this.scheduleReconnect();

View File

@@ -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;
}

View File

@@ -526,6 +526,7 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
cwd: openclawPath, cwd: openclawPath,
encoding: 'utf-8', encoding: 'utf-8',
timeout: 30000, timeout: 30000,
windowsHide: true,
}, },
(err, stdout) => { (err, stdout) => {
if (err) reject(err); if (err) reject(err);

View File

@@ -269,6 +269,7 @@ export function generateCompletionCache(): void {
}, },
stdio: 'ignore', stdio: 'ignore',
detached: false, detached: false,
windowsHide: true,
}); });
child.on('close', (code) => { child.on('close', (code) => {
@@ -305,7 +306,8 @@ export function installCompletionToProfile(): void {
}, },
stdio: 'ignore', stdio: 'ignore',
detached: false, detached: false,
}, windowsHide: true,
}
); );
child.on('close', (code) => { child.on('close', (code) => {

View File

@@ -14,7 +14,7 @@ function getBundledUvPath(): string {
const arch = process.arch; const arch = process.arch;
const target = `${platform}-${arch}`; const target = `${platform}-${arch}`;
const binName = platform === 'win32' ? 'uv.exe' : 'uv'; const binName = platform === 'win32' ? 'uv.exe' : 'uv';
if (app.isPackaged) { if (app.isPackaged) {
return join(process.resourcesPath, 'bin', binName); return join(process.resourcesPath, 'bin', binName);
} else { } else {
@@ -53,7 +53,7 @@ function resolveUvBin(): { bin: string; source: 'bundled' | 'path' | 'bundled-fa
function findUvInPathSync(): boolean { function findUvInPathSync(): boolean {
try { try {
const cmd = process.platform === 'win32' ? 'where.exe uv' : 'which uv'; 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; return true;
} catch { } catch {
return false; return false;
@@ -95,6 +95,7 @@ export async function isPythonReady(): Promise<boolean> {
try { try {
const child = spawn(useShell ? quoteForCmd(uvBin) : uvBin, ['python', 'find', '3.12'], { const child = spawn(useShell ? quoteForCmd(uvBin) : uvBin, ['python', 'find', '3.12'], {
shell: useShell, shell: useShell,
windowsHide: true,
}); });
child.on('close', (code) => resolve(code === 0)); child.on('close', (code) => resolve(code === 0));
child.on('error', () => resolve(false)); child.on('error', () => resolve(false));
@@ -121,6 +122,7 @@ async function runPythonInstall(
const child = spawn(useShell ? quoteForCmd(uvBin) : uvBin, ['python', 'install', '3.12'], { const child = spawn(useShell ? quoteForCmd(uvBin) : uvBin, ['python', 'install', '3.12'], {
shell: useShell, shell: useShell,
env, env,
windowsHide: true,
}); });
child.stdout?.on('data', (data) => { child.stdout?.on('data', (data) => {
@@ -210,12 +212,13 @@ export async function setupManagedPython(): Promise<void> {
const child = spawn(verifyShell ? quoteForCmd(uvBin) : uvBin, ['python', 'find', '3.12'], { const child = spawn(verifyShell ? quoteForCmd(uvBin) : uvBin, ['python', 'find', '3.12'], {
shell: verifyShell, shell: verifyShell,
env: { ...process.env, ...uvEnv }, env: { ...process.env, ...uvEnv },
windowsHide: true,
}); });
let output = ''; let output = '';
child.stdout?.on('data', (data) => { output += data; }); child.stdout?.on('data', (data) => { output += data; });
child.on('close', () => resolve(output.trim())); child.on('close', () => resolve(output.trim()));
}); });
if (findPath) { if (findPath) {
logger.info(`Managed Python 3.12 installed at: ${findPath}`); logger.info(`Managed Python 3.12 installed at: ${findPath}`);
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "clawx", "name": "clawx",
"version": "0.1.21-alpha.3", "version": "0.1.21-alpha.5",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@whiskeysockets/baileys", "@whiskeysockets/baileys",

View File

@@ -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();
});
});
});