watch-policy.ts 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. /**
  2. * Watch Policy
  3. *
  4. * Decides whether the live file watcher should run for a given project.
  5. *
  6. * Native recursive `fs.watch` is pathologically slow on WSL2 `/mnt/*`
  7. * drives (NTFS exposed over the 9p/drvfs bridge): setting up the recursive
  8. * watch walks the directory tree, and every readdir/stat crosses the
  9. * Windows boundary. Inside an MCP server this stalls the event loop during
  10. * startup long enough to blow past host handshake timeouts (opencode's 30s),
  11. * so the tools never appear. See issue #199.
  12. *
  13. * This module centralizes the on/off decision so the watcher, the MCP
  14. * server (for diagnostics), and the installer all agree.
  15. */
  16. import * as fs from 'fs';
  17. import { normalizePath } from '../utils';
  18. let wslChecked = false;
  19. let wslValue = false;
  20. /**
  21. * Detect whether the current process is running under WSL (Windows
  22. * Subsystem for Linux). Result is cached after the first call.
  23. *
  24. * Checks the WSL-specific env vars first (no I/O), then falls back to
  25. * `/proc/version`, which contains "microsoft" on WSL kernels.
  26. */
  27. export function detectWsl(): boolean {
  28. if (wslChecked) return wslValue;
  29. wslChecked = true;
  30. if (process.platform !== 'linux') {
  31. wslValue = false;
  32. return wslValue;
  33. }
  34. if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) {
  35. wslValue = true;
  36. return wslValue;
  37. }
  38. try {
  39. const version = fs.readFileSync('/proc/version', 'utf8').toLowerCase();
  40. wslValue = version.includes('microsoft') || version.includes('wsl');
  41. } catch {
  42. wslValue = false;
  43. }
  44. return wslValue;
  45. }
  46. /**
  47. * True for WSL Windows-drive mounts like `/mnt/c` or `/mnt/d/project`.
  48. * Deliberately matches only single-letter drive mounts, so genuinely fast
  49. * Linux mounts such as `/mnt/wsl/...` are not flagged.
  50. */
  51. function isWindowsDriveMount(projectRoot: string): boolean {
  52. return /^\/mnt\/[a-z](\/|$)/i.test(normalizePath(projectRoot));
  53. }
  54. /**
  55. * Inputs that can be overridden in tests so the decision is deterministic
  56. * without touching real env vars or `/proc/version`.
  57. */
  58. export interface WatchProbe {
  59. /** Defaults to `process.env`. */
  60. env?: NodeJS.ProcessEnv;
  61. /** Defaults to `detectWsl()`. */
  62. isWsl?: boolean;
  63. }
  64. /**
  65. * Decide whether the file watcher should be disabled for a project, and why.
  66. *
  67. * Returns a short human-readable reason when watching should be skipped, or
  68. * `null` when it should run normally.
  69. *
  70. * Precedence (first match wins):
  71. * 1. `CODEGRAPH_NO_WATCH=1` → off (explicit opt-out always wins)
  72. * 2. `CODEGRAPH_FORCE_WATCH=1` → on (overrides auto-detection)
  73. * 3. WSL2 + `/mnt/*` drive → off (recursive fs.watch is too slow; #199)
  74. */
  75. export function watchDisabledReason(projectRoot: string, probe: WatchProbe = {}): string | null {
  76. const env = probe.env ?? process.env;
  77. if (env.CODEGRAPH_NO_WATCH === '1') {
  78. return 'CODEGRAPH_NO_WATCH=1 is set';
  79. }
  80. if (env.CODEGRAPH_FORCE_WATCH === '1') {
  81. return null;
  82. }
  83. const isWsl = probe.isWsl ?? detectWsl();
  84. if (isWsl && isWindowsDriveMount(projectRoot)) {
  85. return 'project is on a WSL2 /mnt/ drive, where recursive fs.watch is too slow to be reliable';
  86. }
  87. return null;
  88. }
  89. /** Test-only: reset the cached WSL detection. */
  90. export function __resetWslCacheForTests(): void {
  91. wslChecked = false;
  92. wslValue = false;
  93. }