Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions __tests__/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 4 additions & 9 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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')) {
Expand Down