fix: add file-lock fallback for single-instance guard
Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
@@ -254,6 +254,7 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を
|
|||||||
### プロセスモデルと Gateway トラブルシューティング
|
### プロセスモデルと Gateway トラブルシューティング
|
||||||
|
|
||||||
- ClawX は Electron アプリのため、**1つのアプリインスタンスでも複数プロセス(main/renderer/zygote/utility)が表示される**のが正常です。
|
- ClawX は Electron アプリのため、**1つのアプリインスタンスでも複数プロセス(main/renderer/zygote/utility)が表示される**のが正常です。
|
||||||
|
- 単一起動保護は Electron のロックに加え、ローカルのプロセスロックファイルも併用し、デスクトップ IPC / セッションバスが不安定な環境でも重複起動を防ぎます。
|
||||||
- ただし OpenClaw Gateway の待受は常に**単一**であるべきです。`127.0.0.1:18789` を Listen しているプロセスは1つだけです。
|
- ただし OpenClaw Gateway の待受は常に**単一**であるべきです。`127.0.0.1:18789` を Listen しているプロセスは1つだけです。
|
||||||
- Listen プロセスの確認例:
|
- Listen プロセスの確認例:
|
||||||
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
|
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ ClawX employs a **dual-process architecture** with a unified host API layer. The
|
|||||||
### Process Model & Gateway Troubleshooting
|
### 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.
|
- 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`.
|
- 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:
|
- To verify the active listener:
|
||||||
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
|
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
|
|||||||
### 进程模型与 Gateway 排障
|
### 进程模型与 Gateway 排障
|
||||||
|
|
||||||
- ClawX 基于 Electron,**单个应用实例出现多个系统进程是正常现象**(main/renderer/zygote/utility)。
|
- ClawX 基于 Electron,**单个应用实例出现多个系统进程是正常现象**(main/renderer/zygote/utility)。
|
||||||
|
- 单实例保护同时使用 Electron 自带锁与本地进程文件锁回退机制,可在桌面会话总线异常时避免重复启动。
|
||||||
- 但 OpenClaw Gateway 监听应始终保持**单实例**:`127.0.0.1:18789` 只能有一个监听者。
|
- 但 OpenClaw Gateway 监听应始终保持**单实例**:`127.0.0.1:18789` 只能有一个监听者。
|
||||||
- 可用以下命令确认监听进程:
|
- 可用以下命令确认监听进程:
|
||||||
- macOS/Linux:`lsof -nP -iTCP:18789 -sTCP:LISTEN`
|
- macOS/Linux:`lsof -nP -iTCP:18789 -sTCP:LISTEN`
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
markQuitCleanupCompleted,
|
markQuitCleanupCompleted,
|
||||||
requestQuitLifecycleAction,
|
requestQuitLifecycleAction,
|
||||||
} from './quit-lifecycle';
|
} from './quit-lifecycle';
|
||||||
|
import { acquireProcessInstanceFileLock } from './process-instance-lock';
|
||||||
import { getSetting } from '../utils/store';
|
import { getSetting } from '../utils/store';
|
||||||
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config';
|
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config';
|
||||||
import { ensureAllBundledPluginsInstalled } from '../utils/plugin-install';
|
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
|
// same port, then each treats the other's gateway as "orphaned" and kills
|
||||||
// it — creating an infinite kill/restart loop on Windows.
|
// it — creating an infinite kill/restart loop on Windows.
|
||||||
// The losing process must exit immediately so it never reaches Gateway startup.
|
// The losing process must exit immediately so it never reaches Gateway startup.
|
||||||
const gotTheLock = app.requestSingleInstanceLock();
|
const gotElectronLock = app.requestSingleInstanceLock();
|
||||||
if (!gotTheLock) {
|
if (!gotElectronLock) {
|
||||||
console.info('[ClawX] Another instance already holds the single-instance lock; exiting duplicate process');
|
console.info('[ClawX] Another instance already holds the single-instance lock; exiting duplicate process');
|
||||||
app.exit(0);
|
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
|
// Global references
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
@@ -420,6 +442,14 @@ async function initialize(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (gotTheLock) {
|
if (gotTheLock) {
|
||||||
|
process.on('exit', () => {
|
||||||
|
releaseProcessInstanceFileLock();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('will-quit', () => {
|
||||||
|
releaseProcessInstanceFileLock();
|
||||||
|
});
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
app.setAppUserModelId(WINDOWS_APP_USER_MODEL_ID);
|
app.setAppUserModelId(WINDOWS_APP_USER_MODEL_ID);
|
||||||
}
|
}
|
||||||
|
|||||||
100
electron/main/process-instance-lock.ts
Normal file
100
electron/main/process-instance-lock.ts
Normal file
@@ -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
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
80
tests/unit/process-instance-lock.test.ts
Normal file
80
tests/unit/process-instance-lock.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user