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:
@@ -106,6 +106,7 @@ export class ClawHubService {
|
|||||||
...env,
|
...env,
|
||||||
CLAWHUB_WORKDIR: this.workDir,
|
CLAWHUB_WORKDIR: this.workDir,
|
||||||
},
|
},
|
||||||
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
23
electron/gateway/process-policy.ts
Normal file
23
electron/gateway/process-policy.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
52
tests/unit/gateway-process-policy.test.ts
Normal file
52
tests/unit/gateway-process-policy.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user