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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- The MCP server no longer drops a later response when a client reuses its initialization request ID after the opening handshake completes.
- The background file watcher no longer exhausts your machine's file-descriptor budget. On macOS it previously kept **one open file handle per watched file**, so on a large project the running MCP server could pile up tens of thousands of handles and blow past the system-wide limit — at which point *unrelated* apps (your shell, editor, Docker, browser) started failing with "too many open files" until the codegraph process was killed. The watcher now uses a single recursive watch on macOS and Windows, and bounded per-directory watches on Linux, so its cost stays flat no matter how large the project is. (#644, #496, #555, #628, #579)
- Indexing a project with very symbol-dense files (tens of thousands of functions or methods in a single file) no longer runs out of memory. The step that links dynamic call relationships used to load every function and method into memory at once, which could exhaust the heap and abort indexing with "JavaScript heap out of memory" on large or generated codebases; it now streams them, so memory stays flat no matter how many symbols the project has. (#610)
- Indexing a very large repository no longer aborts during its first sync with a "too many SQL variables" error. (#540)
Expand Down
33 changes: 33 additions & 0 deletions __tests__/mcp-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,21 @@ function findResponse(stdout: string[], id: number): any | null {
return null;
}

/** Find every JSON-RPC response with the given id on stdout. */
function findResponses(stdout: string[], id: number): any[] {
const responses: any[] = [];
for (const line of stdout) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
if (parsed && parsed.id === id && (parsed.result !== undefined || parsed.error !== undefined)) {
responses.push(parsed);
}
} catch { /* not JSON */ }
}
return responses;
}

function waitFor<T>(
predicate: () => T | undefined | null | false,
timeoutMs: number,
Expand Down Expand Up @@ -228,6 +243,24 @@ describe('Shared MCP daemon (issue #411)', () => {
expect(readLockPid(realRoot)).toBe(daemonPid);
}, 40000);

it('relays a later response when the client reuses the initialize request id', async () => {
const server = spawnServer(tempDir, { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' });
servers.push(server);
sendInitialize(server.child, `file://${tempDir}`, 1);
await waitFor(() => findResponse(server.stdout, 1), 10000);
await waitFor(() => server.stderr.some((l) => l.includes('Attached to shared daemon')), 8000);

// JSON-RPC request IDs may be reused after the previous request completes.
// The proxy must suppress only the daemon's duplicate initialize response,
// not a later response that happens to use the same ID.
sendMessage(server.child, { jsonrpc: '2.0', id: 1, method: 'ping' });
const responses = await waitFor(() => {
const found = findResponses(server.stdout, 1);
return found.length >= 2 ? found : null;
}, 3000);
expect(responses[1].result).toEqual({});
}, 20000);

it('concurrent launchers converge on a single daemon (lockfile race — must-fix 1)', async () => {
const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' };

Expand Down
8 changes: 7 additions & 1 deletion src/mcp/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,13 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
sockBuf = sockBuf.slice(idx + 1);
if (!line.trim()) continue;
if (clientInitId !== undefined) {
try { const m = JSON.parse(line) as JsonRpc; if (m.id === clientInitId && ('result' in m || 'error' in m)) continue; } catch { /* relay */ }
try {
const m = JSON.parse(line) as JsonRpc;
if (m.id === clientInitId && ('result' in m || 'error' in m)) {
clientInitId = undefined;
continue;
}
} catch { /* relay */ }
}
writeClient(line);
}
Expand Down