mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
e5a3d85b0c
* fix(watcher): follow a rebuilt index instead of syncing into the replaced file (#1902) `codegraph index` rebuilds through `CodeGraph.recreate()`, which unlinks `.codegraph/codegraph.db` and creates a new file at the same path. A running MCP daemon kept its handle on the unlinked inode and its watcher kept "auto-syncing" into it, so no edit reached the on-disk index. The #925 self-heal only ran on the tool-call path and ran no catch-up after reopening. - `sync()` now checks, once it holds the index mutex and the write lock, whether the database was replaced on disk (one stat, the same inode check `reopenIfReplaced` uses). If so it reopens the live file in place and widens a scoped sync to a full reconcile. A failed reopen (rebuild mid-way) returns the lock-busy shape so the watcher keeps its pending files and retries. - `reopenIfReplaced()` refuses while an index/sync holds the mutex, so the tool-call path never closes the handle an in-flight sync writes through. - When a tool call's `freshen()` does reopen, the engine runs its existing catch-up sync (serialized on the same mutex), but only for its own watched instance, so an engine that is not the project's writer never writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * 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> * fix(sync): keep the full catch-up until a full sync completes (#1902) Two gaps in following a replaced database: - Only a reopen done by sync() itself set pendingFullReconcile. The tool-call self-heal (reopenIfReplaced) swapped the handle without it, so the next scoped watcher sync skipped whatever the old handle had absorbed. The flag is now set wherever the database is reopened. - The flag was cleared before the sync ran, so a sync that threw lost the catch-up. It is now cleared only after a full sync completes. Ported from the maintainer's hardening of this fix in #1919. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sync): report a rebuild in progress as lock contention, not an empty sync (#1902) Since #2014 the watcher tells lock contention apart from success only by LockUnavailableError; it no longer recognises the all-zero result. Following a rebuilt index still returned that shape when the reopen failed or when the recreated file had not been indexed yet, so the watcher counted the step-aside as a clean sync and dropped the pending edits. Both paths now throw LockUnavailableError. A watcher test proves the edit stays pending through the rebuild gap and is reconciled once the rebuild finishes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: danusha2345 <danusha2345@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
318 lines
14 KiB
TypeScript
318 lines
14 KiB
TypeScript
/**
|
|
* Live sync follows a rebuilt index (issue #1902).
|
|
*
|
|
* `codegraph index` rebuilds through `CodeGraph.recreate()`, which unlinks
|
|
* `.codegraph/codegraph.db` and creates a new file (a new inode) at the same
|
|
* path. A long-lived instance — the MCP daemon — keeps its handle on the old,
|
|
* unlinked inode. Before the fix its watcher kept "auto-syncing" into that dead
|
|
* inode (nothing it wrote was visible to any other process), and the #925
|
|
* self-heal on the tool-call path reopened the live file without a catch-up,
|
|
* so every edit absorbed in between was lost.
|
|
*
|
|
* POSIX-only: an open file can't be unlinked on Windows, and st_ino is
|
|
* unreliable there, so the replaced-inode hazard does not arise.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import CodeGraph, { LockUnavailableError } from '../src/index';
|
|
import type { MCPEngine } from '../src/mcp/engine';
|
|
|
|
const posixOnly = it.runIf(process.platform !== 'win32');
|
|
|
|
/** Rebuild the index in a separate instance, the way `codegraph index` does. */
|
|
async function rebuild(root: string): Promise<void> {
|
|
const cg = await CodeGraph.recreate(root);
|
|
await cg.indexAll();
|
|
cg.close();
|
|
}
|
|
|
|
/** Open the file at the path fresh (as a new CLI process would) and look up a name. */
|
|
async function onDiskHas(root: string, name: string): Promise<boolean> {
|
|
const cg = await CodeGraph.open(root);
|
|
try {
|
|
return cg.searchNodes(name).some((r) => r.node.name === name);
|
|
} finally {
|
|
cg.close();
|
|
}
|
|
}
|
|
|
|
async function waitFor(cond: () => boolean | Promise<boolean>, timeoutMs = 10000): Promise<void> {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
if (await cond()) return;
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
throw new Error('timed out waiting for condition');
|
|
}
|
|
|
|
describe('live sync after the index is rebuilt by another process (#1902)', () => {
|
|
let root: string;
|
|
|
|
beforeEach(() => {
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1902-'));
|
|
fs.mkdirSync(path.join(root, 'src'));
|
|
fs.writeFileSync(path.join(root, 'src', 'a.ts'), 'export function alpha() { return 1; }\n');
|
|
fs.writeFileSync(path.join(root, 'src', 'b.ts'), 'export function bravo() { return 2; }\n');
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
posixOnly('a watcher-picked-up edit lands in the NEW database file, not the unlinked one', async () => {
|
|
const server = CodeGraph.initSync(root);
|
|
await server.indexAll();
|
|
let synced = 0;
|
|
expect(server.watch({ debounceMs: 100, onSyncComplete: () => { synced++; } })).toBe(true);
|
|
try {
|
|
await rebuild(root);
|
|
|
|
fs.appendFileSync(path.join(root, 'src', 'a.ts'), 'export function beta() { return alpha(); }\n');
|
|
await waitFor(() => synced > 0);
|
|
|
|
// A fresh open of the path sees the edit — the watcher wrote into the live file.
|
|
expect(await onDiskHas(root, 'beta')).toBe(true);
|
|
// And the server itself now holds the live file.
|
|
expect(server.searchNodes('beta').some((r) => r.node.name === 'beta')).toBe(true);
|
|
expect(server.reopenIfReplaced()).toBe(false);
|
|
} finally {
|
|
server.unwatch();
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
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, and reports contention instead of success.
|
|
await expect(server.sync({ paths: ['src/a.ts'] })).rejects.toBeInstanceOf(LockUnavailableError);
|
|
// 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 watcher sync that lands in the rebuild gap keeps its pending files until the rebuild is reconciled', async () => {
|
|
const server = CodeGraph.initSync(root);
|
|
await server.indexAll();
|
|
// Record every sync the watcher starts, so the test can wait for the one in the gap.
|
|
const attempts: Array<Promise<unknown>> = [];
|
|
const realSync = server.sync.bind(server);
|
|
(server as any).sync = (options?: Parameters<CodeGraph['sync']>[0]) => {
|
|
const run = realSync(options);
|
|
attempts.push(run);
|
|
return run;
|
|
};
|
|
let synced = 0;
|
|
// The lock-contention backoff (debounce · 2^n, degrading after 5 retries)
|
|
// must outlast the rebuild below; 200 ms leaves several seconds.
|
|
expect(server.watch({ debounceMs: 200, onSyncComplete: () => { synced++; } })).toBe(true);
|
|
try {
|
|
// `codegraph index` has recreated the file but not taken the write lock yet.
|
|
const rebuilder = await CodeGraph.recreate(root);
|
|
fs.appendFileSync(path.join(root, 'src', 'a.ts'), 'export function gamma() { return 3; }\n');
|
|
await waitFor(() => attempts.length > 0);
|
|
await expect(attempts[0]).rejects.toBeInstanceOf(LockUnavailableError);
|
|
// Stepping aside is not a sync: the edit stays pending, nothing is reported as synced.
|
|
expect(synced).toBe(0);
|
|
expect(server.getPendingFiles().map((f) => f.path)).toContain('src/a.ts');
|
|
|
|
const built = await rebuilder.indexAll();
|
|
expect(built.success).toBe(true);
|
|
rebuilder.close();
|
|
|
|
// The watcher's retry reconciles the rebuilt file and only then clears the edit.
|
|
await waitFor(() => synced > 0);
|
|
expect(await onDiskHas(root, 'gamma')).toBe(true);
|
|
expect(server.getPendingFiles()).toEqual([]);
|
|
} finally {
|
|
server.unwatch();
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
posixOnly('a scoped sync that finds the database replaced reconciles the whole tree', async () => {
|
|
const server = CodeGraph.initSync(root);
|
|
await server.indexAll();
|
|
try {
|
|
await rebuild(root);
|
|
|
|
// Two edits after the rebuild, but the sync is told about only one of
|
|
// them — the other stands for everything the dead handle absorbed.
|
|
fs.appendFileSync(path.join(root, 'src', 'a.ts'), 'export function beta() { return 3; }\n');
|
|
fs.appendFileSync(path.join(root, 'src', 'b.ts'), 'export function charlie() { return 4; }\n');
|
|
await server.sync({ paths: ['src/a.ts'] });
|
|
|
|
expect(await onDiskHas(root, 'beta')).toBe(true);
|
|
expect(await onDiskHas(root, 'charlie')).toBe(true);
|
|
|
|
// Once the handle is live again, a scoped sync stays scoped.
|
|
fs.appendFileSync(path.join(root, 'src', 'b.ts'), 'export function delta() { return 5; }\n');
|
|
await server.sync({ paths: ['src/a.ts'] });
|
|
expect(await onDiskHas(root, 'delta')).toBe(false);
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
posixOnly('reopenIfReplaced does not swap the connection under an in-flight sync', async () => {
|
|
const server = CodeGraph.initSync(root);
|
|
await server.indexAll();
|
|
try {
|
|
await rebuild(root);
|
|
const inFlight = server.sync();
|
|
expect(server.isIndexing()).toBe(true);
|
|
expect(server.reopenIfReplaced()).toBe(false);
|
|
await inFlight;
|
|
// The sync itself followed the path.
|
|
expect(server.reopenIfReplaced()).toBe(false);
|
|
fs.appendFileSync(path.join(root, 'src', 'a.ts'), 'export function echo() { return 6; }\n');
|
|
await server.sync({ paths: ['src/a.ts'] });
|
|
expect(await onDiskHas(root, 'echo')).toBe(true);
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
// The engine lazily `require`s the CodeGraph module, which only resolves in
|
|
// the built output, so this block drives the built engine (as the
|
|
// spawned-CLI suites do); `npm run build` first.
|
|
describe('tool-call reopen through the MCP engine', () => {
|
|
const ENGINE = path.resolve(__dirname, '../dist/mcp/engine.js');
|
|
let prevDebounce: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
// Keep the watcher from syncing the edit on its own, so only the
|
|
// tool-call path's catch-up can put it in the rebuilt file.
|
|
prevDebounce = process.env.CODEGRAPH_WATCH_DEBOUNCE_MS;
|
|
process.env.CODEGRAPH_WATCH_DEBOUNCE_MS = '60000';
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (prevDebounce === undefined) delete process.env.CODEGRAPH_WATCH_DEBOUNCE_MS;
|
|
else process.env.CODEGRAPH_WATCH_DEBOUNCE_MS = prevDebounce;
|
|
});
|
|
|
|
async function openEngine(watch: boolean): Promise<MCPEngine> {
|
|
const seed = CodeGraph.initSync(root);
|
|
await seed.indexAll();
|
|
seed.close();
|
|
if (!fs.existsSync(ENGINE)) throw new Error(`${ENGINE} missing — run \`npm run build\` first`);
|
|
const { MCPEngine: Engine } = require(ENGINE) as { MCPEngine: typeof MCPEngine };
|
|
const engine = new Engine({ watch, writerLockRoot: watch ? root : undefined });
|
|
await engine.ensureInitialized(root);
|
|
// Drain the post-open catch-up gate (and prove the project is loaded).
|
|
const first = await engine.getToolHandler().execute('codegraph_search', { query: 'alpha' });
|
|
expect(first.content[0].text).toMatch(/alpha/);
|
|
return engine;
|
|
}
|
|
|
|
posixOnly('reopening a replaced database runs a catch-up sync into the new file', async () => {
|
|
const engine = await openEngine(true);
|
|
try {
|
|
await rebuild(root);
|
|
fs.appendFileSync(path.join(root, 'src', 'b.ts'), 'export function foxtrot() { return 7; }\n');
|
|
expect(await onDiskHas(root, 'foxtrot')).toBe(false);
|
|
|
|
const handler = engine.getToolHandler();
|
|
// This call reopens the replaced database and starts the catch-up;
|
|
// the next call awaits its gate.
|
|
await handler.execute('codegraph_search', { query: 'alpha' });
|
|
const res = await handler.execute('codegraph_search', { query: 'foxtrot' });
|
|
expect(res.isError).toBeFalsy();
|
|
expect(res.content[0].text).toMatch(/foxtrot/);
|
|
expect(await onDiskHas(root, 'foxtrot')).toBe(true);
|
|
} finally {
|
|
engine.stop();
|
|
}
|
|
});
|
|
|
|
posixOnly('an engine that is not watching reopens but does not write', async () => {
|
|
const engine = await openEngine(false);
|
|
try {
|
|
await rebuild(root);
|
|
fs.appendFileSync(path.join(root, 'src', 'b.ts'), 'export function golf() { return 8; }\n');
|
|
|
|
const handler = engine.getToolHandler();
|
|
await handler.execute('codegraph_search', { query: 'alpha' });
|
|
await handler.execute('codegraph_search', { query: 'golf' });
|
|
expect(await onDiskHas(root, 'golf')).toBe(false);
|
|
} finally {
|
|
engine.stop();
|
|
}
|
|
});
|
|
});
|
|
|
|
posixOnly('a reopen by a tool call widens the next scoped sync to the whole tree', async () => {
|
|
const server = CodeGraph.initSync(root);
|
|
await server.indexAll();
|
|
try {
|
|
await rebuild(root);
|
|
// The self-heal on the tool-call path, not a sync, follows the path.
|
|
expect(server.reopenIfReplaced()).toBe(true);
|
|
const live = (server as any).orchestrator;
|
|
const calls: Array<string[] | undefined> = [];
|
|
const sync = live.sync.bind(live);
|
|
live.sync = (onProgress: unknown, paths: string[] | undefined, ...rest: unknown[]) => {
|
|
calls.push(paths);
|
|
return sync(onProgress, paths, ...rest);
|
|
};
|
|
await server.sync({ paths: ['src/a.ts'] });
|
|
await server.sync({ paths: ['src/a.ts'] });
|
|
// The first sync after the reopen reconciled everything; the next stays scoped.
|
|
expect(calls).toEqual([undefined, ['src/a.ts']]);
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
posixOnly('a catch-up sync that fails keeps the full reconcile for the next one', async () => {
|
|
const server = CodeGraph.initSync(root);
|
|
await server.indexAll();
|
|
try {
|
|
await rebuild(root);
|
|
expect(server.reopenIfReplaced()).toBe(true);
|
|
const live = (server as any).orchestrator;
|
|
const calls: Array<string[] | undefined> = [];
|
|
const sync = live.sync.bind(live);
|
|
let failNext = true;
|
|
live.sync = (onProgress: unknown, paths: string[] | undefined, ...rest: unknown[]) => {
|
|
calls.push(paths);
|
|
if (failNext) {
|
|
failNext = false;
|
|
return Promise.reject(new Error('injected sync failure'));
|
|
}
|
|
return sync(onProgress, paths, ...rest);
|
|
};
|
|
await expect(server.sync({ paths: ['src/a.ts'] })).rejects.toThrow('injected sync failure');
|
|
await server.sync({ paths: ['src/a.ts'] });
|
|
expect(calls).toEqual([undefined, undefined]);
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
});
|