mirror of https://github.com/garrytan/gstack.git
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:
parent
c0d85f7c0e
commit
fd976e9b66
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue