mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
1926ec39a2
* fix: safely stop daemon before reindex * fix(index): coordinate rebuilds with running MCP daemons (#1325) Stop verified project daemons and confirm termination before recreating SQLite. Fence daemon and proxy reopening throughout rebuilds while preserving foreign and newer locks. Add Windows lifecycle coverage and cross-platform shutdown regressions. Co-authored-by: Harry B ✦ <drewavesx0@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(mcp): fence project opens during index rebuilds after rebase Carry the #1325 rebuild fence through the upstream explicit-project lifecycle, read-only engine opens, synchronous initialization retries, and direct writer acquisition. Preserve shared project leases, read-only fallback behavior, freshness handling, and direct-mode query pools. Cover watched, unwatched, and read-only engines blocking during a foreign rebuild and recovering after the fence is released. * fix(mcp): answer a running index rebuild as guidance, not a tool error (#1325) A projectPath call during `codegraph index` hit assertNoRebuild's plain Error and returned isError; it is an expected, temporary condition, so it now uses a RebuildInProgressError that the tool handler answers success-shaped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Harry B ✦ <drewavesx0@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
5.6 KiB
TypeScript
122 lines
5.6 KiB
TypeScript
import { afterEach, beforeEach, expect, it } from 'vitest';
|
|
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { CodeGraph } from '../src';
|
|
import { stopDaemonAt, isProcessAlive } from '../src/mcp/daemon-registry';
|
|
import { readWriterLock, releaseWriterLock, tryAcquireWriterLock } from '../src/mcp/writer-lock';
|
|
|
|
const bin = path.resolve(__dirname, '../dist/bin/codegraph.js');
|
|
let root: string;
|
|
let children: ChildProcessWithoutNullStreams[];
|
|
const env = { ...process.env, CODEGRAPH_TELEMETRY: '0', DO_NOT_TRACK: '1',
|
|
CODEGRAPH_NO_PROMPT_HOOK: '1', CODEGRAPH_NO_DAEMON: '', CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '60000' };
|
|
|
|
async function until<T>(fn: () => T, timeout = 20000): Promise<NonNullable<T>> {
|
|
const end = Date.now() + timeout;
|
|
while (Date.now() < end) {
|
|
const value = fn();
|
|
if (value) return value as NonNullable<T>;
|
|
await new Promise(r => setTimeout(r, 25));
|
|
}
|
|
throw new Error('Timed out waiting for process/response');
|
|
}
|
|
function start(args: string[]) {
|
|
const child = spawn(process.execPath, ['--liftoff-only', bin, ...args], { cwd: root, env });
|
|
children.push(child);
|
|
let out = '', err = '';
|
|
child.stdout.on('data', b => out += b);
|
|
child.stderr.on('data', b => err += b);
|
|
child.stdin.on('error', () => {});
|
|
return { child, output: () => out, errors: () => err };
|
|
}
|
|
type Server = ReturnType<typeof start>;
|
|
function send(server: Server, id: number, method: string, params: unknown = {}) {
|
|
server.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
}
|
|
async function response(server: Server, id: number) {
|
|
return until(() => server.output().split('\n').map(l => {
|
|
try { return JSON.parse(l); } catch { return null; }
|
|
}).find(m => m?.id === id));
|
|
}
|
|
async function client() {
|
|
const server = start(['serve', '--mcp', '--path', root]);
|
|
send(server, 1, 'initialize', { protocolVersion: '2024-11-05', capabilities: {},
|
|
clientInfo: { name: 'rebuild-test', version: '1' } });
|
|
await response(server, 1);
|
|
send(server, 2, 'tools/call', { name: 'codegraph_explore', arguments: { query: 'originalSymbol' } });
|
|
expect((await response(server, 2)).result.isError).not.toBe(true);
|
|
return server;
|
|
}
|
|
async function rebuild() {
|
|
const cmd = start(['index', '--quiet']);
|
|
await until(() => cmd.child.exitCode !== null || cmd.child.signalCode !== null, 60000);
|
|
expect(cmd.errors()).not.toContain('Failed to index');
|
|
expect(cmd.child.exitCode).toBe(0);
|
|
}
|
|
beforeEach(async () => {
|
|
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rebuild-')));
|
|
children = [];
|
|
fs.writeFileSync(path.join(root, 'app.ts'), 'export function originalSymbol() {}\n');
|
|
const cg = await CodeGraph.init(root);
|
|
await cg.indexAll();
|
|
cg.close();
|
|
});
|
|
afterEach(async () => {
|
|
releaseWriterLock(root, 'rebuild.pid');
|
|
for (const child of children) {
|
|
child.stdin.end();
|
|
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
}
|
|
await until(() => children.every(c => c.exitCode !== null || c.signalCode !== null));
|
|
await stopDaemonAt(root);
|
|
fs.rmSync(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
|
|
});
|
|
|
|
it.runIf(process.platform === 'win32')('rebuilds after the real MCP client closes with SQLite held by its daemon (#1325)', async () => {
|
|
const server = await client();
|
|
const pid = JSON.parse(fs.readFileSync(path.join(root, '.codegraph/daemon.pid'), 'utf8')).pid;
|
|
server.child.stdin.end();
|
|
await until(() => server.child.exitCode !== null);
|
|
expect(isProcessAlive(pid)).toBe(true);
|
|
await rebuild();
|
|
expect(isProcessAlive(pid)).toBe(false);
|
|
const cg = CodeGraph.openSync(root);
|
|
try { expect(cg.searchNodes('originalSymbol').length).toBeGreaterThan(0); }
|
|
finally { cg.close(); }
|
|
}, 90000);
|
|
|
|
it('keeps an active client from reopening SQLite during rebuild and serves the new graph afterward', async () => {
|
|
const server = await client();
|
|
// Hold the exact CLI rebuild fence to deterministically exercise a request
|
|
// arriving between daemon termination and database recreation.
|
|
expect(tryAcquireWriterLock(root, 'rebuild', 'rebuild.pid').kind).toBe('acquired');
|
|
expect((await stopDaemonAt(root)).outcome).toMatch(/term|kill/);
|
|
await until(() => server.errors().includes('connection lost'));
|
|
send(server, 3, 'tools/call', { name: 'codegraph_explore', arguments: { query: 'originalSymbol' } });
|
|
expect((await response(server, 3)).error.message).toContain('rebuild is in progress');
|
|
expect(readWriterLock(root)?.mode).not.toBe('fallback');
|
|
releaseWriterLock(root, 'rebuild.pid');
|
|
fs.writeFileSync(path.join(root, 'app.ts'), 'export function replacementSymbol() {}\n');
|
|
await rebuild();
|
|
send(server, 4, 'tools/call', { name: 'codegraph_explore', arguments: { query: 'replacementSymbol' } });
|
|
const reply = await response(server, 4);
|
|
expect(reply.error).toBeUndefined();
|
|
expect(reply.result.isError).not.toBe(true);
|
|
expect(JSON.stringify(reply.result)).toContain('replacementSymbol');
|
|
}, 90000);
|
|
|
|
it('preserves a foreign writer and the existing database', async () => {
|
|
expect(tryAcquireWriterLock(root, 'direct').kind).toBe('acquired');
|
|
const before = fs.readFileSync(path.join(root, '.codegraph/codegraph.db'));
|
|
try {
|
|
const cmd = start(['index', '--quiet']);
|
|
await until(() => cmd.child.exitCode !== null);
|
|
expect(cmd.child.exitCode).toBe(1);
|
|
expect(cmd.errors()).toContain('writer lock held');
|
|
expect(readWriterLock(root)?.pid).toBe(process.pid);
|
|
expect(fs.readFileSync(path.join(root, '.codegraph/codegraph.db'))).toEqual(before);
|
|
} finally { releaseWriterLock(root); }
|
|
});
|