Skip to content
Merged
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
28 changes: 28 additions & 0 deletions __tests__/mcp-startup-modules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Startup module graph: loading the MCP server entry (what every `serve --mcp`
* process does before it knows its mode) must not load the file watcher,
* extraction or resolution. Only a session that opens a project needs them,
* and they cost every other session memory.
*/
import { describe, it, expect } from 'vitest';
import { execFileSync } from 'child_process';
import * as path from 'path';

const DIST = path.resolve(__dirname, '../dist');

function loadedAfterRequire(entry: string): string[] {
const script = `require(${JSON.stringify(path.join(DIST, entry))});` +
`process.stdout.write(JSON.stringify(Object.keys(require.cache)));`;
const out = execFileSync(process.execPath, ['-e', script], { encoding: 'utf8' });
return (JSON.parse(out) as string[]).map((f) => path.relative(DIST, f).split(path.sep).join('/'));
}

describe('MCP startup modules', () => {
it('loading the server entry leaves the watcher, extraction and the CodeGraph core unloaded', () => {
const loaded = loadedAfterRequire('mcp/index.js');
expect(loaded).toContain('mcp/proxy.js');
for (const heavy of ['sync/watcher.js', 'extraction/index.js', 'resolution/index.js', 'codegraph.js']) {
expect(loaded).not.toContain(heavy);
}
});
});
18 changes: 17 additions & 1 deletion __tests__/query-pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,18 @@ describe('QueryPool', () => {
expect(pool.ready).toBe(false); // destroyed pool must not be selected
});

it('a pool with no default project starts its first worker on warm()', async () => {
let created = 0;
const pool = new QueryPool({ root: null, size: 2, createWorker: () => { created++; return new FakeWorker((m) => ({ result: ok(`r:${m.toolName}`) })); } });
expect(created).toBe(0);
pool.warm();
pool.warm(); // one worker, however many calls ask
expect(created).toBe(1);
await sleep(5);
expect(pool.ready).toBe(true);
await pool.destroy();
});

it('retires a failed cold start and serves queued work on its replacement', async () => {
const workers: FakeWorker[] = [];
const pool = new QueryPool({
Expand Down Expand Up @@ -395,10 +407,14 @@ describe('MCP query pool with real projects (#1465)', () => {
fs.mkdirSync(workspace);
const activeEngine = await start(hasDefault ? alpha : workspace);
expect(pool).not.toBeNull();
await vi.waitFor(() => expect(pool!.ready).toBe(true), { timeout: 15000 });
// A rootless pool starts no worker until its first call.
expect(pool!.liveWorkers).toBe(hasDefault ? 1 : 0);
const handler = activeEngine.getToolHandler();
// Drain catch-up before comparing the worker and in-process paths.
await handler.execute('codegraph_status', { projectPath: alpha });
// The first read call starts the worker and runs in-process meanwhile.
await handler.execute('codegraph_explore', { projectPath: alpha, query: 'alphaSymbol' });
await vi.waitFor(() => expect(pool!.ready).toBe(true), { timeout: 15000 });
const session = new ExploreSessionState();
const calls = Array.from({ length: 6 }, (_, i) => {
const projectPath = i % 2 ? beta : alpha;
Expand Down
3 changes: 2 additions & 1 deletion src/mcp/project-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { realpathSync } from 'fs';
import type { Socket } from 'net';
import type CodeGraph from '../index';
import { canonicalProjectRoot, isInitialized } from '../directory';
import { LockUnavailableError, watchDisabledReason } from '../sync';
import { LockUnavailableError } from '../sync/lock-unavailable';
import { watchDisabledReason } from '../sync/watch-policy';
import { getDaemonSocketCandidates } from './daemon-paths';
import { connectWithHello } from './proxy';
import { markWriterReady, readWriterLock, releaseWriterLock, tryAcquireWriterLock } from './writer-lock';
Expand Down
12 changes: 11 additions & 1 deletion src/mcp/query-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,17 @@ export class QueryPool {
this.retireTimer.unref?.();
}
this.startSettleMs = opts.startSettleMs ?? WORKER_START_SETTLE_MS;
this.spawnOne(); // one eager warm worker, ready for the first call
// One eager warm worker, ready for the first call. A pool with no default
// project waits for its first call instead: most such sessions never query.
if (this.root !== null) this.spawnOne();
}

/**
* Start a worker if none is running. Calls are served in-process until one is
* ready, so the call that triggers this does not wait for it.
*/
warm(): void {
if (this.workers.size === 0 && this.healthy) this.spawnOne();
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3175,6 +3175,7 @@ export class ToolHandler {
// The default may have appeared after the workers started. Pass the
// main thread's current root explicitly; with no root or projectPath,
// keep the main handler's workspace-specific not-indexed guidance.
if (args.projectPath ?? this.cg) this.queryPool?.warm();
const pooled = !!(this.queryPool && this.queryPool.healthy && this.queryPool.ready);
const projectPath = pooled ? args.projectPath ?? this.cg?.getProjectRoot() : undefined;
const wasDegraded = project.isWatcherDegraded?.();
Expand Down
13 changes: 13 additions & 0 deletions src/sync/lock-unavailable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Thrown by a `syncFn` to signal that the underlying sync couldn't acquire
* the cross-process write lock (#449). The watcher treats this as "no
* progress" — preserves `pendingFiles`, skips `onSyncComplete`, and the
* `finally` block reschedules. Quiet (debug-only) because a long-running
* external indexer can hit this every debounce cycle.
*/
export class LockUnavailableError extends Error {
constructor(message = 'CodeGraph file lock unavailable; another process is writing') {
super(message);
this.name = 'LockUnavailableError';
}
}
15 changes: 2 additions & 13 deletions src/sync/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { logDebug, logWarn } from '../errors';
import { normalizePath } from '../utils';
import { isCodeGraphDataDir } from '../directory';
import { watchDisabledReason } from './watch-policy';
import { LockUnavailableError } from './lock-unavailable';

/**
* Number of consecutive lock-contention retries the watcher tolerates before
Expand Down Expand Up @@ -217,19 +218,7 @@ export interface WatchOptions {
inertForTests?: boolean;
}

/**
* Thrown by a `syncFn` to signal that the underlying sync couldn't acquire
* the cross-process write lock (#449). The watcher treats this as "no
* progress" — preserves `pendingFiles`, skips `onSyncComplete`, and the
* `finally` block reschedules. Quiet (debug-only) because a long-running
* external indexer can hit this every debounce cycle.
*/
export class LockUnavailableError extends Error {
constructor(message = 'CodeGraph file lock unavailable; another process is writing') {
super(message);
this.name = 'LockUnavailableError';
}
}
export { LockUnavailableError };

/**
* Per-file pending entry — tracks a source file the watcher saw an event for
Expand Down