fix(mcp): skip fs.watch on WSL2 /mnt drives that hang MCP startup (#199) (#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 #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 #199

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-05-20 10:32:08 -05:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 79b9601aae
commit cf7db7cb98
10 changed files with 714 additions and 5 deletions
+208
View File
@@ -0,0 +1,208 @@
/**
* Git Sync Hooks
*
* When the live file watcher is disabled (e.g. on WSL2 `/mnt/*` drives,
* see watch-policy.ts), the CodeGraph index would otherwise go stale until
* the user runs `codegraph sync` by hand. As an opt-in alternative, we can
* install git hooks that refresh the index after the operations that change
* files on disk: commit, merge (covers `git pull`), and checkout.
*
* The hooks run `codegraph sync` in the background so they never block git,
* and are guarded by `command -v codegraph` so they no-op cleanly when the
* CLI isn't on PATH. Our snippet is delimited by marker comments so install
* is idempotent and removal preserves any user-authored hook content.
*/
import * as fs from 'fs';
import * as path from 'path';
import { execFileSync } from 'child_process';
const MARKER_BEGIN = '# >>> codegraph sync hook >>>';
const MARKER_END = '# <<< codegraph sync hook <<<';
export type GitHookName = 'post-commit' | 'post-merge' | 'post-checkout';
/** Hooks installed by default: commit, merge (git pull), and checkout. */
export const DEFAULT_SYNC_HOOKS: GitHookName[] = ['post-commit', 'post-merge', 'post-checkout'];
export interface GitHookResult {
/** Hook names that were created or updated. */
installed: GitHookName[];
/** Resolved hooks directory, or null when not a git repo. */
hooksDir: string | null;
/** Reason nothing happened (e.g. not a git repository). */
skipped?: string;
}
/**
* Whether `projectRoot` is inside a git working tree. Returns false if git
* isn't installed or the path isn't a repo.
*/
export function isGitRepo(projectRoot: string): boolean {
try {
const out = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
cwd: projectRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
return out === 'true';
} catch {
return false;
}
}
/**
* Resolve the git hooks directory for a project, honoring `core.hooksPath`
* and git worktrees. Returns an absolute path, or null when not a repo.
*/
function gitHooksDir(projectRoot: string): string | null {
try {
const out = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], {
cwd: projectRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
if (!out) return null;
return path.isAbsolute(out) ? out : path.resolve(projectRoot, out);
} catch {
return null;
}
}
/** The shell snippet (between markers) injected into each hook. */
function markerBlock(): string {
return [
MARKER_BEGIN,
'# Keeps the CodeGraph index fresh while the live file watcher is off',
'# (e.g. WSL2 /mnt drives). Runs in the background so it never blocks git.',
'# Managed by codegraph; remove with `codegraph uninit` or delete this block.',
'if command -v codegraph >/dev/null 2>&1; then',
' ( codegraph sync >/dev/null 2>&1 & ) >/dev/null 2>&1',
'fi',
MARKER_END,
].join('\n');
}
/** Remove our marker block (and the marker lines) from hook content. */
function stripMarkerBlock(content: string): string {
const lines = content.split('\n');
const kept: string[] = [];
let inBlock = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === MARKER_BEGIN) { inBlock = true; continue; }
if (trimmed === MARKER_END) { inBlock = false; continue; }
if (!inBlock) kept.push(line);
}
return kept.join('\n');
}
/** Whether a hook body is just a shebang / blank lines (i.e. only ever ours). */
function isEffectivelyEmpty(content: string): boolean {
return content
.split('\n')
.map((l) => l.trim())
.every((l) => l.length === 0 || l.startsWith('#!'));
}
function chmodExecutable(file: string): void {
try {
fs.chmodSync(file, 0o755);
} catch {
/* chmod is a no-op / unsupported on some platforms (e.g. Windows) */
}
}
/**
* Install (or update) the CodeGraph sync hooks in a git repository.
* Idempotent: re-running replaces our marker block rather than duplicating
* it, and any user-authored hook content is preserved.
*/
export function installGitSyncHook(
projectRoot: string,
hooks: GitHookName[] = DEFAULT_SYNC_HOOKS,
): GitHookResult {
const hooksDir = gitHooksDir(projectRoot);
if (!hooksDir) {
return { installed: [], hooksDir: null, skipped: 'not a git repository' };
}
try {
fs.mkdirSync(hooksDir, { recursive: true });
} catch {
return { installed: [], hooksDir, skipped: 'could not access the git hooks directory' };
}
const block = markerBlock();
const installed: GitHookName[] = [];
for (const hook of hooks) {
const file = path.join(hooksDir, hook);
let content: string;
if (fs.existsSync(file)) {
// Strip any prior block, then re-append the current one.
const base = stripMarkerBlock(fs.readFileSync(file, 'utf8')).replace(/\s*$/, '');
content = base.length > 0
? `${base}\n\n${block}\n`
: `#!/bin/sh\n${block}\n`;
} else {
content = `#!/bin/sh\n${block}\n`;
}
fs.writeFileSync(file, content);
chmodExecutable(file);
installed.push(hook);
}
return { installed, hooksDir };
}
/**
* Remove the CodeGraph sync hooks. Strips only our marker block; deletes the
* hook file entirely when nothing but a shebang remains, otherwise rewrites
* the user's content untouched.
*/
export function removeGitSyncHook(
projectRoot: string,
hooks: GitHookName[] = DEFAULT_SYNC_HOOKS,
): GitHookResult {
const hooksDir = gitHooksDir(projectRoot);
if (!hooksDir) {
return { installed: [], hooksDir: null, skipped: 'not a git repository' };
}
const removed: GitHookName[] = [];
for (const hook of hooks) {
const file = path.join(hooksDir, hook);
if (!fs.existsSync(file)) continue;
const original = fs.readFileSync(file, 'utf8');
if (!original.includes(MARKER_BEGIN)) continue;
const stripped = stripMarkerBlock(original);
if (isEffectivelyEmpty(stripped)) {
fs.unlinkSync(file);
} else {
fs.writeFileSync(file, `${stripped.replace(/\s*$/, '')}\n`);
chmodExecutable(file);
}
removed.push(hook);
}
return { installed: removed, hooksDir };
}
/** Whether any CodeGraph sync hook is currently installed. */
export function isSyncHookInstalled(
projectRoot: string,
hooks: GitHookName[] = DEFAULT_SYNC_HOOKS,
): boolean {
const hooksDir = gitHooksDir(projectRoot);
if (!hooksDir) return false;
return hooks.some((hook) => {
const file = path.join(hooksDir, hook);
return fs.existsSync(file) && fs.readFileSync(file, 'utf8').includes(MARKER_BEGIN);
});
}
+12
View File
@@ -6,8 +6,20 @@
*
* Components:
* - FileWatcher: Debounced fs.watch that auto-triggers sync on file changes
* - Watch policy: decides when the watcher must be disabled (e.g. WSL2 /mnt)
* - Git sync hooks: opt-in commit/merge/checkout hooks when watching is off
* - Content hashing for change detection (in extraction module)
* - Incremental reindexing (in extraction module)
*/
export { FileWatcher, WatchOptions } from './watcher';
export { watchDisabledReason, detectWsl } from './watch-policy';
export {
installGitSyncHook,
removeGitSyncHook,
isSyncHookInstalled,
isGitRepo,
DEFAULT_SYNC_HOOKS,
type GitHookName,
type GitHookResult,
} from './git-hooks';
+104
View File
@@ -0,0 +1,104 @@
/**
* Watch Policy
*
* Decides whether the live file watcher should run for a given project.
*
* Native recursive `fs.watch` is pathologically slow on WSL2 `/mnt/*`
* drives (NTFS exposed over the 9p/drvfs bridge): setting up the recursive
* watch walks the directory tree, and every readdir/stat crosses the
* Windows boundary. Inside an MCP server this stalls the event loop during
* startup long enough to blow past host handshake timeouts (opencode's 30s),
* so the tools never appear. See issue #199.
*
* This module centralizes the on/off decision so the watcher, the MCP
* server (for diagnostics), and the installer all agree.
*/
import * as fs from 'fs';
import { normalizePath } from '../utils';
let wslChecked = false;
let wslValue = false;
/**
* Detect whether the current process is running under WSL (Windows
* Subsystem for Linux). Result is cached after the first call.
*
* Checks the WSL-specific env vars first (no I/O), then falls back to
* `/proc/version`, which contains "microsoft" on WSL kernels.
*/
export function detectWsl(): boolean {
if (wslChecked) return wslValue;
wslChecked = true;
if (process.platform !== 'linux') {
wslValue = false;
return wslValue;
}
if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) {
wslValue = true;
return wslValue;
}
try {
const version = fs.readFileSync('/proc/version', 'utf8').toLowerCase();
wslValue = version.includes('microsoft') || version.includes('wsl');
} catch {
wslValue = false;
}
return wslValue;
}
/**
* True for WSL Windows-drive mounts like `/mnt/c` or `/mnt/d/project`.
* Deliberately matches only single-letter drive mounts, so genuinely fast
* Linux mounts such as `/mnt/wsl/...` are not flagged.
*/
function isWindowsDriveMount(projectRoot: string): boolean {
return /^\/mnt\/[a-z](\/|$)/i.test(normalizePath(projectRoot));
}
/**
* Inputs that can be overridden in tests so the decision is deterministic
* without touching real env vars or `/proc/version`.
*/
export interface WatchProbe {
/** Defaults to `process.env`. */
env?: NodeJS.ProcessEnv;
/** Defaults to `detectWsl()`. */
isWsl?: boolean;
}
/**
* Decide whether the file watcher should be disabled for a project, and why.
*
* Returns a short human-readable reason when watching should be skipped, or
* `null` when it should run normally.
*
* Precedence (first match wins):
* 1. `CODEGRAPH_NO_WATCH=1` → off (explicit opt-out always wins)
* 2. `CODEGRAPH_FORCE_WATCH=1` → on (overrides auto-detection)
* 3. WSL2 + `/mnt/*` drive → off (recursive fs.watch is too slow; #199)
*/
export function watchDisabledReason(projectRoot: string, probe: WatchProbe = {}): string | null {
const env = probe.env ?? process.env;
if (env.CODEGRAPH_NO_WATCH === '1') {
return 'CODEGRAPH_NO_WATCH=1 is set';
}
if (env.CODEGRAPH_FORCE_WATCH === '1') {
return null;
}
const isWsl = probe.isWsl ?? detectWsl();
if (isWsl && isWindowsDriveMount(projectRoot)) {
return 'project is on a WSL2 /mnt/ drive, where recursive fs.watch is too slow to be reliable';
}
return null;
}
/** Test-only: reset the cached WSL detection. */
export function __resetWslCacheForTests(): void {
wslChecked = false;
wslValue = false;
}
+11
View File
@@ -13,6 +13,7 @@ import { CodeGraphConfig } from '../types';
import { shouldIncludeFile } from '../extraction';
import { logDebug, logWarn } from '../errors';
import { normalizePath } from '../utils';
import { watchDisabledReason } from './watch-policy';
/**
* Options for the file watcher
@@ -82,6 +83,16 @@ export class FileWatcher {
if (this.watcher) return true; // Already watching
this.stopped = false;
// Some environments make recursive fs.watch unusable — most notably WSL2
// /mnt/ drives, where setup blocks long enough to break MCP startup
// handshakes (issue #199). Skip watching there; callers fall back to
// manual `codegraph sync` or the git sync hooks.
const disabledReason = watchDisabledReason(this.projectRoot);
if (disabledReason) {
logDebug('File watcher disabled', { reason: disabledReason, projectRoot: this.projectRoot });
return false;
}
try {
this.watcher = fs.watch(
this.projectRoot,