Files
codegraph/src/sync/watch-policy.ts
T
cf7db7cb98 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>
2026-05-20 10:32:08 -05:00

105 lines
3.2 KiB
TypeScript

/**
* 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;
}