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

## [Unreleased]

### Fixes

- 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)

## [0.9.9] - 2026-06-02

Expand Down
121 changes: 0 additions & 121 deletions __tests__/__helpers__/chokidar-mock.ts

This file was deleted.

37 changes: 16 additions & 21 deletions __tests__/mcp-staleness-banner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,22 @@
* decides whether to Read the specific stale file. These tests exercise
* the full real path: real CodeGraph index + real ToolHandler.execute().
*
* **chokidar is mocked** (see __helpers__/chokidar-mock.ts): the real
* FSEvents/inotify event delivery is non-deterministic under parallel
* vitest execution and produced a consistent ~30% failure rate on these
* tests when run inside the full suite. The mock replaces chokidar with
* a controllable EventEmitter so the tests synthesize file events
* deterministically via `triggerFileEvent(...)` instead of waiting on
* the OS-level watcher to deliver. The watcher's actual debounce timer
* (real setTimeout) is left untouched.
* **Event delivery uses a synthetic seam** (`__emitWatchEventForTests`): the
* real native fs.watch (FSEvents/inotify) delivery is non-deterministic under
* parallel vitest execution and produced a consistent ~30% failure rate on
* these tests when run inside the full suite. The seam drives the watcher's
* pending-set pipeline directly so the tests synthesize file events
* deterministically. The watcher's actual debounce timer (real setTimeout) is
* left untouched.
*/

import { vi } from 'vitest';
// Hoisted: chokidar is replaced by the controllable mock for this file.
vi.mock('chokidar', async () => (await import('./__helpers__/chokidar-mock')).chokidarMockModule);

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
import { triggerFileEvent } from './__helpers__/chokidar-mock';
import { __emitWatchEventForTests } from '../src/sync/watcher';

function waitFor(condition: () => boolean, timeoutMs = 2000, intervalMs = 25): Promise<void> {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -83,7 +78,7 @@ describe('MCP staleness banner', () => {

it('prepends a stale banner when the response references a pending file', async () => {
// Long debounce so the edit lingers in pendingFiles while we query.
cg.watch({ debounceMs: 4000 });
cg.watch({ debounceMs: 4000, inertForTests: true });
await cg.waitUntilWatcherReady();

// Real disk write so a later sync (if it fires) sees the new content,
Expand All @@ -93,7 +88,7 @@ describe('MCP staleness banner', () => {
path.join(testDir, 'src', 'alpha-only.ts'),
'export function alphaOnly() { return 99; }\n',
);
triggerFileEvent(testDir, 'change', 'src/alpha-only.ts');
__emitWatchEventForTests(testDir, 'src/alpha-only.ts');

// With mocked chokidar this is synchronous — keep the wait just to
// exercise the realistic shape (the watcher's `chokidarReady` gate
Expand All @@ -114,7 +109,7 @@ describe('MCP staleness banner', () => {
});

it('uses the footer (not the banner) when pending files are not referenced', async () => {
cg.watch({ debounceMs: 4000 });
cg.watch({ debounceMs: 4000, inertForTests: true });
await cg.waitUntilWatcherReady();

// Edit bravo-only.ts but search for the alphaOnly symbol, whose hit is
Expand All @@ -124,7 +119,7 @@ describe('MCP staleness banner', () => {
path.join(testDir, 'src', 'bravo-only.ts'),
'export function bravoOnly() { return 22; }\n',
);
triggerFileEvent(testDir, 'change', 'src/bravo-only.ts');
__emitWatchEventForTests(testDir, 'src/bravo-only.ts');
await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/bravo-only.ts'));

const res = await handler.execute('codegraph_search', { query: 'alphaOnly' });
Expand All @@ -136,14 +131,14 @@ describe('MCP staleness banner', () => {
});

it('drops the banner once the sync completes and clears the pending entry', async () => {
cg.watch({ debounceMs: 200 });
cg.watch({ debounceMs: 200, inertForTests: true });
await cg.waitUntilWatcherReady();

fs.writeFileSync(
path.join(testDir, 'src', 'alpha-only.ts'),
'export function alphaOnly() { return 7; }\n',
);
triggerFileEvent(testDir, 'change', 'src/alpha-only.ts');
__emitWatchEventForTests(testDir, 'src/alpha-only.ts');
// Wait through debounce (200ms) + sync; pendingFiles drains back to empty.
await waitFor(() => cg.getPendingFiles().length === 0, 3000);

Expand All @@ -154,14 +149,14 @@ describe('MCP staleness banner', () => {
});

it('lists pending files under "Pending sync" in codegraph_status', async () => {
cg.watch({ debounceMs: 4000 });
cg.watch({ debounceMs: 4000, inertForTests: true });
await cg.waitUntilWatcherReady();

fs.writeFileSync(
path.join(testDir, 'src', 'charlie-only.ts'),
'export function charlieOnly() { return 33; }\n',
);
triggerFileEvent(testDir, 'change', 'src/charlie-only.ts');
__emitWatchEventForTests(testDir, 'src/charlie-only.ts');
await waitFor(() => cg.getPendingFiles().some((p) => p.path === 'src/charlie-only.ts'));

const res = await handler.execute('codegraph_status', {});
Expand Down
Loading