diff --git a/electron/main/index.ts b/electron/main/index.ts index d1ffb35dc..b90cfdb2d 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -87,6 +87,7 @@ if (gotElectronLock) { const fileLock = acquireProcessInstanceFileLock({ userDataDir: app.getPath('userData'), lockName: 'clawx', + force: true, // Electron lock already guarantees exclusivity; force-clean orphan/recycled-PID locks }); gotFileLock = fileLock.acquired; releaseProcessInstanceFileLock = fileLock.release; diff --git a/electron/main/process-instance-lock.ts b/electron/main/process-instance-lock.ts index a4d7b2417..2929424c5 100644 --- a/electron/main/process-instance-lock.ts +++ b/electron/main/process-instance-lock.ts @@ -17,6 +17,14 @@ export interface ProcessInstanceFileLockOptions { 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 { @@ -101,6 +109,23 @@ export function acquireProcessInstanceFileLock( 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'; diff --git a/tests/unit/process-instance-lock.test.ts b/tests/unit/process-instance-lock.test.ts index 3fa5cca99..40edf91a7 100644 --- a/tests/unit/process-instance-lock.test.ts +++ b/tests/unit/process-instance-lock.test.ts @@ -154,4 +154,40 @@ describe('process instance file lock', () => { expect(lock.ownerFormat).toBe('unknown'); expect(readFileSync(lockPath, 'utf8')).toContain('future-lock-schema'); }); + + it('force: true acquires lock even when existing owner pid is alive', () => { + const userDataDir = createTempDir(); + const lockPath = join(userDataDir, 'clawx.instance.lock'); + // Simulate a lock held by a live process (e.g. orphan Python process after update) + writeFileSync(lockPath, '14736', 'utf8'); + + const lock = acquireProcessInstanceFileLock({ + userDataDir, + lockName: 'clawx', + pid: 5555, + isPidAlive: () => true, // owner appears alive (PID recycled on Windows) + force: true, + }); + + expect(lock.acquired).toBe(true); + expect(readFileSync(lockPath, 'utf8')).toBe('5555'); + lock.release(); + }); + + it('force: true acquires lock when lock file has malformed content', () => { + const userDataDir = createTempDir(); + const lockPath = join(userDataDir, 'clawx.instance.lock'); + writeFileSync(lockPath, 'garbage-content', 'utf8'); + + const lock = acquireProcessInstanceFileLock({ + userDataDir, + lockName: 'clawx', + pid: 7777, + force: true, + }); + + expect(lock.acquired).toBe(true); + expect(readFileSync(lockPath, 'utf8')).toBe('7777'); + lock.release(); + }); });