Files
DeskClaw/electron/main/process-instance-lock.ts

207 lines
5.6 KiB
TypeScript

import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const LOCK_SCHEMA = 'deskclaw-instance-lock';
const LOCK_VERSION = 1;
export interface ProcessInstanceFileLock {
acquired: boolean;
lockPath: string;
ownerPid?: number;
ownerFormat?: 'legacy' | 'structured' | 'unknown';
release: () => void;
}
export interface ProcessInstanceFileLockOptions {
userDataDir: string;
lockName: string;
pid?: number;
isPidAlive?: (pid: number) => boolean;
/**
* When true, unconditionally remove any existing lock file before attempting
* to acquire. Use this when an external mechanism (e.g. Electron's
* `requestSingleInstanceLock`) already guarantees that no other real instance
* is running, so a surviving lock file can only be stale (orphan child
* process, PID recycling on Windows, etc.).
*/
force?: 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';
}
}
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(
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`);
// When force mode is enabled, unconditionally remove any existing lock file
// before attempting acquisition. This is safe because an external mechanism
// (Electron's requestSingleInstanceLock) already guarantees exclusivity.
if (options.force && existsSync(lockPath)) {
const staleOwner = readLockOwner(lockPath);
try {
rmSync(lockPath, { force: true });
} catch {
// best-effort; fall through to normal acquisition
}
if (staleOwner.kind !== 'unknown') {
console.info(
`[ClawX] Force-cleaned stale instance lock (pid=${staleOwner.pid}, format=${staleOwner.kind})`,
);
}
}
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);
}
let released = false;
return {
acquired: true,
lockPath,
release: () => {
if (released) return;
released = true;
try {
const currentOwner = readLockOwner(lockPath);
if (
(currentOwner.kind === 'legacy' || currentOwner.kind === 'structured')
&& currentOwner.pid !== pid
) {
return;
}
if (currentOwner.kind === 'unknown') {
return;
}
rmSync(lockPath, { force: true });
} catch {
// best-effort
}
},
};
} catch (error) {
const errno = (error as NodeJS.ErrnoException).code;
if (errno !== 'EEXIST') {
break;
}
const owner = readLockOwner(lockPath);
if (owner.kind === 'legacy' || owner.kind === 'structured') {
ownerPid = owner.pid;
ownerFormat = owner.kind;
} else {
ownerPid = undefined;
ownerFormat = 'unknown';
}
const shouldTreatAsStale =
(owner.kind === 'legacy' || owner.kind === 'structured')
&& !isPidAlive(owner.pid);
if (shouldTreatAsStale && existsSync(lockPath)) {
try {
rmSync(lockPath, { force: true });
continue;
} catch {
// If deletion fails, treat as held lock.
}
}
break;
}
}
return {
acquired: false,
lockPath,
ownerPid,
ownerFormat,
release: () => {
// no-op when lock wasn't acquired
},
};
}