diff --git a/browse/src/auto-cookie-persist.ts b/browse/src/auto-cookie-persist.ts index 6c7bfd290..fe3d0056c 100644 --- a/browse/src/auto-cookie-persist.ts +++ b/browse/src/auto-cookie-persist.ts @@ -215,12 +215,39 @@ export function acquireLock(config: BrowseConfig): boolean { return true; } +function readProcessStartTimeMs(pid: number): number | null { + if (process.platform === 'win32') return null; + try { + const result = Bun.spawnSync(['ps', '-p', String(pid), '-o', 'lstart='], { + stdout: 'pipe', stderr: 'pipe', timeout: 2000, + }); + if (result.exitCode !== 0) return null; + const parsed = Date.parse(result.stdout.toString().trim()); + return Number.isFinite(parsed) ? parsed : null; + } catch { + return null; + } +} + +function isRecordedOwnerStillAlive(meta: LockMeta): boolean { + if (typeof meta.pid !== 'number' || meta.pid <= 0) return false; + if (meta.pid === process.pid) return true; + if (!isProcessAlive(meta.pid)) return false; + + const recordedMs = Date.parse(meta.startedAt); + const liveStartMs = readProcessStartTimeMs(meta.pid); + if (Number.isFinite(recordedMs) && liveStartMs !== null && liveStartMs > recordedMs) { + return false; + } + return true; +} + /** Reclaim a lock dir iff its recorded owner pid is no longer alive. */ function reclaimIfStale(dir: string): boolean { try { const raw = fs.readFileSync(path.join(dir, 'owner.json'), 'utf-8'); const meta = JSON.parse(raw) as LockMeta; - if (typeof meta.pid === 'number' && meta.pid > 0 && isProcessAlive(meta.pid)) { + if (isRecordedOwnerStillAlive(meta)) { return false; // live owner — do not steal } } catch { diff --git a/browse/test/auto-cookie-persist.test.ts b/browse/test/auto-cookie-persist.test.ts index a18af4684..39e890e86 100644 --- a/browse/test/auto-cookie-persist.test.ts +++ b/browse/test/auto-cookie-persist.test.ts @@ -181,6 +181,24 @@ describe('workspace lock', () => { expect(acquireLock(cfg)).toBe(true); releaseLock(cfg); }); + + test('reclaims a lock whose pid was reused by a newer process', () => { + if (process.platform === 'win32') return; + const cfg = tmpConfig(); + const lockDir = path.join(cfg.stateDir, 'browse-auto-cookies', `${computeProfileId(cfg)}.lock`); + fs.mkdirSync(lockDir, { recursive: true }); + const proc = Bun.spawn(['sleep', '5'], { stdout: 'ignore', stderr: 'ignore' }); + try { + fs.writeFileSync(path.join(lockDir, 'owner.json'), JSON.stringify({ + pid: proc.pid, + startedAt: '1970-01-01T00:00:00.000Z', + })); + expect(acquireLock(cfg)).toBe(true); + releaseLock(cfg); + } finally { + proc.kill(); + } + }); }); describe('save → load round-trip (real Playwright)', () => {