fix(processes): fix multiple clawx processes running concurently (#589)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Haze <hazeone@users.noreply.github.com> Co-authored-by: paisley <8197966+su8su@users.noreply.github.com> Co-authored-by: Felix <24791380+vcfgv@users.noreply.github.com>
This commit is contained in:
@@ -27,6 +27,13 @@ import {
|
||||
createMainWindowFocusState,
|
||||
requestSecondInstanceFocus,
|
||||
} from './main-window-focus';
|
||||
import {
|
||||
createQuitLifecycleState,
|
||||
markQuitCleanupCompleted,
|
||||
requestQuitLifecycleAction,
|
||||
} from './quit-lifecycle';
|
||||
import { createSignalQuitHandler } from './signal-quit';
|
||||
import { acquireProcessInstanceFileLock } from './process-instance-lock';
|
||||
import { getSetting } from '../utils/store';
|
||||
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config';
|
||||
import { ensureAllBundledPluginsInstalled } from '../utils/plugin-install';
|
||||
@@ -68,10 +75,37 @@ if (process.platform === 'linux') {
|
||||
// same port, then each treats the other's gateway as "orphaned" and kills
|
||||
// it — creating an infinite kill/restart loop on Windows.
|
||||
// The losing process must exit immediately so it never reaches Gateway startup.
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
const gotElectronLock = app.requestSingleInstanceLock();
|
||||
if (!gotElectronLock) {
|
||||
console.info('[ClawX] Another instance already holds the single-instance lock; exiting duplicate process');
|
||||
app.exit(0);
|
||||
}
|
||||
let releaseProcessInstanceFileLock: () => void = () => {};
|
||||
let gotFileLock = true;
|
||||
if (gotElectronLock) {
|
||||
try {
|
||||
const fileLock = acquireProcessInstanceFileLock({
|
||||
userDataDir: app.getPath('userData'),
|
||||
lockName: 'clawx',
|
||||
});
|
||||
gotFileLock = fileLock.acquired;
|
||||
releaseProcessInstanceFileLock = fileLock.release;
|
||||
if (!fileLock.acquired) {
|
||||
const ownerDescriptor = fileLock.ownerPid
|
||||
? `${fileLock.ownerFormat ?? 'legacy'} pid=${fileLock.ownerPid}`
|
||||
: fileLock.ownerFormat === 'unknown'
|
||||
? 'unknown lock format/content'
|
||||
: 'unknown owner';
|
||||
console.info(
|
||||
`[ClawX] Another instance already holds process lock (${fileLock.lockPath}, ${ownerDescriptor}); exiting duplicate process`,
|
||||
);
|
||||
app.exit(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ClawX] Failed to acquire process instance file lock; continuing with Electron single-instance lock only', error);
|
||||
}
|
||||
}
|
||||
const gotTheLock = gotElectronLock && gotFileLock;
|
||||
|
||||
// Global references
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
@@ -80,6 +114,7 @@ let clawHubService!: ClawHubService;
|
||||
let hostEventBus!: HostEventBus;
|
||||
let hostApiServer: Server | null = null;
|
||||
const mainWindowFocusState = createMainWindowFocusState();
|
||||
const quitLifecycleState = createQuitLifecycleState();
|
||||
|
||||
/**
|
||||
* Resolve the icons directory path (works in both dev and packaged mode)
|
||||
@@ -216,7 +251,7 @@ async function initialize(): Promise<void> {
|
||||
logger.init();
|
||||
logger.info('=== ClawX Application Starting ===');
|
||||
logger.debug(
|
||||
`Runtime: platform=${process.platform}/${process.arch}, electron=${process.versions.electron}, node=${process.versions.node}, packaged=${app.isPackaged}`
|
||||
`Runtime: platform=${process.platform}/${process.arch}, electron=${process.versions.electron}, node=${process.versions.node}, packaged=${app.isPackaged}, pid=${process.pid}, ppid=${process.ppid}`
|
||||
);
|
||||
|
||||
// Warm up network optimization (non-blocking)
|
||||
@@ -413,6 +448,22 @@ async function initialize(): Promise<void> {
|
||||
}
|
||||
|
||||
if (gotTheLock) {
|
||||
const requestQuitOnSignal = createSignalQuitHandler({
|
||||
logInfo: (message) => logger.info(message),
|
||||
requestQuit: () => app.quit(),
|
||||
});
|
||||
|
||||
process.on('exit', () => {
|
||||
releaseProcessInstanceFileLock();
|
||||
});
|
||||
|
||||
process.once('SIGINT', () => requestQuitOnSignal('SIGINT'));
|
||||
process.once('SIGTERM', () => requestQuitOnSignal('SIGTERM'));
|
||||
|
||||
app.on('will-quit', () => {
|
||||
releaseProcessInstanceFileLock();
|
||||
});
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
app.setAppUserModelId(WINDOWS_APP_USER_MODEL_ID);
|
||||
}
|
||||
@@ -461,15 +512,69 @@ if (gotTheLock) {
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
app.on('before-quit', (event) => {
|
||||
setQuitting();
|
||||
const action = requestQuitLifecycleAction(quitLifecycleState);
|
||||
|
||||
if (action === 'allow-quit') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (action === 'cleanup-in-progress') {
|
||||
logger.debug('Quit requested while cleanup already in progress; waiting for shutdown task to finish');
|
||||
return;
|
||||
}
|
||||
|
||||
hostEventBus.closeAll();
|
||||
hostApiServer?.close();
|
||||
// Fire-and-forget: do not await gatewayManager.stop() here.
|
||||
// Awaiting inside before-quit can stall Electron's quit sequence.
|
||||
void gatewayManager.stop().catch((err) => {
|
||||
|
||||
const stopPromise = gatewayManager.stop().catch((err) => {
|
||||
logger.warn('gatewayManager.stop() error during quit:', err);
|
||||
});
|
||||
const timeoutPromise = new Promise<'timeout'>((resolve) => {
|
||||
setTimeout(() => resolve('timeout'), 5000);
|
||||
});
|
||||
|
||||
void Promise.race([stopPromise.then(() => 'stopped' as const), timeoutPromise]).then((result) => {
|
||||
if (result === 'timeout') {
|
||||
logger.warn('Gateway shutdown timed out during app quit; proceeding with forced quit');
|
||||
void gatewayManager.forceTerminateOwnedProcessForQuit().then((terminated) => {
|
||||
if (terminated) {
|
||||
logger.warn('Forced gateway process termination completed after quit timeout');
|
||||
}
|
||||
}).catch((err) => {
|
||||
logger.warn('Forced gateway termination failed after quit timeout:', err);
|
||||
});
|
||||
}
|
||||
markQuitCleanupCompleted(quitLifecycleState);
|
||||
app.quit();
|
||||
});
|
||||
});
|
||||
|
||||
// Best-effort Gateway cleanup on unexpected crashes.
|
||||
// These handlers attempt to terminate the Gateway child process within a
|
||||
// short timeout before force-exiting, preventing orphaned processes.
|
||||
const emergencyGatewayCleanup = (reason: string, error: unknown): void => {
|
||||
logger.error(`${reason}:`, error);
|
||||
try {
|
||||
void gatewayManager?.stop().catch(() => { /* ignore */ });
|
||||
} catch {
|
||||
// ignore — stop() may not be callable if state is corrupted
|
||||
}
|
||||
// Give Gateway stop a brief window, then force-exit.
|
||||
setTimeout(() => {
|
||||
process.exit(1);
|
||||
}, 3000).unref();
|
||||
};
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
emergencyGatewayCleanup('Uncaught exception in main process', error);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
emergencyGatewayCleanup('Unhandled promise rejection in main process', reason);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user