From de2b80e9212abddbf1c9604e94212b2a17f0642e Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Fri, 23 Oct 2026 16:11:24 -0500 Subject: [PATCH] fix(mcp): a project opened for projectPath is released on its own idle deadline The handler keeps one idle timer, armed for the oldest open project a trim can release, and it skips a project whose catch-up is still running. scheduleIdleRelease returned early while a timer was armed. So when a project's catch-up settled after a newer project had armed the timer, the trim found the older project not yet idle and kept the newer one's deadline. The older project stayed open, writer lock included, until then: up to nearly twice the idle timeout. With the 10-minute default, a project last used at 0:00 whose catch-up settles at 9:50, while another was used at 8:20, went at 18:20, not 10:00. Each trim that gets as far as scheduling now replaces the timer, re-armed for the oldest releasable project. Trims skip scheduling while calls are in flight, and the last call to finish trims again, so a settled catch-up still gets its own deadline. There is still one timer and no polling. Validation: the new fake-timer test drives that order through execute(). On main a565f215 nothing is released at 3.6 s (expected [] to deeply equal [ 'a' ]); with the fix A goes at 3.5 s and B at 4.5 s. Its afterEach restores both CODEGRAPH_* variables the test sets, also when the test fails: checked with the test failing on main's code, with the variables preset and with them unset. Follow-up to issue 2087, whose idle release (PR #2283) shipped in 1.6.2. Original PR #2306 by @bompus. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/mcp-idle-release-order.test.ts | 78 ++++++++++++++++++++++++ src/mcp/tools.ts | 6 +- 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 __tests__/mcp-idle-release-order.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 913421280..737aad632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph index` no longer stops with advice to run `codegraph daemon stop`, a command that doesn't exist, when the project's background-server lock names a process that isn't CodeGraph, such as a lock left by a crash whose process number the system has since given to another program: it now clears that stale lock, leaving the process alone, and rebuilds, and `codegraph unlock` clears it too. When a CodeGraph process that doesn't answer holds the lock, such as an older background server, the message names that process and the command that stops it, and the message about another server already serving a project now points to `codegraph daemon`. Thanks @inth3shadows for the report and @danusha2345 for the first fix. (#2770) - A program that uses CodeGraph as a library and indexes chosen files with `indexFiles()` now keeps other CodeGraph processes, like a `codegraph sync` run by a git hook, out of the index until those files are stored. Before, it let go of the write lock that keeps two writers apart as soon as indexing started, so another process could write to the index at the same time. - The shared background server is no longer killed with "Main thread unresponsive" while it brings another project's index up to date, such as one a session names with `projectPath`: only its own project's index counted as progress, so a long update of any other looked like a hang, and the server was stopped and restarted again and again. Every index the server keeps up to date now counts, and so does the one a session updates on its own when the background server is unavailable. Thanks @mradwankhalil for the report and the first fix. (#2404) +- A project a session queries through `projectPath` is now let go once it has gone its own 10 minutes without a query. Before, when its index was still being brought up to date as the session went on to query another project, it was held, locked, until that other project's 10 minutes were up, so its own session and `codegraph index` could wait nearly twice as long to take over. Thanks @bompus. (#2087) - An index left incomplete by an indexing run that was stopped partway, by a crash, an out-of-memory kill or the server's own hang check, no longer gets the background server killed with "Main thread unresponsive" every time it starts: on a large project, restoring the database indexes the run had set aside took longer than the hang check allows, so each restart died at the same point and the index was never repaired. They are now restored in the background, and the project is served once they are back. Thanks @cbeaulieu-gt for the report and the first fix. (#1887) - A new agent session no longer waits up to about 12 minutes for its first answer when the background server is stuck: a server whose process still holds its connection open but never responds made the session wait out a long series of retries. The session now gives up within seconds and answers on its own, and switches back to the background server once that has been restarted. - The bundled Node runtime moves to 24.22.1, Node's October security release, and every release now checks the Node it bundles against the checksums nodejs.org publishes. The new runtime also fixes a crash on Windows as a command like `codegraph sync` exits, after its work is done, printing `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)`, which made a script or CI run that had succeeded fail. Thanks @Ancienttwo for the report and the first fix, and @IdealBoy709 for the report on the security release. (#2617, #2780) diff --git a/__tests__/mcp-idle-release-order.test.ts b/__tests__/mcp-idle-release-order.test.ts new file mode 100644 index 000000000..a3b565e59 --- /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 d3fc528ac..e15c8386d 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -2547,9 +2547,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();