Skip to content
Open
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
78 changes: 78 additions & 0 deletions __tests__/mcp-idle-release-order.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void> => {
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();
});
});
6 changes: 5 additions & 1 deletion src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down