From 4d6a60fa77d5e26e1c5c07366a228f5589015ff5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Mar 2026 12:09:25 +0000 Subject: [PATCH 1/6] fix: harden gateway process shutdown lifecycle Co-authored-by: Haze --- README.ja-JP.md | 9 ++ README.md | 9 ++ README.zh-CN.md | 9 ++ electron/gateway/manager.ts | 2 + electron/gateway/supervisor.ts | 39 +++++-- electron/main/index.ts | 40 +++++++- electron/main/quit-lifecycle.ts | 30 ++++++ tests/unit/gateway-supervisor.test.ts | 137 +++++++++++++++++++++++++ tests/unit/main-quit-lifecycle.test.ts | 23 +++++ 9 files changed, 285 insertions(+), 13 deletions(-) create mode 100644 electron/main/quit-lifecycle.ts create mode 100644 tests/unit/gateway-supervisor.test.ts create mode 100644 tests/unit/main-quit-lifecycle.test.ts diff --git a/README.ja-JP.md b/README.ja-JP.md index fe6c4c6..8919251 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -251,6 +251,15 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を - **セキュアストレージ**: APIキーや機密データは、OSのネイティブセキュアストレージ機構を活用します - **CORSセーフ設計**: ローカルHTTPはMainプロキシ経由とし、Renderer側CORS問題を回避します +### プロセスモデルと Gateway トラブルシューティング + +- ClawX は Electron アプリのため、**1つのアプリインスタンスでも複数プロセス(main/renderer/zygote/utility)が表示される**のが正常です。 +- ただし OpenClaw Gateway の待受は常に**単一**であるべきです。`127.0.0.1:18789` を Listen しているプロセスは1つだけです。 +- Listen プロセスの確認例: + - macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN` + - Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen` +- ウィンドウの閉じるボタン(`X`)は既定でトレイへ最小化する動作で、完全終了ではありません。完全終了する場合はトレイメニューの **Quit ClawX** を使用してください。 + --- ## ユースケース diff --git a/README.md b/README.md index 930c14a..e577243 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,15 @@ ClawX employs a **dual-process architecture** with a unified host API layer. The - **Secure Storage**: API keys and sensitive data leverage the operating system's native secure storage mechanisms - **CORS-Safe by Design**: Local HTTP access is proxied by Main, preventing renderer-side CORS issues +### Process Model & Gateway Troubleshooting + +- ClawX is an Electron app, so **one app instance normally appears as multiple OS processes** (main/renderer/zygote/utility). This is expected. +- The OpenClaw Gateway listener should still be **single-owner**: only one process should listen on `127.0.0.1:18789`. +- To verify the active listener: + - macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN` + - Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen` +- Clicking the window close button (`X`) hides ClawX to tray; it does **not** fully quit the app. Use tray menu **Quit ClawX** for complete shutdown. + --- ## Use Cases diff --git a/README.zh-CN.md b/README.zh-CN.md index 13d93c2..6b616d5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -255,6 +255,15 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用 - **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制 - **CORS 安全**:本地 HTTP 请求由主进程代理,避免渲染进程跨域问题 +### 进程模型与 Gateway 排障 + +- ClawX 基于 Electron,**单个应用实例出现多个系统进程是正常现象**(main/renderer/zygote/utility)。 +- 但 OpenClaw Gateway 监听应始终保持**单实例**:`127.0.0.1:18789` 只能有一个监听者。 +- 可用以下命令确认监听进程: + - macOS/Linux:`lsof -nP -iTCP:18789 -sTCP:LISTEN` + - Windows(PowerShell):`Get-NetTCPConnection -LocalPort 18789 -State Listen` +- 点击窗口关闭按钮(`X`)默认只是最小化到托盘,并不会完全退出应用。请在托盘菜单中选择 **Quit ClawX** 执行完整退出。 + --- ## 使用场景 diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index c0de90b..1c09ec9 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -241,6 +241,7 @@ export class GatewayManager extends EventEmitter { onConnectedToExistingGateway: () => { this.ownsProcess = false; this.setStatus({ pid: undefined }); + logger.info(`Gateway manager attached to external process on port ${this.status.port} (ownsProcess=false)`); this.startHealthCheck(); }, waitForPortFree: async (port) => { @@ -714,6 +715,7 @@ export class GatewayManager extends EventEmitter { this.process = child; this.ownsProcess = true; + logger.debug(`Gateway manager now owns process pid=${child.pid ?? 'unknown'}`); this.lastSpawnSummary = lastSpawnSummary; } diff --git a/electron/gateway/supervisor.ts b/electron/gateway/supervisor.ts index 04391d9..07137df 100644 --- a/electron/gateway/supervisor.ts +++ b/electron/gateway/supervisor.ts @@ -24,6 +24,13 @@ export function warmupManagedPythonReadiness(): void { export async function terminateOwnedGatewayProcess(child: Electron.UtilityProcess): Promise { let exited = false; + const terminateWindowsProcessTree = async (pid: number): Promise => { + const cp = await import('child_process'); + await new Promise((resolve) => { + cp.exec(`taskkill /F /PID ${pid} /T`, { timeout: 5000, windowsHide: true }, () => resolve()); + }); + }; + await new Promise((resolve) => { child.once('exit', () => { exited = true; @@ -32,20 +39,33 @@ export async function terminateOwnedGatewayProcess(child: Electron.UtilityProces const pid = child.pid; logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`); - try { - child.kill(); - } catch { - // ignore if already exited + + if (process.platform === 'win32' && pid) { + void terminateWindowsProcessTree(pid).catch((err) => { + logger.warn(`Windows process-tree kill failed for Gateway pid=${pid}:`, err); + }); + } else { + try { + child.kill(); + } catch { + // ignore if already exited + } } const timeout = setTimeout(() => { if (!exited) { logger.warn(`Gateway did not exit in time, force-killing (pid=${pid ?? 'unknown'})`); if (pid) { - try { - process.kill(pid, 'SIGKILL'); - } catch { - // ignore + if (process.platform === 'win32') { + void terminateWindowsProcessTree(pid).catch((err) => { + logger.warn(`Forced Windows process-tree kill failed for Gateway pid=${pid}:`, err); + }); + } else { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // ignore + } } } } @@ -226,6 +246,9 @@ export async function findExistingGatewayProcess(options: { const pids = await getListeningProcessIds(port); if (pids.length > 0 && (!ownedPid || !pids.includes(String(ownedPid)))) { await terminateOrphanedProcessIds(port, pids); + if (process.platform === 'win32') { + await waitForPortFree(port, 10000); + } return null; } } catch (err) { diff --git a/electron/main/index.ts b/electron/main/index.ts index f82bb02..ec6ad99 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -27,6 +27,11 @@ import { createMainWindowFocusState, requestSecondInstanceFocus, } from './main-window-focus'; +import { + createQuitLifecycleState, + markQuitCleanupCompleted, + requestQuitLifecycleAction, +} from './quit-lifecycle'; import { getSetting } from '../utils/store'; import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config'; import { ensureAllBundledPluginsInstalled } from '../utils/plugin-install'; @@ -70,6 +75,7 @@ if (process.platform === 'linux') { // The losing process must exit immediately so it never reaches Gateway startup. const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { + console.info('[ClawX] Another instance already holds the single-instance lock; exiting duplicate process'); app.exit(0); } @@ -80,6 +86,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 +223,7 @@ async function initialize(): Promise { 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) @@ -461,15 +468,38 @@ 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'); + } + markQuitCleanupCompleted(quitLifecycleState); + app.quit(); + }); }); } diff --git a/electron/main/quit-lifecycle.ts b/electron/main/quit-lifecycle.ts new file mode 100644 index 0000000..3eaf45e --- /dev/null +++ b/electron/main/quit-lifecycle.ts @@ -0,0 +1,30 @@ +export interface QuitLifecycleState { + cleanupStarted: boolean; + cleanupCompleted: boolean; +} + +export type QuitLifecycleAction = 'start-cleanup' | 'cleanup-in-progress' | 'allow-quit'; + +export function createQuitLifecycleState(): QuitLifecycleState { + return { + cleanupStarted: false, + cleanupCompleted: false, + }; +} + +export function requestQuitLifecycleAction(state: QuitLifecycleState): QuitLifecycleAction { + if (state.cleanupCompleted) { + return 'allow-quit'; + } + + if (state.cleanupStarted) { + return 'cleanup-in-progress'; + } + + state.cleanupStarted = true; + return 'start-cleanup'; +} + +export function markQuitCleanupCompleted(state: QuitLifecycleState): void { + state.cleanupCompleted = true; +} diff --git a/tests/unit/gateway-supervisor.test.ts b/tests/unit/gateway-supervisor.test.ts new file mode 100644 index 0000000..e2b9e81 --- /dev/null +++ b/tests/unit/gateway-supervisor.test.ts @@ -0,0 +1,137 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const originalPlatform = process.platform; + +const { + mockExec, + mockCreateServer, +} = vi.hoisted(() => ({ + mockExec: vi.fn(), + mockCreateServer: vi.fn(), +})); + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => '/tmp', + }, + utilityProcess: {}, +})); + +vi.mock('child_process', () => ({ + exec: mockExec, + execSync: vi.fn(), + spawn: vi.fn(), + default: { + exec: mockExec, + execSync: vi.fn(), + spawn: vi.fn(), + }, +})); + +vi.mock('net', () => ({ + createServer: mockCreateServer, +})); + +class MockUtilityChild extends EventEmitter { + pid?: number; + kill = vi.fn(); + + constructor(pid?: number) { + super(); + this.pid = pid; + } +} + +function setPlatform(platform: string): void { + Object.defineProperty(process, 'platform', { value: platform, writable: true }); +} + +describe('gateway supervisor process cleanup', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + + mockExec.mockImplementation((_cmd: string, _opts: object, cb: (err: Error | null, stdout: string) => void) => { + cb(null, ''); + return {} as never; + }); + + mockCreateServer.mockImplementation(() => { + const handlers = new Map void>(); + return { + once(event: string, callback: (...args: unknown[]) => void) { + handlers.set(event, callback); + return this; + }, + listen() { + queueMicrotask(() => handlers.get('listening')?.()); + return this; + }, + close(callback?: () => void) { + callback?.(); + }, + }; + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true }); + }); + + it('uses taskkill tree strategy for owned process on Windows', async () => { + setPlatform('win32'); + const child = new MockUtilityChild(4321); + const { terminateOwnedGatewayProcess } = await import('@electron/gateway/supervisor'); + + const stopPromise = terminateOwnedGatewayProcess(child as unknown as Electron.UtilityProcess); + child.emit('exit', 0); + await stopPromise; + + await vi.waitFor(() => { + expect(mockExec).toHaveBeenCalledWith( + 'taskkill /F /PID 4321 /T', + expect.objectContaining({ timeout: 5000, windowsHide: true }), + expect.any(Function), + ); + }); + expect(child.kill).not.toHaveBeenCalled(); + }); + + it('uses direct child.kill for owned process on non-Windows', async () => { + setPlatform('linux'); + const child = new MockUtilityChild(9876); + const { terminateOwnedGatewayProcess } = await import('@electron/gateway/supervisor'); + + const stopPromise = terminateOwnedGatewayProcess(child as unknown as Electron.UtilityProcess); + child.emit('exit', 0); + await stopPromise; + + expect(child.kill).toHaveBeenCalledTimes(1); + }); + + it('waits for port release after orphan cleanup on Windows', async () => { + setPlatform('win32'); + const { findExistingGatewayProcess } = await import('@electron/gateway/supervisor'); + + mockExec.mockImplementation((cmd: string, _opts: object, cb: (err: Error | null, stdout: string) => void) => { + if (cmd.includes('netstat -ano')) { + cb(null, ' TCP 127.0.0.1:18789 0.0.0.0:0 LISTENING 4321\n'); + return {} as never; + } + cb(null, ''); + return {} as never; + }); + + const result = await findExistingGatewayProcess({ port: 18789 }); + expect(result).toBeNull(); + + expect(mockExec).toHaveBeenCalledWith( + expect.stringContaining('taskkill /F /PID 4321 /T'), + expect.objectContaining({ timeout: 5000, windowsHide: true }), + expect.any(Function), + ); + expect(mockCreateServer).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/main-quit-lifecycle.test.ts b/tests/unit/main-quit-lifecycle.test.ts new file mode 100644 index 0000000..78799f8 --- /dev/null +++ b/tests/unit/main-quit-lifecycle.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { + createQuitLifecycleState, + markQuitCleanupCompleted, + requestQuitLifecycleAction, +} from '@electron/main/quit-lifecycle'; + +describe('main quit lifecycle coordination', () => { + it('starts cleanup only once', () => { + const state = createQuitLifecycleState(); + + expect(requestQuitLifecycleAction(state)).toBe('start-cleanup'); + expect(requestQuitLifecycleAction(state)).toBe('cleanup-in-progress'); + }); + + it('allows quit after cleanup is marked complete', () => { + const state = createQuitLifecycleState(); + + expect(requestQuitLifecycleAction(state)).toBe('start-cleanup'); + markQuitCleanupCompleted(state); + expect(requestQuitLifecycleAction(state)).toBe('allow-quit'); + }); +}); From ad4a9d8d282aeeb5151ed4c55cc0ad167bd2b018 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Mar 2026 12:27:28 +0000 Subject: [PATCH 2/6] fix: add file-lock fallback for single-instance guard Co-authored-by: Haze --- README.ja-JP.md | 1 + README.md | 1 + README.zh-CN.md | 1 + electron/main/index.ts | 34 +++++++- electron/main/process-instance-lock.ts | 100 +++++++++++++++++++++++ tests/unit/process-instance-lock.test.ts | 80 ++++++++++++++++++ 6 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 electron/main/process-instance-lock.ts create mode 100644 tests/unit/process-instance-lock.test.ts diff --git a/README.ja-JP.md b/README.ja-JP.md index 8919251..35e0e7f 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -254,6 +254,7 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を ### プロセスモデルと Gateway トラブルシューティング - ClawX は Electron アプリのため、**1つのアプリインスタンスでも複数プロセス(main/renderer/zygote/utility)が表示される**のが正常です。 +- 単一起動保護は Electron のロックに加え、ローカルのプロセスロックファイルも併用し、デスクトップ IPC / セッションバスが不安定な環境でも重複起動を防ぎます。 - ただし OpenClaw Gateway の待受は常に**単一**であるべきです。`127.0.0.1:18789` を Listen しているプロセスは1つだけです。 - Listen プロセスの確認例: - macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN` diff --git a/README.md b/README.md index e577243..69f4a97 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ ClawX employs a **dual-process architecture** with a unified host API layer. The ### Process Model & Gateway Troubleshooting - ClawX is an Electron app, so **one app instance normally appears as multiple OS processes** (main/renderer/zygote/utility). This is expected. +- Single-instance protection uses Electron's lock plus a local process-file lock fallback, preventing duplicate app launch in environments where desktop IPC/session bus is unstable. - The OpenClaw Gateway listener should still be **single-owner**: only one process should listen on `127.0.0.1:18789`. - To verify the active listener: - macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN` diff --git a/README.zh-CN.md b/README.zh-CN.md index 6b616d5..332501e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -258,6 +258,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用 ### 进程模型与 Gateway 排障 - ClawX 基于 Electron,**单个应用实例出现多个系统进程是正常现象**(main/renderer/zygote/utility)。 +- 单实例保护同时使用 Electron 自带锁与本地进程文件锁回退机制,可在桌面会话总线异常时避免重复启动。 - 但 OpenClaw Gateway 监听应始终保持**单实例**:`127.0.0.1:18789` 只能有一个监听者。 - 可用以下命令确认监听进程: - macOS/Linux:`lsof -nP -iTCP:18789 -sTCP:LISTEN` diff --git a/electron/main/index.ts b/electron/main/index.ts index ec6ad99..e272fd2 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -32,6 +32,7 @@ import { markQuitCleanupCompleted, requestQuitLifecycleAction, } from './quit-lifecycle'; +import { acquireProcessInstanceFileLock } from './process-instance-lock'; import { getSetting } from '../utils/store'; import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config'; import { ensureAllBundledPluginsInstalled } from '../utils/plugin-install'; @@ -73,11 +74,32 @@ 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) { + console.info( + `[ClawX] Another instance already holds process lock (${fileLock.lockPath}${fileLock.ownerPid ? `, pid=${fileLock.ownerPid}` : ''}); 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; @@ -420,6 +442,14 @@ async function initialize(): Promise { } if (gotTheLock) { + process.on('exit', () => { + releaseProcessInstanceFileLock(); + }); + + app.on('will-quit', () => { + releaseProcessInstanceFileLock(); + }); + if (process.platform === 'win32') { app.setAppUserModelId(WINDOWS_APP_USER_MODEL_ID); } diff --git a/electron/main/process-instance-lock.ts b/electron/main/process-instance-lock.ts new file mode 100644 index 0000000..154d36e --- /dev/null +++ b/electron/main/process-instance-lock.ts @@ -0,0 +1,100 @@ +import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export interface ProcessInstanceFileLock { + acquired: boolean; + lockPath: string; + ownerPid?: number; + release: () => void; +} + +export interface ProcessInstanceFileLockOptions { + userDataDir: string; + lockName: string; + pid?: number; + isPidAlive?: (pid: number) => boolean; +} + +function defaultPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const errno = (error as NodeJS.ErrnoException).code; + return errno !== 'ESRCH'; + } +} + +function readLockOwnerPid(lockPath: string): number | undefined { + try { + const raw = readFileSync(lockPath, 'utf8').trim(); + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; + } catch { + return undefined; + } +} + +export function acquireProcessInstanceFileLock( + options: ProcessInstanceFileLockOptions, +): ProcessInstanceFileLock { + const pid = options.pid ?? process.pid; + const isPidAlive = options.isPidAlive ?? defaultPidAlive; + + mkdirSync(options.userDataDir, { recursive: true }); + const lockPath = join(options.userDataDir, `${options.lockName}.instance.lock`); + + let ownerPid: number | undefined; + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const fd = openSync(lockPath, 'wx'); + try { + writeFileSync(fd, String(pid), 'utf8'); + } finally { + closeSync(fd); + } + + let released = false; + return { + acquired: true, + lockPath, + release: () => { + if (released) return; + released = true; + try { + rmSync(lockPath, { force: true }); + } catch { + // best-effort + } + }, + }; + } catch (error) { + const errno = (error as NodeJS.ErrnoException).code; + if (errno !== 'EEXIST') { + break; + } + + ownerPid = readLockOwnerPid(lockPath); + if (ownerPid && !isPidAlive(ownerPid) && existsSync(lockPath)) { + try { + rmSync(lockPath, { force: true }); + continue; + } catch { + // If deletion fails, treat as held lock. + } + } + + break; + } + } + + return { + acquired: false, + lockPath, + ownerPid, + release: () => { + // no-op when lock wasn't acquired + }, + }; +} diff --git a/tests/unit/process-instance-lock.test.ts b/tests/unit/process-instance-lock.test.ts new file mode 100644 index 0000000..ebf59c4 --- /dev/null +++ b/tests/unit/process-instance-lock.test.ts @@ -0,0 +1,80 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { acquireProcessInstanceFileLock } from '@electron/main/process-instance-lock'; + +const tempDirs: string[] = []; + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'clawx-instance-lock-')); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (!dir) continue; + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('process instance file lock', () => { + it('acquires lock and writes owner pid', () => { + const userDataDir = createTempDir(); + const lock = acquireProcessInstanceFileLock({ + userDataDir, + lockName: 'clawx', + pid: 12345, + }); + + const lockPath = join(userDataDir, 'clawx.instance.lock'); + expect(lock.acquired).toBe(true); + expect(existsSync(lockPath)).toBe(true); + expect(readFileSync(lockPath, 'utf8')).toBe('12345'); + + lock.release(); + expect(existsSync(lockPath)).toBe(false); + }); + + it('rejects a second lock when owner pid is alive', () => { + const userDataDir = createTempDir(); + const first = acquireProcessInstanceFileLock({ + userDataDir, + lockName: 'clawx', + pid: 2222, + isPidAlive: () => true, + }); + + const second = acquireProcessInstanceFileLock({ + userDataDir, + lockName: 'clawx', + pid: 3333, + isPidAlive: () => true, + }); + + expect(first.acquired).toBe(true); + expect(second.acquired).toBe(false); + expect(second.ownerPid).toBe(2222); + + first.release(); + }); + + it('replaces stale lock file when owner pid is not alive', () => { + const userDataDir = createTempDir(); + const lockPath = join(userDataDir, 'clawx.instance.lock'); + writeFileSync(lockPath, '4444', 'utf8'); + + const lock = acquireProcessInstanceFileLock({ + userDataDir, + lockName: 'clawx', + pid: 5555, + isPidAlive: () => false, + }); + + expect(lock.acquired).toBe(true); + expect(readFileSync(lockPath, 'utf8')).toBe('5555'); + lock.release(); + }); +}); From 3c7a2c13d68400971cc7c0cd86b4d4be02863d20 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Mar 2026 12:37:10 +0000 Subject: [PATCH 3/6] fix: release instance lock on termination signals Co-authored-by: Haze --- electron/main/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/electron/main/index.ts b/electron/main/index.ts index e272fd2..c46255e 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -442,10 +442,19 @@ async function initialize(): Promise { } if (gotTheLock) { + const releaseProcessLockOnSignal = (signal: NodeJS.Signals): void => { + logger.info(`Received ${signal}; releasing instance lock and requesting app quit`); + releaseProcessInstanceFileLock(); + app.quit(); + }; + process.on('exit', () => { releaseProcessInstanceFileLock(); }); + process.once('SIGINT', () => releaseProcessLockOnSignal('SIGINT')); + process.once('SIGTERM', () => releaseProcessLockOnSignal('SIGTERM')); + app.on('will-quit', () => { releaseProcessInstanceFileLock(); }); From 0fea2a1c70cbfbbfdf13df38fc5eeafc419229ef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Mar 2026 15:19:45 +0000 Subject: [PATCH 4/6] fix: recover from malformed instance lock files Co-authored-by: Haze --- electron/main/process-instance-lock.ts | 4 +++- tests/unit/process-instance-lock.test.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/electron/main/process-instance-lock.ts b/electron/main/process-instance-lock.ts index 154d36e..72bdc19 100644 --- a/electron/main/process-instance-lock.ts +++ b/electron/main/process-instance-lock.ts @@ -76,7 +76,9 @@ export function acquireProcessInstanceFileLock( } ownerPid = readLockOwnerPid(lockPath); - if (ownerPid && !isPidAlive(ownerPid) && existsSync(lockPath)) { + const shouldTreatAsStale = + ownerPid === undefined || !isPidAlive(ownerPid); + if (shouldTreatAsStale && existsSync(lockPath)) { try { rmSync(lockPath, { force: true }); continue; diff --git a/tests/unit/process-instance-lock.test.ts b/tests/unit/process-instance-lock.test.ts index ebf59c4..dadb579 100644 --- a/tests/unit/process-instance-lock.test.ts +++ b/tests/unit/process-instance-lock.test.ts @@ -77,4 +77,20 @@ describe('process instance file lock', () => { expect(readFileSync(lockPath, 'utf8')).toBe('5555'); lock.release(); }); + + it('replaces malformed lock file content', () => { + const userDataDir = createTempDir(); + const lockPath = join(userDataDir, 'clawx.instance.lock'); + writeFileSync(lockPath, 'not-a-pid', 'utf8'); + + const lock = acquireProcessInstanceFileLock({ + userDataDir, + lockName: 'clawx', + pid: 6666, + }); + + expect(lock.acquired).toBe(true); + expect(readFileSync(lockPath, 'utf8')).toBe('6666'); + lock.release(); + }); }); From 4c9380f24ae4e76c392ad4ee22e10c916156a238 Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Thu, 19 Mar 2026 10:55:15 +0800 Subject: [PATCH 5/6] 0.2.6-alpha.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2791f70..2bdf059 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawx", - "version": "0.2.6-alpha.0", + "version": "0.2.6-alpha.1", "pnpm": { "onlyBuiltDependencies": [ "@discordjs/opus", From 8d387745a9f1e70760cb3d6a82eb2aa187b783b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Mar 2026 03:59:52 +0000 Subject: [PATCH 6/6] fix: harden instance lock compatibility semantics Co-authored-by: Haze --- README.ja-JP.md | 1 + README.md | 1 + README.zh-CN.md | 1 + electron/main/index.ts | 21 +++--- electron/main/process-instance-lock.ts | 85 +++++++++++++++++++++--- electron/main/signal-quit.ts | 11 +++ tests/unit/process-instance-lock.test.ts | 53 +++++++++++++-- tests/unit/signal-quit.test.ts | 15 +++++ 8 files changed, 168 insertions(+), 20 deletions(-) create mode 100644 electron/main/signal-quit.ts create mode 100644 tests/unit/signal-quit.test.ts diff --git a/README.ja-JP.md b/README.ja-JP.md index 35e0e7f..1ab1543 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -255,6 +255,7 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を - ClawX は Electron アプリのため、**1つのアプリインスタンスでも複数プロセス(main/renderer/zygote/utility)が表示される**のが正常です。 - 単一起動保護は Electron のロックに加え、ローカルのプロセスロックファイルも併用し、デスクトップ IPC / セッションバスが不安定な環境でも重複起動を防ぎます。 +- ローリングアップグレード中に旧版/新版が混在すると、単一起動保護の挙動が非対称になる場合があります。安定運用のため、デスクトップクライアントは可能な限り同一バージョンへ揃えてください。 - ただし OpenClaw Gateway の待受は常に**単一**であるべきです。`127.0.0.1:18789` を Listen しているプロセスは1つだけです。 - Listen プロセスの確認例: - macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN` diff --git a/README.md b/README.md index 69f4a97..7ae0abd 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,7 @@ ClawX employs a **dual-process architecture** with a unified host API layer. The - ClawX is an Electron app, so **one app instance normally appears as multiple OS processes** (main/renderer/zygote/utility). This is expected. - Single-instance protection uses Electron's lock plus a local process-file lock fallback, preventing duplicate app launch in environments where desktop IPC/session bus is unstable. +- During rolling upgrades, mixed old/new app versions can still have asymmetric protection behavior. For best reliability, upgrade all desktop clients to the same version. - The OpenClaw Gateway listener should still be **single-owner**: only one process should listen on `127.0.0.1:18789`. - To verify the active listener: - macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN` diff --git a/README.zh-CN.md b/README.zh-CN.md index 332501e..454dabb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -259,6 +259,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用 - ClawX 基于 Electron,**单个应用实例出现多个系统进程是正常现象**(main/renderer/zygote/utility)。 - 单实例保护同时使用 Electron 自带锁与本地进程文件锁回退机制,可在桌面会话总线异常时避免重复启动。 +- 滚动升级期间若新旧版本混跑,单实例保护仍可能出现不对称行为。为保证稳定性,建议桌面客户端尽量统一升级到同一版本。 - 但 OpenClaw Gateway 监听应始终保持**单实例**:`127.0.0.1:18789` 只能有一个监听者。 - 可用以下命令确认监听进程: - macOS/Linux:`lsof -nP -iTCP:18789 -sTCP:LISTEN` diff --git a/electron/main/index.ts b/electron/main/index.ts index c46255e..25ce180 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -32,6 +32,7 @@ import { 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'; @@ -90,8 +91,13 @@ if (gotElectronLock) { 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}${fileLock.ownerPid ? `, pid=${fileLock.ownerPid}` : ''}); exiting duplicate process`, + `[ClawX] Another instance already holds process lock (${fileLock.lockPath}, ${ownerDescriptor}); exiting duplicate process`, ); app.exit(0); } @@ -442,18 +448,17 @@ async function initialize(): Promise { } if (gotTheLock) { - const releaseProcessLockOnSignal = (signal: NodeJS.Signals): void => { - logger.info(`Received ${signal}; releasing instance lock and requesting app quit`); - releaseProcessInstanceFileLock(); - app.quit(); - }; + const requestQuitOnSignal = createSignalQuitHandler({ + logInfo: (message) => logger.info(message), + requestQuit: () => app.quit(), + }); process.on('exit', () => { releaseProcessInstanceFileLock(); }); - process.once('SIGINT', () => releaseProcessLockOnSignal('SIGINT')); - process.once('SIGTERM', () => releaseProcessLockOnSignal('SIGTERM')); + process.once('SIGINT', () => requestQuitOnSignal('SIGINT')); + process.once('SIGTERM', () => requestQuitOnSignal('SIGTERM')); app.on('will-quit', () => { releaseProcessInstanceFileLock(); diff --git a/electron/main/process-instance-lock.ts b/electron/main/process-instance-lock.ts index 72bdc19..f39cf61 100644 --- a/electron/main/process-instance-lock.ts +++ b/electron/main/process-instance-lock.ts @@ -1,10 +1,14 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +const LOCK_SCHEMA = 'clawx-instance-lock'; +const LOCK_VERSION = 1; + export interface ProcessInstanceFileLock { acquired: boolean; lockPath: string; ownerPid?: number; + ownerFormat?: 'legacy' | 'structured' | 'unknown'; release: () => void; } @@ -25,14 +29,67 @@ function defaultPidAlive(pid: number): boolean { } } -function readLockOwnerPid(lockPath: string): number | undefined { - try { - const raw = readFileSync(lockPath, 'utf8').trim(); - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; - } catch { +type ParsedLockOwner = + | { kind: 'legacy'; pid: number } + | { kind: 'structured'; pid: number } + | { kind: 'unknown' }; + +interface StructuredLockContent { + schema: string; + version: number; + pid: number; +} + +function parsePositivePid(raw: string): number | undefined { + if (!/^\d+$/.test(raw)) { return undefined; } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return undefined; + } + return parsed; +} + +function parseStructuredLockContent(raw: string): StructuredLockContent | undefined { + try { + const parsed = JSON.parse(raw) as Partial; + if ( + parsed?.schema === LOCK_SCHEMA + && parsed?.version === LOCK_VERSION + && typeof parsed?.pid === 'number' + && Number.isFinite(parsed.pid) + && parsed.pid > 0 + ) { + return { + schema: parsed.schema, + version: parsed.version, + pid: parsed.pid, + }; + } + } catch { + // ignore parse errors + } + return undefined; +} + +function readLockOwner(lockPath: string): ParsedLockOwner { + try { + const raw = readFileSync(lockPath, 'utf8').trim(); + const legacyPid = parsePositivePid(raw); + if (legacyPid !== undefined) { + return { kind: 'legacy', pid: legacyPid }; + } + + const structured = parseStructuredLockContent(raw); + if (structured) { + return { kind: 'structured', pid: structured.pid }; + } + } catch { + // ignore read errors + } + + return { kind: 'unknown' }; } export function acquireProcessInstanceFileLock( @@ -45,11 +102,14 @@ export function acquireProcessInstanceFileLock( const lockPath = join(options.userDataDir, `${options.lockName}.instance.lock`); let ownerPid: number | undefined; + let ownerFormat: ProcessInstanceFileLock['ownerFormat'] = 'unknown'; for (let attempt = 0; attempt < 2; attempt += 1) { try { const fd = openSync(lockPath, 'wx'); try { + // Keep writing legacy numeric format for broad backward compatibility. + // Parser accepts both legacy numeric and structured JSON formats. writeFileSync(fd, String(pid), 'utf8'); } finally { closeSync(fd); @@ -75,9 +135,17 @@ export function acquireProcessInstanceFileLock( break; } - ownerPid = readLockOwnerPid(lockPath); + const owner = readLockOwner(lockPath); + if (owner.kind === 'legacy' || owner.kind === 'structured') { + ownerPid = owner.pid; + ownerFormat = owner.kind; + } else { + ownerPid = undefined; + ownerFormat = 'unknown'; + } const shouldTreatAsStale = - ownerPid === undefined || !isPidAlive(ownerPid); + (owner.kind === 'legacy' || owner.kind === 'structured') + && !isPidAlive(owner.pid); if (shouldTreatAsStale && existsSync(lockPath)) { try { rmSync(lockPath, { force: true }); @@ -95,6 +163,7 @@ export function acquireProcessInstanceFileLock( acquired: false, lockPath, ownerPid, + ownerFormat, release: () => { // no-op when lock wasn't acquired }, diff --git a/electron/main/signal-quit.ts b/electron/main/signal-quit.ts new file mode 100644 index 0000000..42bbdcf --- /dev/null +++ b/electron/main/signal-quit.ts @@ -0,0 +1,11 @@ +export interface SignalQuitHandlerHooks { + logInfo: (message: string) => void; + requestQuit: () => void; +} + +export function createSignalQuitHandler(hooks: SignalQuitHandlerHooks): (signal: NodeJS.Signals) => void { + return (signal: NodeJS.Signals) => { + hooks.logInfo(`Received ${signal}; requesting app quit`); + hooks.requestQuit(); + }; +} diff --git a/tests/unit/process-instance-lock.test.ts b/tests/unit/process-instance-lock.test.ts index dadb579..b13dcdc 100644 --- a/tests/unit/process-instance-lock.test.ts +++ b/tests/unit/process-instance-lock.test.ts @@ -57,6 +57,7 @@ describe('process instance file lock', () => { expect(first.acquired).toBe(true); expect(second.acquired).toBe(false); expect(second.ownerPid).toBe(2222); + expect(second.ownerFormat).toBe('legacy'); first.release(); }); @@ -78,7 +79,28 @@ describe('process instance file lock', () => { lock.release(); }); - it('replaces malformed lock file content', () => { + it('replaces stale structured lock file when owner pid is not alive', () => { + const userDataDir = createTempDir(); + const lockPath = join(userDataDir, 'clawx.instance.lock'); + writeFileSync(lockPath, JSON.stringify({ + schema: 'clawx-instance-lock', + version: 1, + pid: 7777, + }), 'utf8'); + + const lock = acquireProcessInstanceFileLock({ + userDataDir, + lockName: 'clawx', + pid: 6666, + isPidAlive: () => false, + }); + + expect(lock.acquired).toBe(true); + expect(readFileSync(lockPath, 'utf8')).toBe('6666'); + lock.release(); + }); + + it('does not treat malformed lock file content as stale', () => { const userDataDir = createTempDir(); const lockPath = join(userDataDir, 'clawx.instance.lock'); writeFileSync(lockPath, 'not-a-pid', 'utf8'); @@ -89,8 +111,31 @@ describe('process instance file lock', () => { pid: 6666, }); - expect(lock.acquired).toBe(true); - expect(readFileSync(lockPath, 'utf8')).toBe('6666'); - lock.release(); + expect(lock.acquired).toBe(false); + expect(lock.ownerPid).toBeUndefined(); + expect(lock.ownerFormat).toBe('unknown'); + expect(readFileSync(lockPath, 'utf8')).toBe('not-a-pid'); + }); + + it('does not treat unknown structured lock schema as stale', () => { + const userDataDir = createTempDir(); + const lockPath = join(userDataDir, 'clawx.instance.lock'); + writeFileSync(lockPath, JSON.stringify({ + schema: 'future-lock-schema', + version: 2, + pid: 8888, + owner: 'future-build', + }), 'utf8'); + + const lock = acquireProcessInstanceFileLock({ + userDataDir, + lockName: 'clawx', + pid: 9999, + }); + + expect(lock.acquired).toBe(false); + expect(lock.ownerPid).toBeUndefined(); + expect(lock.ownerFormat).toBe('unknown'); + expect(readFileSync(lockPath, 'utf8')).toContain('future-lock-schema'); }); }); diff --git a/tests/unit/signal-quit.test.ts b/tests/unit/signal-quit.test.ts new file mode 100644 index 0000000..82d3570 --- /dev/null +++ b/tests/unit/signal-quit.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createSignalQuitHandler } from '@electron/main/signal-quit'; + +describe('signal quit handler', () => { + it('logs and requests quit when signal is received', () => { + const logInfo = vi.fn(); + const requestQuit = vi.fn(); + const handler = createSignalQuitHandler({ logInfo, requestQuit }); + + handler('SIGTERM'); + + expect(logInfo).toHaveBeenCalledWith('Received SIGTERM; requesting app quit'); + expect(requestQuit).toHaveBeenCalledTimes(1); + }); +});