Skip to content

Commit cf7db7c

Browse files
colbymchenryclaude
andauthored
fix(mcp): skip fs.watch on WSL2 /mnt drives that hang MCP startup (colbymchenry#199) (colbymchenry#210)
Recursive fs.watch on a WSL2 /mnt NTFS/9p mount walks the directory tree with every readdir/stat crossing the Windows boundary, stalling the event loop long enough to blow past opencode's 30s MCP handshake timeout so the tools never appear. This is the file-watcher half of the colbymchenry#172 fix, which moved the DB/WASM open off the handshake but left the watcher on the critical path. - Add watchDisabledReason() policy: CODEGRAPH_NO_WATCH (off) > CODEGRAPH_FORCE_WATCH (force on) > WSL2 + /mnt auto-detect (off). FileWatcher.start() and the MCP server both honor it; the server now logs why watching is off and how to refresh. - Add `codegraph serve --mcp --no-watch`. - When watching is off, init/install offer git sync hooks (post-commit, post-merge, post-checkout) that run `codegraph sync` in the background, or fall back to manual sync; either way the user is told the index stays frozen until re-synced. uninit removes the hooks. - Tests: watch-policy + git-hooks (idempotency, user-content preservation, core.hooksPath). Root-cause analysis and workaround by @mengfanbo123. Closes colbymchenry#199 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 79b9601 commit cf7db7c

10 files changed

Lines changed: 714 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
2020
the line number while the line-numbered arm answered with zero follow-up
2121
tool calls. Payload cost is small (~3-5%). Set
2222
`CODEGRAPH_EXPLORE_LINENUMS=0` to disable.
23+
- **MCP / watcher**: CodeGraph now skips the live file watcher on WSL2
24+
`/mnt/*` drives, where recursive `fs.watch` is slow enough to break MCP
25+
startup (see Fixed). When the watcher is off, `codegraph init` /
26+
`codegraph install` offer to keep the index fresh via git hooks
27+
(`post-commit`, `post-merge`, `post-checkout`) that run `codegraph sync`
28+
in the background — accept for automatic refresh on commit / pull /
29+
checkout, or decline and sync by hand. Either way you're told the index
30+
stays frozen until it's re-synced. New controls: `CODEGRAPH_NO_WATCH=1`
31+
(or `codegraph serve --mcp --no-watch`) forces the watcher off anywhere;
32+
`CODEGRAPH_FORCE_WATCH=1` overrides the WSL auto-detect when your `/mnt`
33+
setup is actually fast. `codegraph uninit` removes any hooks it installed.
2334

2435
### Changed
2536
- **MCP / explore**: `codegraph_explore` output is now adaptive to project
@@ -46,6 +57,19 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4657
Thanks to [@essopsp](https://github.com/essopsp) for the repro.
4758

4859
### Fixed
60+
- **MCP**: the server no longer hangs on startup under WSL2 when the project
61+
lives on an NTFS `/mnt/*` mount. Setting up the recursive file watcher
62+
there took tens of seconds — every directory read crosses the Windows/9p
63+
boundary — which blew past the host's initialization timeout (opencode's
64+
30s), so the codegraph tools silently never appeared, even on small
65+
projects. This is the file-watcher half of the
66+
[#172](https://github.com/colbymchenry/codegraph/issues/172) startup fix:
67+
that one moved the database/WASM open off the handshake, but the watcher
68+
setup was still on the critical path. CodeGraph now auto-skips the watcher
69+
on those mounts, with manual and git-hook sync fallbacks (see Added).
70+
Closes [#199](https://github.com/colbymchenry/codegraph/issues/199).
71+
Thanks to [@mengfanbo123](https://github.com/mengfanbo123) for the precise
72+
root-cause analysis and workaround.
4973
- **Installer (Claude Code)**: project-local installs (`Just this project`)
5074
now write the MCP server to `.mcp.json` in the project root — the file
5175
Claude Code actually reads for project-scoped servers. Previously they

__tests__/git-hooks.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Git Sync Hooks Tests
3+
*
4+
* Covers installing/removing the opt-in commit/merge/checkout hooks that
5+
* keep the index fresh when the live watcher is disabled (issue #199).
6+
* Exercises real git repos in temp dirs — no mocking.
7+
*/
8+
9+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
10+
import { execFileSync } from 'child_process';
11+
import * as fs from 'fs';
12+
import * as path from 'path';
13+
import * as os from 'os';
14+
import {
15+
installGitSyncHook,
16+
removeGitSyncHook,
17+
isSyncHookInstalled,
18+
isGitRepo,
19+
DEFAULT_SYNC_HOOKS,
20+
} from '../src/sync/git-hooks';
21+
22+
function gitInit(dir: string): void {
23+
execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'ignore' });
24+
}
25+
26+
function isExecutable(file: string): boolean {
27+
if (process.platform === 'win32') return true; // mode bits not meaningful
28+
return (fs.statSync(file).mode & 0o111) !== 0;
29+
}
30+
31+
describe('git sync hooks', () => {
32+
let repo: string;
33+
34+
beforeEach(() => {
35+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-githooks-'));
36+
});
37+
38+
afterEach(() => {
39+
if (fs.existsSync(repo)) fs.rmSync(repo, { recursive: true, force: true });
40+
});
41+
42+
it('installs all default hooks, executable, invoking codegraph sync', () => {
43+
gitInit(repo);
44+
const result = installGitSyncHook(repo);
45+
46+
expect(result.installed.sort()).toEqual([...DEFAULT_SYNC_HOOKS].sort());
47+
expect(result.skipped).toBeUndefined();
48+
49+
for (const hook of DEFAULT_SYNC_HOOKS) {
50+
const file = path.join(repo, '.git', 'hooks', hook);
51+
expect(fs.existsSync(file)).toBe(true);
52+
const body = fs.readFileSync(file, 'utf8');
53+
expect(body).toContain('codegraph sync');
54+
expect(body).toContain('command -v codegraph'); // no-op when not on PATH
55+
expect(isExecutable(file)).toBe(true);
56+
}
57+
expect(isSyncHookInstalled(repo)).toBe(true);
58+
});
59+
60+
it('is idempotent — re-install does not duplicate the block', () => {
61+
gitInit(repo);
62+
installGitSyncHook(repo);
63+
installGitSyncHook(repo);
64+
65+
const body = fs.readFileSync(path.join(repo, '.git', 'hooks', 'post-commit'), 'utf8');
66+
const occurrences = body.split('# >>> codegraph sync hook >>>').length - 1;
67+
expect(occurrences).toBe(1);
68+
});
69+
70+
it('preserves a pre-existing user hook and appends our block', () => {
71+
gitInit(repo);
72+
const file = path.join(repo, '.git', 'hooks', 'post-commit');
73+
fs.writeFileSync(file, '#!/bin/sh\necho "my custom hook"\n', { mode: 0o755 });
74+
75+
installGitSyncHook(repo, ['post-commit']);
76+
77+
const body = fs.readFileSync(file, 'utf8');
78+
expect(body).toContain('echo "my custom hook"');
79+
expect(body).toContain('codegraph sync');
80+
});
81+
82+
it('remove strips our block; deletes a hook that was only ours', () => {
83+
gitInit(repo);
84+
installGitSyncHook(repo, ['post-commit']);
85+
const file = path.join(repo, '.git', 'hooks', 'post-commit');
86+
expect(fs.existsSync(file)).toBe(true);
87+
88+
const result = removeGitSyncHook(repo, ['post-commit']);
89+
expect(result.installed).toEqual(['post-commit']);
90+
expect(fs.existsSync(file)).toBe(false); // was ours-only → deleted
91+
expect(isSyncHookInstalled(repo)).toBe(false);
92+
});
93+
94+
it('remove keeps user content when the hook is shared', () => {
95+
gitInit(repo);
96+
const file = path.join(repo, '.git', 'hooks', 'post-commit');
97+
fs.writeFileSync(file, '#!/bin/sh\necho "keep me"\n', { mode: 0o755 });
98+
installGitSyncHook(repo, ['post-commit']);
99+
100+
removeGitSyncHook(repo, ['post-commit']);
101+
102+
expect(fs.existsSync(file)).toBe(true);
103+
const body = fs.readFileSync(file, 'utf8');
104+
expect(body).toContain('echo "keep me"');
105+
expect(body).not.toContain('codegraph sync');
106+
});
107+
108+
it('honors core.hooksPath', () => {
109+
gitInit(repo);
110+
const customHooks = path.join(repo, '.husky');
111+
fs.mkdirSync(customHooks);
112+
execFileSync('git', ['config', 'core.hooksPath', '.husky'], { cwd: repo, stdio: 'ignore' });
113+
114+
const result = installGitSyncHook(repo, ['post-commit']);
115+
expect(result.hooksDir).toBe(customHooks);
116+
expect(fs.existsSync(path.join(customHooks, 'post-commit'))).toBe(true);
117+
// The default .git/hooks dir should NOT have received the hook.
118+
expect(fs.existsSync(path.join(repo, '.git', 'hooks', 'post-commit'))).toBe(false);
119+
});
120+
121+
it('skips cleanly when not a git repository', () => {
122+
expect(isGitRepo(repo)).toBe(false);
123+
const result = installGitSyncHook(repo);
124+
expect(result.installed).toEqual([]);
125+
expect(result.hooksDir).toBeNull();
126+
expect(result.skipped).toMatch(/not a git repository/);
127+
expect(isSyncHookInstalled(repo)).toBe(false);
128+
});
129+
});

__tests__/watch-policy.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Watch Policy Tests
3+
*
4+
* Covers the decision of whether the live file watcher runs, including the
5+
* WSL2 /mnt auto-detect and the env-var escape hatches (issue #199), plus
6+
* that FileWatcher.start() honors the decision.
7+
*/
8+
9+
import { describe, it, expect, afterEach, vi } from 'vitest';
10+
import * as fs from 'fs';
11+
import * as path from 'path';
12+
import * as os from 'os';
13+
import { watchDisabledReason } from '../src/sync/watch-policy';
14+
import { FileWatcher } from '../src/sync/watcher';
15+
import type { CodeGraphConfig } from '../src/types';
16+
17+
describe('watchDisabledReason', () => {
18+
it('returns a reason when CODEGRAPH_NO_WATCH=1', () => {
19+
const reason = watchDisabledReason('/home/me/project', {
20+
env: { CODEGRAPH_NO_WATCH: '1' },
21+
isWsl: false,
22+
});
23+
expect(reason).toBeTruthy();
24+
expect(reason).toMatch(/CODEGRAPH_NO_WATCH/);
25+
});
26+
27+
it('auto-disables on a WSL2 /mnt drive', () => {
28+
const reason = watchDisabledReason('/mnt/d/code/project', { env: {}, isWsl: true });
29+
expect(reason).toBeTruthy();
30+
expect(reason).toMatch(/mnt/);
31+
});
32+
33+
it('does NOT disable on a native WSL home path', () => {
34+
expect(watchDisabledReason('/home/me/project', { env: {}, isWsl: true })).toBeNull();
35+
});
36+
37+
it('does NOT disable on /mnt when not running under WSL', () => {
38+
// A real Linux box may legitimately have a fast /mnt mount.
39+
expect(watchDisabledReason('/mnt/d/code/project', { env: {}, isWsl: false })).toBeNull();
40+
});
41+
42+
it('does NOT treat /mnt/wsl (fast Linux mount) as a Windows drive', () => {
43+
expect(watchDisabledReason('/mnt/wsl/project', { env: {}, isWsl: true })).toBeNull();
44+
});
45+
46+
it('CODEGRAPH_FORCE_WATCH=1 overrides WSL auto-detect', () => {
47+
const reason = watchDisabledReason('/mnt/d/code/project', {
48+
env: { CODEGRAPH_FORCE_WATCH: '1' },
49+
isWsl: true,
50+
});
51+
expect(reason).toBeNull();
52+
});
53+
54+
it('CODEGRAPH_NO_WATCH wins over CODEGRAPH_FORCE_WATCH', () => {
55+
const reason = watchDisabledReason('/home/me/project', {
56+
env: { CODEGRAPH_NO_WATCH: '1', CODEGRAPH_FORCE_WATCH: '1' },
57+
isWsl: false,
58+
});
59+
expect(reason).toBeTruthy();
60+
});
61+
});
62+
63+
describe('FileWatcher honors the watch policy', () => {
64+
let testDir: string;
65+
66+
const baseConfig: CodeGraphConfig = {
67+
version: 1,
68+
rootDir: '.',
69+
include: ['**/*.ts'],
70+
exclude: ['**/node_modules/**'],
71+
languages: [],
72+
frameworks: [],
73+
maxFileSize: 1024 * 1024,
74+
extractDocstrings: true,
75+
trackCallSites: true,
76+
};
77+
78+
afterEach(() => {
79+
delete process.env.CODEGRAPH_NO_WATCH;
80+
if (testDir && fs.existsSync(testDir)) {
81+
fs.rmSync(testDir, { recursive: true, force: true });
82+
}
83+
});
84+
85+
it('does not start when CODEGRAPH_NO_WATCH=1', () => {
86+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-nowatch-'));
87+
process.env.CODEGRAPH_NO_WATCH = '1';
88+
89+
const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
90+
const watcher = new FileWatcher(testDir, baseConfig, syncFn);
91+
92+
expect(watcher.start()).toBe(false);
93+
expect(watcher.isActive()).toBe(false);
94+
});
95+
});

src/bin/codegraph.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,10 @@ program
415415
clack.log.success(`${target.displayName}: ${file.action} ${file.path}`);
416416
}
417417
} catch { /* non-fatal */ }
418+
try {
419+
const { offerWatchFallback } = await import('../installer');
420+
await offerWatchFallback(clack, projectPath);
421+
} catch { /* non-fatal */ }
418422
clack.outro('');
419423
return;
420424
}
@@ -459,6 +463,11 @@ program
459463
clack.log.info('Run "codegraph index" to index the project');
460464
}
461465

466+
try {
467+
const { offerWatchFallback } = await import('../installer');
468+
await offerWatchFallback(clack, projectPath);
469+
} catch { /* non-fatal */ }
470+
462471
clack.outro('Done');
463472
cg.destroy();
464473
} catch (err) {
@@ -505,6 +514,15 @@ program
505514
const cg = CodeGraph.openSync(projectPath);
506515
cg.uninitialize();
507516

517+
// Clean up any git sync hooks we installed (no-op if none / not a repo).
518+
try {
519+
const { removeGitSyncHook } = await import('../sync/git-hooks');
520+
const removed = removeGitSyncHook(projectPath);
521+
if (removed.installed.length > 0) {
522+
info(`Removed git ${removed.installed.join(', ')} sync hook${removed.installed.length > 1 ? 's' : ''}`);
523+
}
524+
} catch { /* non-fatal */ }
525+
508526
success(`Removed CodeGraph from ${projectPath}`);
509527
} catch (err) {
510528
error(`Failed to uninitialize: ${err instanceof Error ? err.message : String(err)}`);
@@ -1085,9 +1103,16 @@ program
10851103
.description('Start CodeGraph as an MCP server for AI assistants')
10861104
.option('-p, --path <path>', 'Project path (optional for MCP mode, uses rootUri from client)')
10871105
.option('--mcp', 'Run as MCP server (stdio transport)')
1088-
.action(async (options: { path?: string; mcp?: boolean }) => {
1106+
.option('--no-watch', 'Disable the file watcher (no auto-sync; useful on slow filesystems like WSL2 /mnt drives)')
1107+
.action(async (options: { path?: string; mcp?: boolean; watch?: boolean }) => {
10891108
const projectPath = options.path ? resolveProjectPath(options.path) : undefined;
10901109

1110+
// Commander sets watch=false when --no-watch is passed. Route it through
1111+
// the same env-var chokepoint the watcher and MCP server already honor.
1112+
if (options.watch === false) {
1113+
process.env.CODEGRAPH_NO_WATCH = '1';
1114+
}
1115+
10911116
try {
10921117
if (options.mcp) {
10931118
// Start MCP server - it handles initialization lazily based on rootUri from client

0 commit comments

Comments
 (0)