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; 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 }, }; }