fix(sync): step aside for a rebuild that has recreated the file but not locked it yet

`codegraph index` recreates the database, then takes the write lock inside
indexAll. With the watcher now following a replaced file, a sync landing in
that gap reopened the empty file and started a full reconcile, holding the
lock the rebuild was about to request — `codegraph index` could then fail
with "another process may be indexing". Review flagged the gap; this closes
it. After following a replaced file, a sync that finds no index_state yet
(the rebuild has not started indexing) and a file written within the last
two minutes returns the lock-busy shape so the watcher retries; the first
sync after that reconciles the whole tree once, then syncs go back to scoped.
The time bound keeps a rebuild that died before indexing from parking the
watcher forever.

New test: fails without the guard, passes with it; 136 related tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
danusha2345
2026-09-23 14:43:35 +00:00
committed by Forge
co-authored by Claude Opus 5.5
parent d575529171
commit 5570e5d0b7
2 changed files with 67 additions and 1 deletions
+32
View File
@@ -83,6 +83,38 @@ describe('live sync after the index is rebuilt by another process (#1902)', () =
}
});
posixOnly('a sync in the gap between recreate and indexAll steps aside, then reconciles in full', async () => {
const server = CodeGraph.initSync(root);
await server.indexAll();
try {
// `codegraph index`: the file is recreated first, the write lock is taken later by indexAll.
const rebuilder = await CodeGraph.recreate(root);
fs.appendFileSync(path.join(root, 'src', 'a.ts'), 'export function gamma() { return 3; }\n');
// The server's sync lands in the gap: it must not claim the lock for a full reconcile of the empty file.
const gap = await server.sync({ paths: ['src/a.ts'] });
expect(gap.filesChecked).toBe(0);
// So the rebuild still gets its lock and completes.
const built = await rebuilder.indexAll();
expect(built.success).toBe(true);
rebuilder.close();
// An edit the rebuild did not see, made after it finished.
fs.appendFileSync(path.join(root, 'src', 'b.ts'), 'export function delta() { return 4; }\n');
// The next (scoped, unrelated) sync is widened to the whole tree once.
fs.writeFileSync(path.join(root, 'src', 'c.ts'), 'export function echo() { return 5; }\n');
const after = await server.sync({ paths: ['src/c.ts'] });
expect(after.filesChecked).toBeGreaterThan(1);
expect(await onDiskHas(root, 'gamma')).toBe(true);
expect(await onDiskHas(root, 'delta')).toBe(true);
expect(await onDiskHas(root, 'echo')).toBe(true);
// And only once: the following scoped sync stays scoped.
fs.appendFileSync(path.join(root, 'src', 'c.ts'), 'export function foxtrot() { return 6; }\n');
const scoped = await server.sync({ paths: ['src/c.ts'] });
expect(scoped.filesChecked).toBe(1);
} finally {
server.close();
}
});
posixOnly('a scoped sync that finds the database replaced reconciles the whole tree', async () => {
const server = CodeGraph.initSync(root);
await server.indexAll();
+35 -1
View File
@@ -265,6 +265,25 @@ export class CodeGraph {
}
}
/**
* Set when this instance followed a database replaced on disk (#1902): the
* next sync that can run reconciles the whole tree, because whatever the old
* handle absorbed since the rebuild never reached the new file.
*/
private pendingFullReconcile = false;
/** How long a recreated, not-yet-indexed database is treated as a rebuild in progress. */
private static readonly RECREATE_GRACE_MS = 120_000;
/** The database file at the path was written within the recreate grace window. */
private isFreshlyRecreated(): boolean {
try {
return Date.now() - fs.statSync(getDatabasePath(this.projectRoot)).mtimeMs < CodeGraph.RECREATE_GRACE_MS;
} catch {
return false;
}
}
/**
* Heal a stale database handle in place. If `.codegraph/` was removed and
* recreated at the SAME path while this instance held the DB open — a git
@@ -1091,11 +1110,26 @@ export class CodeGraph {
// If the reopen fails (the rebuild is mid-way), report the lock-busy
// shape so the watcher keeps its pending files and retries.
try {
if (this.reopenReplacedDatabase()) options = { ...options, paths: undefined };
if (this.reopenReplacedDatabase()) this.pendingFullReconcile = true;
} catch {
this.fileLock.release();
return { filesChecked: 0, filesAdded: 0, filesModified: 0, filesRemoved: 0, nodesUpdated: 0, durationMs: 0 };
}
if (this.pendingFullReconcile) {
// `codegraph index` recreates the file, THEN takes the write lock in
// indexAll. A sync landing in that gap would otherwise run a full
// reconcile of the empty file and hold the lock the rebuild is about
// to ask for. A fresh file with no index_state yet is that rebuild:
// step aside (lock-busy shape, the watcher retries) and reconcile in
// full once it is done. Bounded, so a rebuild that died before
// indexing does not park the watcher forever.
if (this.getIndexState() === null && this.isFreshlyRecreated()) {
this.fileLock.release();
return { filesChecked: 0, filesAdded: 0, filesModified: 0, filesRemoved: 0, nodesUpdated: 0, durationMs: 0 };
}
this.pendingFullReconcile = false;
options = { ...options, paths: undefined };
}
// Defer WAL auto-checkpointing for the whole incremental run, exactly
// as indexAll does for the bulk path (#1231): sync's store loop and its
// resolution passes churn the same FTS + secondary-index hot pages, and