diff --git a/__tests__/mcp-startup-modules.test.ts b/__tests__/mcp-startup-modules.test.ts new file mode 100644 index 000000000..7c9a47a57 --- /dev/null +++ b/__tests__/mcp-startup-modules.test.ts @@ -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); + } + }); +}); diff --git a/__tests__/query-pool.test.ts b/__tests__/query-pool.test.ts index 7c28f48f0..5cf2f582a 100644 --- a/__tests__/query-pool.test.ts +++ b/__tests__/query-pool.test.ts @@ -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({ @@ -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; diff --git a/src/mcp/project-lifecycle.ts b/src/mcp/project-lifecycle.ts index 453ef26fc..cc151ba13 100644 --- a/src/mcp/project-lifecycle.ts +++ b/src/mcp/project-lifecycle.ts @@ -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'; diff --git a/src/mcp/query-pool.ts b/src/mcp/query-pool.ts index 630191ab4..09ac847ed 100644 --- a/src/mcp/query-pool.ts +++ b/src/mcp/query-pool.ts @@ -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(); } /** diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index ad55bd827..31766409f 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -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?.(); diff --git a/src/sync/lock-unavailable.ts b/src/sync/lock-unavailable.ts new file mode 100644 index 000000000..9639fad24 --- /dev/null +++ b/src/sync/lock-unavailable.ts @@ -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'; + } +} diff --git a/src/sync/watcher.ts b/src/sync/watcher.ts index 350524cd5..08a44444c 100644 --- a/src/sync/watcher.ts +++ b/src/sync/watcher.ts @@ -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 @@ -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