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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- Projects queried through `projectPath` (a repository other than the server's default, such as an indexed child of an un-indexed workspace) now stay in sync like the default project does: the first call catches up edits made while no server was running, and a file watcher keeps the index current for as long as the project stays open in that session. Up to 8 such projects are kept open per server, the least recently used one is closed when a ninth is opened, and all are closed when the server stops. A project another live server already syncs is left to that server. (#1835)

- Rust calls on `self` now stay with the enclosing type instead of linking to an unrelated type’s same-named method. Thanks @L4XB. (#1861)

- Turning telemetry off now resets its identity and stops running processes from recording, sending, or restoring unsent data. (#1869)
Expand Down
127 changes: 127 additions & 0 deletions __tests__/mcp-projectpath-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* 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 } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import { MCPEngine } from '../src/mcp/engine';
import { __setLoadCodeGraphForTests } from '../src/mcp/tools';

const opened: CodeGraph[] = [];
/** 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);
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();
}

describe('MCP explicit projectPath lifecycle (#1835)', () => {
let workspace: string;
let serviceA: string;
let serviceB: string;
let engine: MCPEngine;
const prevDebounce = process.env.CODEGRAPH_WATCH_DEBOUNCE_MS;

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';
opened.length = 0;
__setLoadCodeGraphForTests(RecordingCodeGraph as unknown as typeof CodeGraph);
engine = new MCPEngine({ watch: true });
// Two indexed children, none at the root: no default project (#1607).
await engine.ensureInitialized(workspace);
});

afterEach(() => {
engine.stop();
__setLoadCodeGraphForTests(null);
if (prevDebounce === undefined) delete process.env.CODEGRAPH_WATCH_DEBOUNCE_MS;
else process.env.CODEGRAPH_WATCH_DEBOUNCE_MS = prevDebounce;
fs.rmSync(workspace, { recursive: true, force: true });
});

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');
}

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(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 () => (await search(serviceA, 'alphaWatched')).includes('alphaWatched'), 10000);
expect(seen).toBe(true);
});

it('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);

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);
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'));
});
});
124 changes: 93 additions & 31 deletions src/mcp/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ export class MCPEngine {
private watcherStarted = false;
/** Set when this engine holds writer.pid (#1740). */
private writerLockRoot: string | null = null;
// Roots of explicit-`projectPath` projects whose writer.pid this engine holds
// (#1835) — released when the ToolHandler closes the project or on stop().
private explicitWriterLocks: Set<string> = new Set();
private opts: Required<Omit<MCPEngineOptions, 'writerLockRoot'>>;
private closed = false;
// Off-loop read-tool pool (daemon mode only). Created lazily once the default
Expand All @@ -85,6 +88,10 @@ export class MCPEngine {
constructor(opts: MCPEngineOptions = {}) {
this.opts = { watch: opts.watch ?? true, queryPool: opts.queryPool ?? false };
this.toolHandler = new ToolHandler(null);
this.toolHandler.setProjectLifecycle({
activate: (cg) => this.activateExplicitProject(cg),
release: (cg) => this.releaseExplicitProject(cg),
});
if (opts.writerLockRoot) {
const writer = tryAcquireWriterLock(opts.writerLockRoot, 'fallback');
if (writer.kind === 'taken') {
Expand Down Expand Up @@ -231,12 +238,97 @@ export class MCPEngine {
this.queryPool = null;
}
this.toolHandler.closeAll();
for (const root of this.explicitWriterLocks) releaseWriterLock(root);
this.explicitWriterLocks.clear();
if (this.cg) {
try { this.cg.close(); } catch { /* ignore */ }
this.cg = null;
}
}

/**
* Give a project opened for an explicit `projectPath` the default project's
* lifecycle (#1835): a file watcher while the ToolHandler keeps it cached and
* a catch-up sync now, whose promise the handler awaits before the first call
* against it. Only when this engine wins the project's writer lock — if another
* live process (its own daemon, say) holds it, that process already syncs the
* index and we must not contend for codegraph.lock (#1740). Never throws.
*/
private activateExplicitProject(cg: CodeGraph): Promise<void> {
if (this.closed || !this.opts.watch) return Promise.resolve();
const root = cg.getProjectRoot();
const writer = tryAcquireWriterLock(root, 'fallback');
if (writer.kind === 'taken') {
process.stderr.write(
`[CodeGraph MCP] Not syncing ${root} from this session — ${writerLockHeldMessage(writer.existing, writer.pidPath)}\n`
);
return Promise.resolve();
}
this.explicitWriterLocks.add(root);

const disabledReason = watchDisabledReason(root);
if (disabledReason) {
process.stderr.write(`[CodeGraph MCP] File watcher disabled for ${root} — ${disabledReason}.\n`);
} else if (cg.watch(this.watchOptions())) {
process.stderr.write(`[CodeGraph MCP] File watcher active for ${root} (opened via projectPath)\n`);
}

return cg
.sync()
.then((result) => {
const changed = result.filesAdded + result.filesModified + result.filesRemoved;
if (changed > 0) {
process.stderr.write(`[CodeGraph MCP] Caught up ${changed} file(s) changed in ${root}\n`);
}
})
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[CodeGraph MCP] Catch-up sync failed for ${root}: ${msg}\n`);
});
}

/** Drop the writer lock of an explicit project the ToolHandler is closing (#1835). */
private releaseExplicitProject(cg: CodeGraph): void {
const root = cg.getProjectRoot();
if (!this.explicitWriterLocks.delete(root)) return;
releaseWriterLock(root);
}

/** Watch options shared by the default project and explicit projects. */
private watchOptions(): Parameters<CodeGraph['watch']>[0] {
// Optional override for the debounce window via env var (issue #403).
// Useful for workspaces with bursty writes (formatter-on-save chains,
// large generated outputs) where the 2s default fires too often. Clamped
// to [100ms, 60s]; out-of-range / non-numeric values fall back to the
// FileWatcher default. We log the active value so it's discoverable.
const debounceMs = parseDebounceEnv(process.env.CODEGRAPH_WATCH_DEBOUNCE_MS);
if (debounceMs !== undefined) {
process.stderr.write(`[CodeGraph MCP] File watcher debounce: ${debounceMs}ms (CODEGRAPH_WATCH_DEBOUNCE_MS)\n`);
}
return {
debounceMs,
onSyncComplete: (result) => {
if (result.filesChanged > 0) {
process.stderr.write(
`[CodeGraph MCP] Auto-synced ${result.filesChanged} file(s) in ${result.durationMs}ms\n`
);
}
},
onSyncError: (err) => {
process.stderr.write(`[CodeGraph MCP] Auto-sync error: ${err.message}\n`);
},
onDegraded: (reason) => {
// Live watching gave up permanently (watch-resource exhaustion or a
// write lock held past the retry budget). Say so loudly and ONCE — the
// graph will no longer auto-update, so a long-running MCP session must
// not keep assuming it's fresh. The reason already names the remedy
// (`codegraph sync` / git sync hooks).
process.stderr.write(`[CodeGraph MCP] File watcher degraded — ${reason}\n`);
},
};
}


private async doInitialize(searchFrom: string): Promise<void> {
this.toolHandler.setDefaultProjectHint(searchFrom);

Expand Down Expand Up @@ -326,37 +418,7 @@ export class MCPEngine {
return;
}

// Optional override for the debounce window via env var (issue #403).
// Useful for workspaces with bursty writes (formatter-on-save chains,
// large generated outputs) where the 2s default fires too often. Clamped
// to [100ms, 60s]; out-of-range / non-numeric values fall back to the
// FileWatcher default. We log the active value so it's discoverable.
const debounceMs = parseDebounceEnv(process.env.CODEGRAPH_WATCH_DEBOUNCE_MS);
if (debounceMs !== undefined) {
process.stderr.write(`[CodeGraph MCP] File watcher debounce: ${debounceMs}ms (CODEGRAPH_WATCH_DEBOUNCE_MS)\n`);
}

const started = this.cg.watch({
debounceMs,
onSyncComplete: (result) => {
if (result.filesChanged > 0) {
process.stderr.write(
`[CodeGraph MCP] Auto-synced ${result.filesChanged} file(s) in ${result.durationMs}ms\n`
);
}
},
onSyncError: (err) => {
process.stderr.write(`[CodeGraph MCP] Auto-sync error: ${err.message}\n`);
},
onDegraded: (reason) => {
// Live watching gave up permanently (watch-resource exhaustion or a
// write lock held past the retry budget). Say so loudly and ONCE — the
// graph will no longer auto-update, so a long-running MCP session must
// not keep assuming it's fresh. The reason already names the remedy
// (`codegraph sync` / git sync hooks).
process.stderr.write(`[CodeGraph MCP] File watcher degraded — ${reason}\n`);
},
});
const started = this.cg.watch(this.watchOptions());

this.watcherStarted = true;
if (started) {
Expand Down
Loading