diff --git a/__tests__/mcp-idle-release-order.test.ts b/__tests__/mcp-idle-release-order.test.ts new file mode 100644 index 0000000000..a3b565e595 --- /dev/null +++ b/__tests__/mcp-idle-release-order.test.ts @@ -0,0 +1,78 @@ +/** + * Idle release of explicit projects keeps each project's own deadline (#2087). + * + * The idle timer is armed for the oldest project a trim can release, and a + * project whose catch-up is still running is skipped. When that catch-up + * settles after a newer project armed the timer, the older project must be + * released on its own deadline, not held (with its writer lock) until the + * newer one's. Fake timers make the ordering exact. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ToolHandler, type ProjectLifecycle } from '../src/mcp/tools'; +import CodeGraph from '../src'; + +const IDLE_MS = 3000; +// Long enough to fail validation after the project gate, so a call opens and +// gates its project without running a query. +const OVERSIZED_PATH = 'x'.repeat(100_000); + +describe('idle release order (#2087)', () => { + let workspace: string; + const prevIdle = process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS; + const prevGate = process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS; + + beforeEach(() => { + workspace = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-idle-order-'))); + // Real (empty) indexes, so the roots resolve; the lifecycle below hands + // out stand-ins and never opens them. + for (const name of ['a', 'b']) CodeGraph.initSync(path.join(workspace, name)).close(); + process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS = String(IDLE_MS); + process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = '10'; + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + }); + + afterEach(() => { + vi.useRealTimers(); + if (prevIdle === undefined) delete process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS; + else process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS = prevIdle; + 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 }); + }); + + it('releases an older project on its own deadline after its catch-up settles', async () => { + const released: string[] = []; + let settleA!: () => void; + const catchUpA = new Promise((resolve) => { settleA = resolve; }); + const lifecycle: ProjectLifecycle = { + open: (root) => ({ + getProjectRoot: () => root, + reopenIfReplaced: () => false, + close: () => {}, + }) as unknown as CodeGraph, + activate: (cg) => (path.basename(cg.getProjectRoot()) === 'a' ? catchUpA : Promise.resolve()), + release: (cg) => { released.push(path.basename(cg.getProjectRoot())); }, + }; + const handler = new ToolHandler(null); + handler.setProjectLifecycle(lifecycle); + const call = async (name: string): Promise => { + const done = handler.execute('codegraph_files', { projectPath: path.join(workspace, name), path: OVERSIZED_PATH }); + await vi.advanceTimersByTimeAsync(10); // the catch-up gate's serve-anyway deadline + expect((await done).content[0]!.text).toContain('path exceeds maximum length'); + }; + + await call('a'); // A used at t=0, its catch-up still running + await vi.advanceTimersByTimeAsync(1490); + await call('b'); // B used at t=1500: the timer is armed for B, due at 4500 + await vi.advanceTimersByTimeAsync(990); + settleA(); // t=2500: A is due at 3000, which the 1 s floor moves to 3500 + await vi.advanceTimersByTimeAsync(1100); + expect(released).toEqual(['a']); + await vi.advanceTimersByTimeAsync(1000); + expect(released).toEqual(['a', 'b']); + await handler.closeAll(); + }); +}); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index c8fec8d3e5..c363228dcf 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -2409,9 +2409,13 @@ export class ToolHandler { * Arm one unref'd timer for the oldest project a trim could release. A * project whose catch-up is still running is trimmed when that settles; the * 1s floor keeps a project a trim must skip from re-arming in a tight loop. + * Each call replaces the armed timer, so an older project whose catch-up + * settles after a newer one armed the timer keeps its own deadline. */ private scheduleIdleRelease(idleMs: number): void { - if (this.idleReleaseTimer || this.closing || idleMs <= 0) return; + if (this.idleReleaseTimer) clearTimeout(this.idleReleaseTimer); + this.idleReleaseTimer = null; + if (this.closing || idleMs <= 0) return; for (const [root, cg] of this.projectCache) { if (this.projectGates.has(cg)) continue; const due = (this.projectUsedAt.get(root) ?? Date.now()) + idleMs - Date.now();