From a0fc786d6e37f7ba84039453d874509d1e7dcc36 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Mar 2026 12:27:28 +0000 Subject: [PATCH] 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(); + }); +});