fix: harden instance lock compatibility semantics
Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
@@ -255,6 +255,7 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を
|
|||||||
|
|
||||||
- ClawX は Electron アプリのため、**1つのアプリインスタンスでも複数プロセス(main/renderer/zygote/utility)が表示される**のが正常です。
|
- ClawX は Electron アプリのため、**1つのアプリインスタンスでも複数プロセス(main/renderer/zygote/utility)が表示される**のが正常です。
|
||||||
- 単一起動保護は Electron のロックに加え、ローカルのプロセスロックファイルも併用し、デスクトップ IPC / セッションバスが不安定な環境でも重複起動を防ぎます。
|
- 単一起動保護は 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`
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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`.
|
- 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`
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
|
|||||||
|
|
||||||
- ClawX 基于 Electron,**单个应用实例出现多个系统进程是正常现象**(main/renderer/zygote/utility)。
|
- ClawX 基于 Electron,**单个应用实例出现多个系统进程是正常现象**(main/renderer/zygote/utility)。
|
||||||
- 单实例保护同时使用 Electron 自带锁与本地进程文件锁回退机制,可在桌面会话总线异常时避免重复启动。
|
- 单实例保护同时使用 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 { createSignalQuitHandler } from './signal-quit';
|
||||||
import { acquireProcessInstanceFileLock } from './process-instance-lock';
|
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';
|
||||||
@@ -90,8 +91,13 @@ if (gotElectronLock) {
|
|||||||
gotFileLock = fileLock.acquired;
|
gotFileLock = fileLock.acquired;
|
||||||
releaseProcessInstanceFileLock = fileLock.release;
|
releaseProcessInstanceFileLock = fileLock.release;
|
||||||
if (!fileLock.acquired) {
|
if (!fileLock.acquired) {
|
||||||
|
const ownerDescriptor = fileLock.ownerPid
|
||||||
|
? `${fileLock.ownerFormat ?? 'legacy'} pid=${fileLock.ownerPid}`
|
||||||
|
: fileLock.ownerFormat === 'unknown'
|
||||||
|
? 'unknown lock format/content'
|
||||||
|
: 'unknown owner';
|
||||||
console.info(
|
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);
|
app.exit(0);
|
||||||
}
|
}
|
||||||
@@ -442,18 +448,17 @@ async function initialize(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (gotTheLock) {
|
if (gotTheLock) {
|
||||||
const releaseProcessLockOnSignal = (signal: NodeJS.Signals): void => {
|
const requestQuitOnSignal = createSignalQuitHandler({
|
||||||
logger.info(`Received ${signal}; releasing instance lock and requesting app quit`);
|
logInfo: (message) => logger.info(message),
|
||||||
releaseProcessInstanceFileLock();
|
requestQuit: () => app.quit(),
|
||||||
app.quit();
|
});
|
||||||
};
|
|
||||||
|
|
||||||
process.on('exit', () => {
|
process.on('exit', () => {
|
||||||
releaseProcessInstanceFileLock();
|
releaseProcessInstanceFileLock();
|
||||||
});
|
});
|
||||||
|
|
||||||
process.once('SIGINT', () => releaseProcessLockOnSignal('SIGINT'));
|
process.once('SIGINT', () => requestQuitOnSignal('SIGINT'));
|
||||||
process.once('SIGTERM', () => releaseProcessLockOnSignal('SIGTERM'));
|
process.once('SIGTERM', () => requestQuitOnSignal('SIGTERM'));
|
||||||
|
|
||||||
app.on('will-quit', () => {
|
app.on('will-quit', () => {
|
||||||
releaseProcessInstanceFileLock();
|
releaseProcessInstanceFileLock();
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const LOCK_SCHEMA = 'clawx-instance-lock';
|
||||||
|
const LOCK_VERSION = 1;
|
||||||
|
|
||||||
export interface ProcessInstanceFileLock {
|
export interface ProcessInstanceFileLock {
|
||||||
acquired: boolean;
|
acquired: boolean;
|
||||||
lockPath: string;
|
lockPath: string;
|
||||||
ownerPid?: number;
|
ownerPid?: number;
|
||||||
|
ownerFormat?: 'legacy' | 'structured' | 'unknown';
|
||||||
release: () => void;
|
release: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,14 +29,67 @@ function defaultPidAlive(pid: number): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readLockOwnerPid(lockPath: string): number | undefined {
|
type ParsedLockOwner =
|
||||||
try {
|
| { kind: 'legacy'; pid: number }
|
||||||
const raw = readFileSync(lockPath, 'utf8').trim();
|
| { kind: 'structured'; pid: number }
|
||||||
const parsed = Number.parseInt(raw, 10);
|
| { kind: 'unknown' };
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
||||||
} catch {
|
interface StructuredLockContent {
|
||||||
|
schema: string;
|
||||||
|
version: number;
|
||||||
|
pid: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositivePid(raw: string): number | undefined {
|
||||||
|
if (!/^\d+$/.test(raw)) {
|
||||||
return undefined;
|
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<StructuredLockContent>;
|
||||||
|
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(
|
export function acquireProcessInstanceFileLock(
|
||||||
@@ -45,11 +102,14 @@ export function acquireProcessInstanceFileLock(
|
|||||||
const lockPath = join(options.userDataDir, `${options.lockName}.instance.lock`);
|
const lockPath = join(options.userDataDir, `${options.lockName}.instance.lock`);
|
||||||
|
|
||||||
let ownerPid: number | undefined;
|
let ownerPid: number | undefined;
|
||||||
|
let ownerFormat: ProcessInstanceFileLock['ownerFormat'] = 'unknown';
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const fd = openSync(lockPath, 'wx');
|
const fd = openSync(lockPath, 'wx');
|
||||||
try {
|
try {
|
||||||
|
// Keep writing legacy numeric format for broad backward compatibility.
|
||||||
|
// Parser accepts both legacy numeric and structured JSON formats.
|
||||||
writeFileSync(fd, String(pid), 'utf8');
|
writeFileSync(fd, String(pid), 'utf8');
|
||||||
} finally {
|
} finally {
|
||||||
closeSync(fd);
|
closeSync(fd);
|
||||||
@@ -75,9 +135,17 @@ export function acquireProcessInstanceFileLock(
|
|||||||
break;
|
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 =
|
const shouldTreatAsStale =
|
||||||
ownerPid === undefined || !isPidAlive(ownerPid);
|
(owner.kind === 'legacy' || owner.kind === 'structured')
|
||||||
|
&& !isPidAlive(owner.pid);
|
||||||
if (shouldTreatAsStale && existsSync(lockPath)) {
|
if (shouldTreatAsStale && existsSync(lockPath)) {
|
||||||
try {
|
try {
|
||||||
rmSync(lockPath, { force: true });
|
rmSync(lockPath, { force: true });
|
||||||
@@ -95,6 +163,7 @@ export function acquireProcessInstanceFileLock(
|
|||||||
acquired: false,
|
acquired: false,
|
||||||
lockPath,
|
lockPath,
|
||||||
ownerPid,
|
ownerPid,
|
||||||
|
ownerFormat,
|
||||||
release: () => {
|
release: () => {
|
||||||
// no-op when lock wasn't acquired
|
// no-op when lock wasn't acquired
|
||||||
},
|
},
|
||||||
|
|||||||
11
electron/main/signal-quit.ts
Normal file
11
electron/main/signal-quit.ts
Normal file
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ describe('process instance file lock', () => {
|
|||||||
expect(first.acquired).toBe(true);
|
expect(first.acquired).toBe(true);
|
||||||
expect(second.acquired).toBe(false);
|
expect(second.acquired).toBe(false);
|
||||||
expect(second.ownerPid).toBe(2222);
|
expect(second.ownerPid).toBe(2222);
|
||||||
|
expect(second.ownerFormat).toBe('legacy');
|
||||||
|
|
||||||
first.release();
|
first.release();
|
||||||
});
|
});
|
||||||
@@ -78,7 +79,28 @@ describe('process instance file lock', () => {
|
|||||||
lock.release();
|
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 userDataDir = createTempDir();
|
||||||
const lockPath = join(userDataDir, 'clawx.instance.lock');
|
const lockPath = join(userDataDir, 'clawx.instance.lock');
|
||||||
writeFileSync(lockPath, 'not-a-pid', 'utf8');
|
writeFileSync(lockPath, 'not-a-pid', 'utf8');
|
||||||
@@ -89,8 +111,31 @@ describe('process instance file lock', () => {
|
|||||||
pid: 6666,
|
pid: 6666,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(lock.acquired).toBe(true);
|
expect(lock.acquired).toBe(false);
|
||||||
expect(readFileSync(lockPath, 'utf8')).toBe('6666');
|
expect(lock.ownerPid).toBeUndefined();
|
||||||
lock.release();
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
15
tests/unit/signal-quit.test.ts
Normal file
15
tests/unit/signal-quit.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user