From 9f3c2b885a0dd8e9c3cc9a2d03fd38d4d9910945 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Sat, 26 Sep 2026 10:36:19 +0300 Subject: [PATCH] fix(lock): keep a live writer lock regardless of file age (#1959) --- CHANGELOG.md | 1 + __tests__/security.test.ts | 17 +++++++++++++++++ src/utils.ts | 13 ++++--------- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75535a0bbd..6617953280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,6 +162,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### MCP / indexing +- A long index run no longer loses its write lock after two minutes: while the process holding it is alive, another process waits instead of writing the same rows at once. (#1959) - Daemon startup and cleanup now preserve live legacy PID-only locks while still reclaiming dead or identity-disproved records, preventing two writers from serving the same project. - Incremental sync now keeps edge rebinding crash-safe: replacing a resolved edge with its recovery reference commits atomically, so an interruption cannot permanently remove the relationship. - Status now detects committed but unindexed changes and restored edits without scanning every source file; thanks @inth3shadows. (#1829) diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts index 9aa3c9597c..30e845c182 100644 --- a/__tests__/security.test.ts +++ b/__tests__/security.test.ts @@ -66,6 +66,23 @@ describe('FileLock', () => { lock1.release(); }); + it('keeps a live holder protected after its lock file is older than two minutes (#1959)', () => { + const holder = new FileLock(lockPath); + const contender = new FileLock(lockPath); + holder.acquire(); + const old = new Date(Date.now() - 3 * 60_000); + fs.utimesSync(lockPath, old, old); + + try { + expect(() => contender.acquire()).toThrow(/locked by another process/); + expect(fs.readFileSync(lockPath, 'utf8')).toBe(String(process.pid)); + } finally { + holder.release(); + contender.release(); + } + expect(fs.existsSync(lockPath)).toBe(false); + }); + it('should detect and remove stale locks from dead processes', () => { // Write a lock file with a PID that doesn't exist // PID 99999999 is extremely unlikely to be a real process diff --git a/src/utils.ts b/src/utils.ts index dc860a3f49..cdc46e831f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -241,9 +241,6 @@ export class FileLock { private lockPath: string; private held = false; - /** Locks older than this are considered stale regardless of PID status */ - private static readonly STALE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes - constructor(lockPath: string) { this.lockPath = lockPath; } @@ -257,18 +254,16 @@ export class FileLock { try { const content = fs.readFileSync(this.lockPath, 'utf-8').trim(); const pid = parseInt(content, 10); - const stat = fs.statSync(this.lockPath); - const lockAge = Date.now() - stat.mtimeMs; - - // Treat locks older than the timeout as stale, regardless of PID - if (lockAge < FileLock.STALE_TIMEOUT_MS && !isNaN(pid) && this.isProcessAlive(pid)) { + // A long sync can hold the lock for hours. Its age says nothing about + // whether the writer is still alive; only a dead PID is stale. + if (!isNaN(pid) && this.isProcessAlive(pid)) { throw new Error( `CodeGraph database is locked by another process (PID ${pid}). ` + `If this is stale, run 'codegraph unlock' or delete ${this.lockPath}` ); } - // Stale lock (dead process or timed out) - remove it + // Stale lock (dead process or malformed PID) - remove it fs.unlinkSync(this.lockPath); } catch (err) { if (err instanceof Error && err.message.includes('locked by another')) {