mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-06 09:03:47 +08:00
* test: make Windows load-sensitive tests wait on the tree, not the clock (#1773) Each failure from the loaded Windows run was traced to its mechanism and reproduced deterministically before changing it. No assertion is weakened. - daemon-lock-refresh: the PowerShell holder slept 200ms against a 375ms retry budget. Windows refuses a replace-rename while any handle to the target is open, even a Node read handle, so hold one in-process and close it inside the rename mock after the first real failure. Also asserts the first rename really failed with a retryable code. - mcp-projectpath-lifecycle: the owner daemon armed a 500ms idle timer at start and exited (code 0) before a starved engine reached it. It now starts with idle exit off and is armed over IPC once the engines hold a session. The file waits for catch-up instead of the gate's 3s serve-anyway deadline, and its index-heavy hooks and eviction case get explicit timeouts. - mcp-daemon / mcp-writer-lock teardown: racing launchers spawn detached daemon candidates whose cwd is the fixture; a loser still starting at teardown pinned it (EBUSY) or took over after the winner was stopped. A --require preload records candidate pids, and teardown waits for the losers before stopping the winner. - mcp-writer-lock: writer.pid appears before the daemon binds and rewrites daemon.pid, so cleanup could probe a daemon mid-start ('unverified'); wait for both proxies to attach. Removal uses fs.promises.rm, because fs.rmSync gives up on the first Windows access-denied error despite maxRetries. - mcp-daemon #1963: connect only after the proxy attached (lock before bind). - sync-rebuild-convergence / multi-repo-workspace: explicit timeouts scoped to the cases that build real indexes; the global timeout is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(daemon): give the Windows pid-file replace a longer retry budget (#1773) Windows refuses to replace a file while any handle to it is open, including a plain Node read handle, so an antivirus scan, an indexer, or another session reading daemon.pid can each block the daemon's startup refresh for a moment, and for longer on a loaded machine. The retry waited 375ms in all; it now backs off 25, 50, 100, 200, then 400ms three times, about 1.6s over eight attempts, still well inside a launcher's ~6s connect window. Permanent failures and non-Windows errors still abort at once, and an ownership change still stops the retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
506 lines
24 KiB
TypeScript
506 lines
24 KiB
TypeScript
/**
|
|
* Explicit-`projectPath` project lifecycle (#1835).
|
|
*
|
|
* A server whose root has no index of its own (a workspace whose indexed
|
|
* children are gitignored) serves each child through `projectPath`. Before
|
|
* this fix those projects were opened read-only: no catch-up sync on open and
|
|
* no file watcher, so their answers went stale until someone ran
|
|
* `codegraph sync` by hand. Now the engine gives an explicit project the same
|
|
* lifecycle the default project gets — a catch-up sync the first call waits
|
|
* for, a watcher while it stays cached — bounded (LRU) and released on stop().
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import { spawn, ChildProcess } from 'child_process';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import CodeGraph from '../src/index';
|
|
import { MCPEngine } from '../src/mcp/engine';
|
|
import { MAX_CACHED_PROJECTS, __setLoadCodeGraphForTests } from '../src/mcp/tools';
|
|
|
|
// Default and read-only opens use the engine's lazy CommonJS loader.
|
|
const { MCPEngine: BuiltMCPEngine } = require('../dist/mcp/engine') as typeof import('../src/mcp/engine');
|
|
|
|
const opened: CodeGraph[] = [];
|
|
let onOpen: ((cg: CodeGraph) => void) | undefined;
|
|
/** CodeGraph that records every instance the ToolHandler opens. */
|
|
class RecordingCodeGraph extends CodeGraph {
|
|
static openSync(projectRoot: string): CodeGraph {
|
|
const cg = CodeGraph.openSync(projectRoot);
|
|
opened.push(cg);
|
|
onOpen?.(cg);
|
|
return cg;
|
|
}
|
|
}
|
|
|
|
async function makeProject(dir: string, symbol: string): Promise<void> {
|
|
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'src', 'sample.ts'), `export function ${symbol}() { return 1; }\n`);
|
|
const cg = await CodeGraph.init(dir, { config: { include: ['**/*.ts'], exclude: [] } });
|
|
await cg.indexAll();
|
|
cg.close();
|
|
}
|
|
|
|
async function waitFor(check: () => Promise<boolean>, timeoutMs: number): Promise<boolean> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (await check()) return true;
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
return check();
|
|
}
|
|
|
|
// Every case drives real catch-up syncs and waits for them (see the gate note
|
|
// below), so each gets room for a loaded Windows VM; cases that do more set
|
|
// their own (#1773). These bounds only catch a hang.
|
|
describe('MCP explicit projectPath lifecycle (#1835)', { timeout: 30_000 }, () => {
|
|
let workspace: string;
|
|
let serviceA: string;
|
|
let serviceB: string;
|
|
let engine: MCPEngine;
|
|
const engines: MCPEngine[] = [];
|
|
const children: ChildProcess[] = [];
|
|
const prevDebounce = process.env.CODEGRAPH_WATCH_DEBOUNCE_MS;
|
|
const prevGate = process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
|
|
|
|
// Both hooks build indexes or wait out a sync; a loaded Windows VM exceeds
|
|
// the default 10s hook timeout, and has taken over 30s (#1773).
|
|
beforeEach(async () => {
|
|
workspace = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1835-')));
|
|
serviceA = path.join(workspace, 'service-a');
|
|
serviceB = path.join(workspace, 'service-b');
|
|
await makeProject(serviceA, 'alphaOriginal');
|
|
await makeProject(serviceB, 'betaOriginal');
|
|
process.env.CODEGRAPH_WATCH_DEBOUNCE_MS = '100';
|
|
// These cases assert what a call left behind, so wait for its catch-up
|
|
// rather than the gate's 3s serve-anyway deadline, which a loaded machine
|
|
// misses (#1773). That deadline has its own coverage in
|
|
// mcp-catchup-gate.test.ts; the eviction case sets its own values.
|
|
process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = '0';
|
|
opened.length = 0;
|
|
onOpen = undefined;
|
|
__setLoadCodeGraphForTests(RecordingCodeGraph as unknown as typeof CodeGraph);
|
|
engine = new MCPEngine({ watch: true });
|
|
engines.push(engine);
|
|
// Two indexed children, none at the root: no default project (#1607).
|
|
await engine.ensureInitialized(workspace);
|
|
}, 60_000);
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(engines.splice(0).map((e) => e.stop()));
|
|
for (const child of children.splice(0)) {
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
const exited = new Promise((resolve) => child.once('exit', resolve));
|
|
child.kill('SIGTERM');
|
|
await exited;
|
|
}
|
|
}
|
|
vi.restoreAllMocks();
|
|
__setLoadCodeGraphForTests(null);
|
|
if (prevDebounce === undefined) delete process.env.CODEGRAPH_WATCH_DEBOUNCE_MS;
|
|
else process.env.CODEGRAPH_WATCH_DEBOUNCE_MS = prevDebounce;
|
|
if (prevGate === undefined) delete process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
|
|
else process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = prevGate;
|
|
fs.rmSync(workspace, { recursive: true, force: true });
|
|
}, 60_000);
|
|
|
|
function names(root: string): string[] {
|
|
const reader = CodeGraph.openSync(root);
|
|
try { return reader.getNodesByKind('function').map((n) => n.name); }
|
|
finally { reader.close(); }
|
|
}
|
|
|
|
/**
|
|
* A daemon owner starts with idle exit OFF. Armed at startup, its 500ms
|
|
* timer raced this process's first connection and lost on a loaded machine,
|
|
* so the daemon exited before any engine reached it (#1773). A test that
|
|
* needs the idle exit enables it with {@link armIdleExit} once it holds a
|
|
* session; the daemon reads the value each time its last client leaves.
|
|
*/
|
|
async function startOwner(mode: 'direct' | 'daemon', slowCatchUp = false): Promise<ChildProcess> {
|
|
const modulePath = path.resolve(__dirname, '../dist/mcp');
|
|
const ownerScript = mode === 'daemon' ? `
|
|
const { Daemon, tryAcquireDaemonLock } = require(process.argv[1] + '/daemon');
|
|
const root = process.argv[2];
|
|
tryAcquireDaemonLock(root);
|
|
const daemon = new Daemon(root, { idleTimeoutMs: 0 });
|
|
process.on('message', (message) => {
|
|
if (message !== 'arm-idle') return;
|
|
daemon.idleTimeoutMs = 500;
|
|
process.send('idle-armed');
|
|
});
|
|
daemon.start().then(() => { if (!process.env.CG_TEST_HOLD_CATCHUP) process.send('ready'); });
|
|
` : `
|
|
const { MCPEngine } = require(process.argv[1] + '/engine');
|
|
const engine = new MCPEngine();
|
|
process.on('SIGTERM', async () => { await engine.stop(); process.exit(0); });
|
|
engine.ensureInitialized(process.argv[2]).then(async () => {
|
|
await engine.getToolHandler().execute('codegraph_status', {});
|
|
if (!process.env.CG_TEST_HOLD_CATCHUP) process.send('ready');
|
|
});
|
|
`;
|
|
const script = `
|
|
if (process.env.CG_TEST_HOLD_CATCHUP) {
|
|
const CodeGraph = require(process.argv[1] + '/../index').default;
|
|
const sync = CodeGraph.prototype.sync;
|
|
CodeGraph.prototype.sync = async function (...args) {
|
|
process.send('ready');
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
return sync.apply(this, args);
|
|
};
|
|
}
|
|
` + ownerScript;
|
|
const child = spawn(process.execPath, ['-e', script, modulePath, serviceA], {
|
|
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
env: { ...process.env, CODEGRAPH_NO_DAEMON: mode === 'daemon' ? '0' : '1', CODEGRAPH_QUERY_POOL_SIZE: '0', CG_TEST_HOLD_CATCHUP: slowCatchUp ? '1' : '' },
|
|
});
|
|
children.push(child);
|
|
let stderr = '';
|
|
child.stderr!.on('data', (chunk) => { stderr += String(chunk); });
|
|
child.stdout!.resume();
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error(`Owner did not start: ${stderr}`)), 10000);
|
|
child.once('message', () => { clearTimeout(timer); resolve(); });
|
|
child.once('error', (err) => { clearTimeout(timer); reject(err); });
|
|
child.once('exit', () => { clearTimeout(timer); reject(new Error(`Owner exited: ${stderr}`)); });
|
|
});
|
|
return child;
|
|
}
|
|
|
|
async function armIdleExit(owner: ChildProcess): Promise<void> {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const onMessage = (message: unknown): void => {
|
|
if (message !== 'idle-armed') return;
|
|
owner.off('exit', onExit);
|
|
owner.off('message', onMessage);
|
|
resolve();
|
|
};
|
|
const onExit = (): void => { owner.off('message', onMessage); reject(new Error('Owner exited before arming idle exit')); };
|
|
owner.on('message', onMessage);
|
|
owner.once('exit', onExit);
|
|
owner.send('arm-idle');
|
|
});
|
|
}
|
|
|
|
async function search(projectPath: string, symbol: string): Promise<string> {
|
|
const res = await engine.getToolHandler().execute('codegraph_search', { query: symbol, projectPath });
|
|
expect(res.isError).toBeFalsy();
|
|
return res.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n');
|
|
}
|
|
|
|
for (const options of [{ watch: true }, { watch: false }, { readOnly: true }]) {
|
|
it(`defers explicit project opens during a rebuild (${JSON.stringify(options)})`, async () => {
|
|
await engine.stop();
|
|
engine = new BuiltMCPEngine(options);
|
|
engines.push(engine);
|
|
const holder = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
|
|
children.push(holder);
|
|
if (!holder.pid) throw new Error('Failed to spawn rebuild holder');
|
|
const fence = path.join(serviceA, '.codegraph/rebuild.pid');
|
|
fs.writeFileSync(fence, JSON.stringify({ pid: holder.pid, mode: 'rebuild', startedAt: Date.now() }));
|
|
const response = await engine.getToolHandler().execute('codegraph_search', {
|
|
projectPath: serviceA, query: 'alphaOriginal',
|
|
});
|
|
expect(JSON.stringify(response)).toContain('rebuild is in progress');
|
|
// Expected and temporary: guidance, never a tool error that teaches abandonment.
|
|
expect(JSON.stringify(response)).not.toContain('"isError":true');
|
|
expect(opened).toHaveLength(0);
|
|
expect(fs.existsSync(path.join(serviceA, '.codegraph/writer.pid'))).toBe(false);
|
|
fs.unlinkSync(fence);
|
|
expect(await search(serviceA, 'alphaOriginal')).toContain('alphaOriginal');
|
|
});
|
|
|
|
it(`defers default project opens and synchronous retries during a rebuild (${JSON.stringify(options)})`, async () => {
|
|
const reader = new BuiltMCPEngine(options);
|
|
engines.push(reader);
|
|
const holder = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
|
|
children.push(holder);
|
|
if (!holder.pid) throw new Error('Failed to spawn rebuild holder');
|
|
const fence = path.join(serviceA, '.codegraph/rebuild.pid');
|
|
fs.writeFileSync(fence, JSON.stringify({ pid: holder.pid, mode: 'rebuild', startedAt: Date.now() }));
|
|
await reader.ensureInitialized(serviceA);
|
|
expect(reader.hasDefaultCodeGraph()).toBe(false);
|
|
reader.retryInitializeSync(serviceA);
|
|
expect(reader.hasDefaultCodeGraph()).toBe(false);
|
|
expect(fs.existsSync(path.join(serviceA, '.codegraph/writer.pid'))).toBe(false);
|
|
fs.unlinkSync(fence);
|
|
reader.retryInitializeSync(serviceA);
|
|
expect(reader.hasDefaultCodeGraph()).toBe(true);
|
|
});
|
|
}
|
|
|
|
it('catches up an edit made before the first call and watches later edits', async () => {
|
|
// Edited while no server owned the index — the catch-up path.
|
|
fs.writeFileSync(path.join(serviceA, 'src', 'sample.ts'), 'export function alphaRenamed() { return 1; }\n');
|
|
const first = await search(serviceA, 'alphaRenamed');
|
|
expect(first).toContain('alphaRenamed');
|
|
expect(names(serviceA)).toContain('alphaRenamed');
|
|
expect(names(serviceA)).not.toContain('alphaOriginal');
|
|
expect(first).not.toContain('alphaOriginal');
|
|
expect(opened).toHaveLength(1);
|
|
expect(opened[0].isWatching()).toBe(true);
|
|
await opened[0].waitUntilWatcherReady(5000);
|
|
|
|
// Edited while the project stays cached — the watcher path.
|
|
fs.writeFileSync(path.join(serviceA, 'src', 'sample.ts'), 'export function alphaWatched() { return 1; }\n');
|
|
const seen = await waitFor(async () => names(serviceA).includes('alphaWatched'), 10000);
|
|
expect(seen).toBe(true);
|
|
});
|
|
|
|
it.runIf(process.platform !== 'win32')('keeps one watched instance per canonical root and closes it on stop()', async () => {
|
|
const link = path.join(workspace, 'link-to-b');
|
|
fs.symlinkSync(serviceB, link, 'dir');
|
|
expect(await search(serviceB, 'betaOriginal')).toContain('betaOriginal');
|
|
expect(await search(link, 'betaOriginal')).toContain('betaOriginal');
|
|
expect(await search(path.join(serviceB, 'src'), 'betaOriginal')).toContain('betaOriginal');
|
|
expect(opened).toHaveLength(1);
|
|
expect(opened[0].isWatching()).toBe(true);
|
|
expect(fs.existsSync(path.join(serviceB, '.codegraph', 'writer.pid'))).toBe(true);
|
|
|
|
await engine.stop();
|
|
expect(opened[0].isWatching()).toBe(false);
|
|
expect(fs.existsSync(path.join(serviceB, '.codegraph', 'writer.pid'))).toBe(false);
|
|
expect(() => opened[0].getStats()).toThrow();
|
|
});
|
|
|
|
it('does not take over a project another live process is already syncing', async () => {
|
|
// Simulate a foreign writer (another daemon) holding the lock.
|
|
fs.mkdirSync(path.join(serviceB, '.codegraph'), { recursive: true });
|
|
const foreign = { pid: process.ppid, mode: 'daemon', startedAt: Date.now() };
|
|
fs.writeFileSync(path.join(serviceB, '.codegraph', 'writer.pid'), JSON.stringify(foreign));
|
|
expect(await search(serviceB, 'betaOriginal')).toContain('betaOriginal');
|
|
expect(opened).toHaveLength(1);
|
|
expect(opened[0].isWatching()).toBe(false);
|
|
await engine.stop();
|
|
// Not ours — left in place.
|
|
expect(fs.readFileSync(path.join(serviceB, '.codegraph', 'writer.pid'), 'utf8')).toContain(String(process.ppid));
|
|
fs.unlinkSync(path.join(serviceB, '.codegraph', 'writer.pid'));
|
|
});
|
|
|
|
it('catches up both children without selecting a default', async () => {
|
|
for (const [root, symbol] of [[serviceA, 'alphaNew'], [serviceB, 'betaNew']]) {
|
|
fs.writeFileSync(path.join(root!, 'src/sample.ts'), `export function ${symbol}() {}\n`);
|
|
await search(root!, symbol!);
|
|
expect(names(root!)).toEqual([symbol]);
|
|
}
|
|
expect(engine.hasDefaultCodeGraph()).toBe(false);
|
|
});
|
|
|
|
it('shares the catch-up gate across concurrent calls and engines', async () => {
|
|
let release!: () => void;
|
|
const held = new Promise<void>((resolve) => { release = resolve; });
|
|
onOpen = (cg) => {
|
|
const sync = cg.sync.bind(cg);
|
|
vi.spyOn(cg, 'sync').mockImplementation(async (...args) => { await held; return sync(...args); });
|
|
};
|
|
fs.writeFileSync(path.join(serviceA, 'src/sample.ts'), 'export function concurrentNew() {}\n');
|
|
const second = new MCPEngine();
|
|
engines.push(second);
|
|
let completed = 0;
|
|
const calls = [
|
|
search(serviceA, 'concurrentNew'),
|
|
search(path.join(serviceA, 'src'), 'concurrentNew'),
|
|
second.getToolHandler().execute('codegraph_search', { projectPath: serviceA, query: 'concurrentNew' }),
|
|
].map((p) => p.then((r) => { completed++; return r; }));
|
|
try {
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
expect(completed).toBe(0);
|
|
expect(opened).toHaveLength(1);
|
|
} finally { release(); }
|
|
await Promise.all(calls);
|
|
expect(names(serviceA)).toEqual(['concurrentNew']);
|
|
expect(opened[0]!.sync).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('keeps watching after one of two engines releases its lease', async () => {
|
|
const second = new MCPEngine();
|
|
engines.push(second);
|
|
await search(serviceA, 'alphaOriginal');
|
|
await second.getToolHandler().execute('codegraph_search', { projectPath: serviceA, query: 'alphaOriginal' });
|
|
await opened[0]!.waitUntilWatcherReady(5000);
|
|
await engine.stop();
|
|
expect(opened[0]!.isWatching()).toBe(true);
|
|
expect(fs.existsSync(path.join(serviceA, '.codegraph/writer.pid'))).toBe(true);
|
|
fs.writeFileSync(path.join(serviceA, 'src/sample.ts'), 'export function survivingSession() {}\n');
|
|
expect(await waitFor(async () => names(serviceA).includes('survivingSession'), 10000)).toBe(true);
|
|
await second.stop();
|
|
expect(fs.existsSync(path.join(serviceA, '.codegraph/writer.pid'))).toBe(false);
|
|
});
|
|
|
|
it('retries a foreign writer and catches up after its ownership ends', async () => {
|
|
const lock = path.join(serviceA, '.codegraph/writer.pid');
|
|
fs.writeFileSync(lock, JSON.stringify({ pid: process.ppid, mode: 'direct', startedAt: Date.now() }));
|
|
await search(serviceA, 'alphaOriginal');
|
|
expect(opened[0]!.isWatching()).toBe(false);
|
|
fs.writeFileSync(path.join(serviceA, 'src/sample.ts'), 'export function afterOwnerExit() {}\n');
|
|
fs.unlinkSync(lock);
|
|
// No further query should be necessary to activate the surviving session.
|
|
expect(await waitFor(async () => names(serviceA).includes('afterOwnerExit'), 10000)).toBe(true);
|
|
expect(opened[0]!.isWatching()).toBe(true);
|
|
});
|
|
|
|
it('quietly retries catch-up after an indexing lock is released (#1361)', async () => {
|
|
const lock = path.join(serviceA, '.codegraph/codegraph.lock');
|
|
const writer = path.join(serviceA, '.codegraph/writer.pid');
|
|
const prev = process.env.CODEGRAPH_NO_WATCH;
|
|
// Ensure the lifecycle retry, not a watcher event, repairs the index.
|
|
process.env.CODEGRAPH_NO_WATCH = '1';
|
|
const stderr = vi.spyOn(process.stderr, 'write');
|
|
try {
|
|
fs.writeFileSync(path.join(serviceA, 'src/sample.ts'), 'export function afterIndexLock() {}\n');
|
|
fs.writeFileSync(lock, String(process.pid));
|
|
await search(serviceA, 'afterIndexLock');
|
|
expect(names(serviceA)).toEqual(['alphaOriginal']);
|
|
expect(fs.readFileSync(lock, 'utf8')).toBe(String(process.pid));
|
|
expect(JSON.parse(fs.readFileSync(writer, 'utf8')).ready).toBe(false);
|
|
expect(stderr.mock.calls.map(([text]) => String(text)).join('')).not.toContain('Catch-up sync failed');
|
|
|
|
fs.unlinkSync(lock);
|
|
// No second query: the lifecycle's periodic retry must finish catch-up.
|
|
// SQLite rows can be visible before sync's final maintenance finishes.
|
|
expect(await waitFor(async () => JSON.parse(fs.readFileSync(writer, 'utf8')).ready === true, 10000)).toBe(true);
|
|
expect(names(serviceA)).toEqual(['afterIndexLock']);
|
|
expect(opened[0]!.isWatching()).toBe(false);
|
|
} finally {
|
|
fs.rmSync(lock, { force: true });
|
|
stderr.mockRestore();
|
|
if (prev === undefined) delete process.env.CODEGRAPH_NO_WATCH;
|
|
else process.env.CODEGRAPH_NO_WATCH = prev;
|
|
}
|
|
});
|
|
|
|
it('honors no-watch for explicit projects', async () => {
|
|
await engine.stop();
|
|
engine = new MCPEngine({ watch: false });
|
|
engines.push(engine);
|
|
fs.writeFileSync(path.join(serviceA, 'src/sample.ts'), 'export function noWatchEdit() {}\n');
|
|
await search(serviceA, 'noWatchEdit');
|
|
expect(names(serviceA)).toEqual(['alphaOriginal']);
|
|
expect(opened[0]!.isWatching()).toBe(false);
|
|
expect(fs.existsSync(path.join(serviceA, '.codegraph/writer.pid'))).toBe(false);
|
|
});
|
|
|
|
it('honors the CLI no-watch policy while still catching up on access', async () => {
|
|
const prev = process.env.CODEGRAPH_NO_WATCH;
|
|
process.env.CODEGRAPH_NO_WATCH = '1';
|
|
try {
|
|
fs.writeFileSync(path.join(serviceA, 'src/sample.ts'), 'export function policyCatchUp() {}\n');
|
|
await search(serviceA, 'policyCatchUp');
|
|
expect(names(serviceA)).toEqual(['policyCatchUp']);
|
|
expect(opened[0]!.isWatching()).toBe(false);
|
|
fs.writeFileSync(path.join(serviceA, 'src/sample.ts'), 'export function policyUnwatched() {}\n');
|
|
await search(serviceA, 'policyUnwatched');
|
|
expect(names(serviceA)).toEqual(['policyCatchUp']);
|
|
} finally {
|
|
if (prev === undefined) delete process.env.CODEGRAPH_NO_WATCH;
|
|
else process.env.CODEGRAPH_NO_WATCH = prev;
|
|
}
|
|
});
|
|
|
|
// Builds and reconciles nine real indexes; a Windows VM exceeds the default
|
|
// 5s, and a loaded one exceeded 15s (#1773).
|
|
it('defers eviction and shutdown until an active catch-up finishes', async () => {
|
|
const roots = [serviceA, serviceB];
|
|
for (let i = roots.length; i <= MAX_CACHED_PROJECTS; i++) {
|
|
const root = path.join(workspace, `extra-${i}`);
|
|
await makeProject(root, `symbol${i}`);
|
|
roots.push(root);
|
|
}
|
|
let release!: () => void;
|
|
const held = new Promise<void>((resolve) => { release = resolve; });
|
|
onOpen = (cg) => {
|
|
if (cg.getProjectRoot() !== serviceA) return;
|
|
const sync = cg.sync.bind(cg);
|
|
vi.spyOn(cg, 'sync').mockImplementation(async (...args) => { await held; return sync(...args); });
|
|
};
|
|
const prev = process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
|
|
process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = '10';
|
|
try {
|
|
fs.writeFileSync(path.join(serviceA, 'src/sample.ts'), 'export function evictionCatchUp() {}\n');
|
|
await search(serviceA, 'evictionCatchUp');
|
|
// Only A should time out. Finish every other catch-up before expecting
|
|
// B to be the oldest evictable entry; 10ms can expire on those too.
|
|
process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = '0';
|
|
for (const root of roots.slice(1)) await search(root, 'symbol');
|
|
expect(() => opened[0]!.getStats()).not.toThrow();
|
|
expect(() => opened[1]!.getStats()).toThrow();
|
|
let stopped = false;
|
|
const stop = engine.stop().then(() => { stopped = true; });
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
expect(stopped).toBe(false);
|
|
expect(() => opened[0]!.getStats()).not.toThrow();
|
|
release();
|
|
await stop;
|
|
expect(names(serviceA)).toEqual(['evictionCatchUp']);
|
|
expect(() => opened[0]!.getStats()).toThrow();
|
|
expect(fs.existsSync(path.join(serviceA, '.codegraph/writer.pid'))).toBe(false);
|
|
} finally {
|
|
release();
|
|
if (prev === undefined) delete process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
|
|
else process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = prev;
|
|
}
|
|
}, 60_000);
|
|
|
|
|
|
it('drains a tool operation before closing its cached graph', async () => {
|
|
await search(serviceA, 'alphaOriginal');
|
|
const handler = engine.getToolHandler();
|
|
const dispatch = handler.executeReadTool.bind(handler);
|
|
let release!: () => void;
|
|
const held = new Promise<void>((resolve) => { release = resolve; });
|
|
vi.spyOn(handler, 'executeReadTool').mockImplementation(async (...args) => {
|
|
await held;
|
|
return dispatch(...args);
|
|
});
|
|
const call = search(serviceA, 'alphaOriginal');
|
|
let stopped = false;
|
|
const stop = engine.stop().then(() => { stopped = true; });
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
expect(stopped).toBe(false);
|
|
expect(opened[0]!.getStats().fileCount).toBe(1);
|
|
} finally { release(); }
|
|
await call;
|
|
await stop;
|
|
expect(() => opened[0]!.getStats()).toThrow();
|
|
});
|
|
|
|
it.each(['direct', 'daemon'] as const)('takes over when a real %s owner exits', async (mode) => {
|
|
fs.writeFileSync(path.join(serviceA, 'src/sample.ts'), 'export function ownerCatchUp() {}\n');
|
|
const owner = await startOwner(mode, true);
|
|
await search(serviceA, 'ownerCatchUp');
|
|
expect(names(serviceA)).toEqual(['ownerCatchUp']);
|
|
expect(opened[0]!.isWatching()).toBe(false);
|
|
const lock = JSON.parse(fs.readFileSync(path.join(serviceA, '.codegraph/writer.pid'), 'utf8'));
|
|
expect(lock.pid).toBe(owner.pid);
|
|
const exited = new Promise((resolve) => owner.once('exit', resolve));
|
|
owner.kill('SIGTERM');
|
|
await exited;
|
|
fs.writeFileSync(path.join(serviceA, 'src/sample.ts'), 'export function realOwnerExit() {}\n');
|
|
expect(await waitFor(async () => names(serviceA).includes('realOwnerExit'), 10000)).toBe(true);
|
|
expect(opened[0]!.isWatching()).toBe(true);
|
|
}, 60_000);
|
|
|
|
it('retains a daemon while either accessing engine remains connected', async () => {
|
|
const owner = await startOwner('daemon');
|
|
await search(serviceA, 'alphaOriginal');
|
|
const second = new MCPEngine();
|
|
engines.push(second);
|
|
await second.getToolHandler().execute('codegraph_search', { projectPath: serviceA, query: 'alphaOriginal' });
|
|
// Both engines now share one daemon session; from here on the daemon may
|
|
// idle out, and only losing that session should make it do so.
|
|
await armIdleExit(owner);
|
|
await engine.stop();
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
expect(owner.exitCode).toBeNull();
|
|
expect(owner.signalCode).toBeNull();
|
|
fs.writeFileSync(path.join(serviceA, 'src/sample.ts'), 'export function retainedDaemon() {}\n');
|
|
expect(await waitFor(async () => names(serviceA).includes('retainedDaemon'), 10000)).toBe(true);
|
|
const exited = new Promise((resolve) => owner.once('exit', resolve));
|
|
await second.stop();
|
|
await exited;
|
|
expect(owner.exitCode).toBe(0);
|
|
}, 60_000);
|
|
|
|
});
|