fix(browse): distinguish pid reuse when reclaiming a stale cookie lock

reclaimIfStale treated any live pid matching the recorded owner as proof
the lock is still held. After a crash the OS can recycle that pid for an
unrelated process, so a liveness check alone would refuse to reclaim a
lock whose real owner is long gone, permanently disabling auto-persistence
for the workspace. Cross-check the owner's recorded lock-acquire time
against the live process start time via `ps -o lstart=`: if the live
process started after the lock was taken, the pid was reused and the lock
is stale. Fallback is conservative — if start-time can't be read (ps
fails, timeout, non-Unix, unparseable), we keep the lock rather than
risk stealing an active peer's slot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
현성 2026-07-07 21:00:38 +09:00
parent c0d85f7c0e
commit fd976e9b66
2 changed files with 46 additions and 1 deletions

View File

@ -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 {

View File

@ -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)', () => {