fix: harden instance lock compatibility semantics

Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-03-19 03:59:52 +00:00
parent 8ab8305553
commit 373a72bdf2
8 changed files with 168 additions and 20 deletions

View File

@@ -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<void> {
}
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();

View File

@@ -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<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(
@@ -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
},

View 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();
};
}