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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
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 @@ -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();
Expand Down