watcher.ts 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989
  1. /**
  2. * File Watcher
  3. *
  4. * Watches the project directory for file changes and triggers debounced sync
  5. * operations to keep the code graph up-to-date.
  6. *
  7. * Uses Node's built-in `fs.watch` directly (no third-party watcher, no native
  8. * addon) with a per-platform strategy chosen to keep the open-descriptor /
  9. * kernel-watch cost BOUNDED rather than growing with the number of files:
  10. *
  11. * - macOS / Windows: a SINGLE recursive `fs.watch(root, {recursive:true})`.
  12. * libuv maps this to one FSEvents stream (macOS) / one
  13. * ReadDirectoryChangesW handle (Windows), so it costs O(1) descriptors no
  14. * matter how large the tree. This is the fix for the macOS file-table
  15. * exhaustion (#644 / #496 / #555 / #628): the previous watcher held one
  16. * open fd PER WATCHED FILE on macOS (tens of thousands of REG fds), which
  17. * exhausted `kern.maxfiles` and crashed unrelated processes system-wide.
  18. *
  19. * - Linux: recursive `fs.watch` is unsupported, so we watch each (non-ignored)
  20. * DIRECTORY with one inotify watch — O(directories), NOT O(files). New
  21. * directories are picked up dynamically and an overall watch cap bounds
  22. * inotify usage on pathological monorepos (#579). A single inotify watch on
  23. * a directory already reports create/modify/delete for its children, so
  24. * per-file watches are never needed.
  25. *
  26. * Excluded trees (node_modules/, dist/, .git/, …) are filtered via the
  27. * indexer's `buildScopeIgnore` (built-in default-ignore dirs + the project's
  28. * .gitignore) — on Linux they're never descended into (so they cost no watch),
  29. * and on macOS/Windows the single recursive stream still covers them but their
  30. * events are dropped before any sync is scheduled. Either way the watcher's
  31. * scope matches the indexer's (#276 / #407).
  32. */
  33. import * as fs from 'fs';
  34. import * as path from 'path';
  35. import { isSourceFile, buildScopeIgnore, type ScopeIgnore } from '../extraction';
  36. import { loadExtensionOverrides } from '../project-config';
  37. import { logDebug, logWarn } from '../errors';
  38. import { normalizePath } from '../utils';
  39. import { isCodeGraphDataDir } from '../directory';
  40. import { watchDisabledReason } from './watch-policy';
  41. /**
  42. * Number of consecutive lock-contention retries the watcher tolerates before
  43. * it gives up and degrades auto-sync. Brief contention (another writer for a
  44. * few cycles) stays under this; a long-lived external writer crosses it.
  45. */
  46. const MAX_LOCK_RETRIES = 5;
  47. /**
  48. * Number of consecutive GENERIC (non-lock) sync failures the watcher tolerates
  49. * before it degrades auto-sync. A deterministic failure — a tree-sitter
  50. * extractor that crashes on one file, DB corruption, `SQLITE_FULL`, an OOM in
  51. * batched resolution — recurs every debounce cycle, so left unbounded it would
  52. * retry forever (log + work spam) while the auto-update guarantee is silently
  53. * dead (#1127). A single clean sync resets the streak, so a transient hiccup
  54. * that recovers within the budget never degrades.
  55. */
  56. const MAX_SYNC_FAILURE_RETRIES = 5;
  57. /** Cap on the exponential retry backoff (either mode) so it never sleeps absurdly long. */
  58. const MAX_RETRY_BACKOFF_MS = 30_000;
  59. /**
  60. * Adaptive debounce: a pending set this small fires after the quick quiet
  61. * window instead of the full debounce — a lone save (or editor + test file
  62. * pair) syncs near-instantly, while larger bursts keep the full window and
  63. * coalesce exactly as before.
  64. */
  65. const QUICK_SYNC_MAX_PENDING = 2;
  66. const QUICK_SYNC_QUIET_MS = 300;
  67. /**
  68. * Scoped-sync ceiling: above this many pending files a full scan-diff is
  69. * simpler and comparably fast (a branch checkout emits thousands of events),
  70. * and it self-heals anything event coalescing dropped along the way.
  71. */
  72. const SCOPED_SYNC_MAX_PENDING = 500;
  73. /** Actionable degrade message; both exhaustion paths share it verbatim. */
  74. const EXHAUSTION_REASON =
  75. 'OS watch/file limit exhausted; auto-sync disabled. Run `codegraph sync` ' +
  76. '(or install git sync hooks) to refresh the graph after changes.';
  77. /**
  78. * Actionable, NON-fatal warning for Linux inotify watch-count exhaustion.
  79. * Unlike {@link EXHAUSTION_REASON} this does not disable the watcher — the
  80. * watches already installed keep working — so it names the exact kernel knob to
  81. * raise instead.
  82. */
  83. const INOTIFY_LIMIT_REASON =
  84. 'Linux inotify watch limit reached (fs.inotify.max_user_watches); live ' +
  85. 'watching now covers only part of the project, so edits in unwatched ' +
  86. 'directories will not auto-sync. Raise the limit (e.g. `sudo sysctl ' +
  87. 'fs.inotify.max_user_watches=1048576`, persisted in /etc/sysctl.d) and ' +
  88. 'restart, or run `codegraph sync` (or install git sync hooks) to refresh.';
  89. /**
  90. * True when an error is OS watch/file-descriptor exhaustion (EMFILE/ENFILE).
  91. * Prefers the structured `err.code`; falls back to message matching ONLY when
  92. * no code is present (some platforms surface a bare Error from `fs.watch`).
  93. */
  94. function isWatchResourceExhaustion(err: unknown): boolean {
  95. const e = err as NodeJS.ErrnoException | undefined;
  96. if (e?.code === 'EMFILE' || e?.code === 'ENFILE') return true;
  97. if (!e?.code && e?.message) {
  98. return /EMFILE|ENFILE|too many open files/i.test(e.message);
  99. }
  100. return false;
  101. }
  102. /**
  103. * True when an error is Linux inotify *watch-count* exhaustion. `fs.watch`
  104. * surfaces a hit `fs.inotify.max_user_watches` as ENOSPC ("no space" = no watch
  105. * descriptors left, NOT disk space). This only arises on the Linux
  106. * per-directory path; it is non-fatal (raise the limit and partial watching
  107. * keeps working), so it warns rather than degrading.
  108. */
  109. function isInotifyWatchExhaustion(err: unknown): boolean {
  110. return (err as NodeJS.ErrnoException | undefined)?.code === 'ENOSPC';
  111. }
  112. /**
  113. * Native recursive `fs.watch` is only reliable on macOS and Windows; on Linux
  114. * (and AIX) it throws `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`. We branch on this
  115. * to pick the recursive vs per-directory strategy.
  116. */
  117. function supportsRecursiveWatch(): boolean {
  118. return process.platform === 'darwin' || process.platform === 'win32';
  119. }
  120. /**
  121. * Indirection over `fs.watch` so tests can inject a fake that throws or emits
  122. * `EMFILE`/`ENFILE` deterministically (real watch-resource exhaustion can't be
  123. * provoked reliably, and `fs.watch` is a non-configurable property so it can't
  124. * be spied). Production always uses the real `fs.watch`.
  125. */
  126. type WatchFn = typeof fs.watch;
  127. let watchImpl: WatchFn = fs.watch;
  128. /** @internal Test-only seam to inject a fake fs.watch implementation. */
  129. export function __setFsWatchForTests(fn: WatchFn | null): void {
  130. watchImpl = fn ?? fs.watch;
  131. }
  132. /**
  133. * Upper bound on simultaneously-watched directories on the Linux per-directory
  134. * path. Each is one inotify watch; the kernel's `fs.inotify.max_user_watches`
  135. * is the hard limit (commonly 8k–128k). We stop adding watches past this and
  136. * log once — partial live-watch (with `codegraph sync` as the backstop) is far
  137. * better than exhausting the user's inotify budget and breaking watching
  138. * system-wide (#579). Tunable via CODEGRAPH_MAX_DIR_WATCHES.
  139. */
  140. const DEFAULT_MAX_DIR_WATCHES = 50_000;
  141. function maxDirWatches(): number {
  142. const raw = process.env.CODEGRAPH_MAX_DIR_WATCHES;
  143. if (raw && /^\d+$/.test(raw)) {
  144. const n = Number(raw);
  145. if (n > 0) return n;
  146. }
  147. return DEFAULT_MAX_DIR_WATCHES;
  148. }
  149. /**
  150. * Test seam (see {@link __emitWatchEventForTests}). Maps a watcher's project
  151. * root to its live instance so tests can synthesize a change event
  152. * deterministically — real fs.watch delivery latency races under parallel
  153. * vitest (the reason the previous chokidar mock existed). Only populated under
  154. * a test runner, so production carries no bookkeeping or retained references.
  155. */
  156. const liveWatchersForTests = new Map<string, FileWatcher>();
  157. const IS_TEST_RUNTIME = !!(process.env.VITEST || process.env.NODE_ENV === 'test');
  158. /**
  159. * Options for the file watcher
  160. */
  161. export interface WatchOptions {
  162. /**
  163. * Debounce delay in milliseconds.
  164. * After the last file change, wait this long before triggering sync.
  165. * Default: 2000ms
  166. */
  167. debounceMs?: number;
  168. /**
  169. * Callback when a sync completes (for logging/diagnostics).
  170. */
  171. onSyncComplete?: (result: { filesChanged: number; durationMs: number }) => void;
  172. /**
  173. * Callback when a sync errors (for logging/diagnostics).
  174. */
  175. onSyncError?: (error: Error) => void;
  176. /**
  177. * Callback fired ONCE when live watching degrades permanently and auto-sync
  178. * is disabled — OS watch-resource exhaustion (EMFILE/ENFILE), a write lock
  179. * held past the retry budget, or a generic sync failure that persists past
  180. * the retry budget (#1127). The string is an actionable, human-readable
  181. * reason. Lets a host (MCP server, daemon, CLI) tell the user that the index
  182. * will no longer auto-update instead of silently serving stale results.
  183. */
  184. onDegraded?: (reason: string) => void;
  185. /**
  186. * Test-only. When true, `start()` installs NO OS-level fs.watch — the
  187. * watcher is "inert" and only the {@link __emitWatchEventForTests} /
  188. * {@link FileWatcher.ingestEventForTests} seam drives its pipeline. This
  189. * restores the deterministic, OS-free behavior the unit tests need (real
  190. * FSEvents/inotify delivery races under parallel vitest). Production never
  191. * sets it.
  192. */
  193. inertForTests?: boolean;
  194. }
  195. /**
  196. * Thrown by a `syncFn` to signal that the underlying sync couldn't acquire
  197. * the cross-process write lock (#449). The watcher treats this as "no
  198. * progress" — preserves `pendingFiles`, skips `onSyncComplete`, and the
  199. * `finally` block reschedules. Quiet (debug-only) because a long-running
  200. * external indexer can hit this every debounce cycle.
  201. */
  202. export class LockUnavailableError extends Error {
  203. constructor(message = 'CodeGraph file lock unavailable; another process is writing') {
  204. super(message);
  205. this.name = 'LockUnavailableError';
  206. }
  207. }
  208. /**
  209. * Per-file pending entry — tracks a source file the watcher saw an event for
  210. * but hasn't yet synced into the index. Exposed via {@link FileWatcher.getPendingFiles}
  211. * so MCP tool responses can mark stale results without forcing a wait.
  212. */
  213. export interface PendingFile {
  214. /** Project-relative POSIX path (e.g. "src/foo.ts"). */
  215. path: string;
  216. /** Wall-clock ms at the first event we saw for this path since the last sync. */
  217. firstSeenMs: number;
  218. /** Wall-clock ms at the most recent event we saw for this path. */
  219. lastSeenMs: number;
  220. /**
  221. * True when a sync is currently in flight that began AFTER this file's most
  222. * recent event — i.e. the next successful sync will pick it up. False when
  223. * the file is still in the debounce window (no sync running yet).
  224. */
  225. indexing: boolean;
  226. }
  227. /**
  228. * FileWatcher monitors a project directory for changes and triggers
  229. * debounced sync operations via a provided callback.
  230. *
  231. * Design goals:
  232. * - Bounded resource usage: O(1) descriptors on macOS/Windows (one recursive
  233. * watch), O(directories) inotify watches on Linux — never O(files), which
  234. * was the system-crashing fd leak on macOS (#644/#496/#555/#628).
  235. * - Debounced to avoid thrashing on rapid saves
  236. * - Filters to supported source files by extension
  237. * - Ignores .codegraph/ and .git/ regardless of .gitignore
  238. * - Tracks per-file pending state so MCP tools can flag stale results
  239. * without blocking on a sync (issue #403)
  240. */
  241. export class FileWatcher {
  242. /** macOS/Windows: the single recursive watcher. Null on Linux. */
  243. private recursiveWatcher: fs.FSWatcher | null = null;
  244. /** Linux: one watcher per watched directory (keyed by absolute path). */
  245. private dirWatchers = new Map<string, fs.FSWatcher>();
  246. /** Set once the per-directory watch cap is hit, so we log only once. */
  247. private dirCapWarned = false;
  248. /**
  249. * Set once the Linux inotify watch limit (ENOSPC) is hit. Double duty: we
  250. * warn only once, AND we stop attempting new directory watches for the rest
  251. * of the session — once the kernel budget is exhausted every further
  252. * `inotify_add_watch` fails too, so trying the rest of the tree is pure
  253. * waste. NON-fatal (does not degrade): installed watches keep working.
  254. */
  255. private inotifyLimitWarned = false;
  256. /**
  257. * One-way latch: the reason live watching was permanently disabled at runtime
  258. * (watch-resource exhaustion, lock contention past the retry budget, or a
  259. * persistent generic sync failure past the retry budget), or null while
  260. * healthy. Set by {@link degrade}; cleared only by a fresh start().
  261. */
  262. private degradedReason: string | null = null;
  263. /** Consecutive lock-contention retries for watcher-triggered syncs. */
  264. private lockRetryCount = 0;
  265. /** Consecutive generic (non-lock) sync failures; reset only by a clean sync. */
  266. private syncFailureRetryCount = 0;
  267. /** Test-only inert mode: started, but with no OS watcher installed. */
  268. private inert = false;
  269. private debounceTimer: ReturnType<typeof setTimeout> | null = null;
  270. /**
  271. * True when the pending set does NOT exactly describe the change (a
  272. * directory removal's children are unknown from the event, #1285) — the
  273. * next sync must be a full scan-diff. Cleared only after a successful FULL
  274. * sync reconciles the tree.
  275. */
  276. private needsFullScan = false;
  277. /**
  278. * Files seen by the watcher since the last successful sync — populated on
  279. * every change event, cleared at the start of a sync, and re-populated by
  280. * events that arrive mid-sync (or restored on sync failure). Keyed by the
  281. * same project-relative POSIX path the rest of the codebase uses, so a
  282. * caller can intersect tool-response file paths against this map cheaply.
  283. */
  284. private pendingFiles = new Map<string, { firstSeenMs: number; lastSeenMs: number }>();
  285. /**
  286. * Wall-clock ms at which the in-flight sync began. Combined with
  287. * {@link pendingFiles}'s `lastSeenMs`, this distinguishes "still in the
  288. * debounce window" (lastSeen > syncStarted, sync hasn't started yet for
  289. * this edit) from "currently being indexed" (lastSeen <= syncStarted).
  290. */
  291. private syncStartedMs = 0;
  292. private syncing = false;
  293. private stopped = false;
  294. /**
  295. * True once the initial watch set is established. Unlike the previous
  296. * chokidar implementation there is no asynchronous initial "crawl" emitting
  297. * an `add` per existing file — `fs.watch` only reports changes from the
  298. * moment it's installed — so this flips to true synchronously at the end of
  299. * `start()`. The startup reconcile against on-disk state is handled
  300. * separately by the engine's catch-up sync, not by the watcher.
  301. */
  302. private ready = false;
  303. /**
  304. * Callbacks that resolve when the watch set is established. Used by tests
  305. * (and any production caller that cares about a clean baseline) to
  306. * deterministically gate on watcher readiness.
  307. */
  308. private readyWaiters: Array<() => void> = [];
  309. // The shared scope matcher (built-in defaults + project .gitignore, with
  310. // embedded child repos matched by their OWN rules — #514), built once at
  311. // start(). Same source of truth the indexer uses, so watcher scope can
  312. // never diverge from index scope. An embedded repo created after start()
  313. // joins the scope on the next watcher restart / re-index.
  314. private ignoreMatcher: ScopeIgnore | null = null;
  315. private readonly projectRoot: string;
  316. private readonly debounceMs: number;
  317. private readonly syncFn: (paths?: string[]) => Promise<{ filesChanged: number; durationMs: number }>;
  318. private readonly onSyncComplete?: WatchOptions['onSyncComplete'];
  319. private readonly onSyncError?: WatchOptions['onSyncError'];
  320. private readonly onDegraded?: WatchOptions['onDegraded'];
  321. private readonly inertForTests: boolean;
  322. constructor(
  323. projectRoot: string,
  324. syncFn: (paths?: string[]) => Promise<{ filesChanged: number; durationMs: number }>,
  325. options: WatchOptions = {}
  326. ) {
  327. this.projectRoot = projectRoot;
  328. this.syncFn = syncFn;
  329. this.debounceMs = options.debounceMs ?? 2000;
  330. this.onSyncComplete = options.onSyncComplete;
  331. this.onSyncError = options.onSyncError;
  332. this.onDegraded = options.onDegraded;
  333. this.inertForTests = options.inertForTests ?? false;
  334. }
  335. /**
  336. * Start watching for file changes.
  337. * Returns true if watching started successfully, false otherwise.
  338. */
  339. start(): boolean {
  340. if (this.recursiveWatcher || this.dirWatchers.size > 0 || this.inert) return true; // Already watching
  341. this.stopped = false;
  342. this.degradedReason = null;
  343. this.lockRetryCount = 0;
  344. this.syncFailureRetryCount = 0;
  345. // Some environments make filesystem watching unusable — most notably
  346. // WSL2 /mnt/ drives, where the underlying fs.watch calls block long
  347. // enough to break MCP startup handshakes (issue #199). Skip watching
  348. // there; callers fall back to manual `codegraph sync` or git sync hooks.
  349. const disabledReason = watchDisabledReason(this.projectRoot);
  350. if (disabledReason) {
  351. logDebug('File watcher disabled', { reason: disabledReason, projectRoot: this.projectRoot });
  352. return false;
  353. }
  354. // Reuse the indexer's ignore set so the watcher and indexer agree on scope.
  355. this.ignoreMatcher = buildScopeIgnore(this.projectRoot);
  356. try {
  357. if (this.inertForTests) {
  358. // Test-only: install no OS watcher; the seam drives events instead.
  359. this.inert = true;
  360. } else if (supportsRecursiveWatch()) {
  361. this.startRecursive();
  362. } else {
  363. this.startPerDirectory();
  364. }
  365. // The per-directory (Linux) path catches watch-resource exhaustion inside
  366. // watchTree and degrades synchronously rather than throwing, so it never
  367. // reaches the catch below. Surface that as a failed start here so both
  368. // strategies report exhaustion identically (start() === false).
  369. if (this.degradedReason) return false;
  370. // No async crawl to wait on: as soon as the watch set is installed we
  371. // have a clean baseline (pendingFiles is only populated by post-start
  372. // events). Clear defensively and flip ready.
  373. this.pendingFiles.clear();
  374. this.ready = true;
  375. for (const cb of this.readyWaiters) cb();
  376. this.readyWaiters.length = 0;
  377. if (IS_TEST_RUNTIME) liveWatchersForTests.set(this.projectRoot, this);
  378. logDebug('File watcher started', {
  379. projectRoot: this.projectRoot,
  380. debounceMs: this.debounceMs,
  381. mode: this.inertForTests ? 'inert' : supportsRecursiveWatch() ? 'recursive' : 'per-directory',
  382. watchedDirs: this.dirWatchers.size || undefined,
  383. });
  384. return true;
  385. } catch (err) {
  386. // Watcher setup failed. Watch-resource exhaustion (EMFILE/ENFILE on the
  387. // recursive path) is terminal — degrade cleanly with one actionable
  388. // warning instead of leaving a half-broken watcher. Everything else
  389. // (permission denied, missing directory) keeps the prior quiet-stop.
  390. if (isWatchResourceExhaustion(err)) {
  391. this.degrade(EXHAUSTION_REASON, { error: String(err) });
  392. } else {
  393. logWarn('Could not start file watcher', { error: String(err) });
  394. this.stop();
  395. }
  396. return false;
  397. }
  398. }
  399. /**
  400. * macOS/Windows: one recursive watcher for the whole tree. O(1) descriptors.
  401. * `filename` arrives relative to the project root (with subdirectories), so
  402. * it maps straight to a project-relative path.
  403. */
  404. private startRecursive(): void {
  405. this.recursiveWatcher = watchImpl(
  406. this.projectRoot,
  407. { recursive: true, persistent: true },
  408. (_event, filename) => {
  409. if (this.stopped || filename == null) return;
  410. this.handleChange(normalizePath(String(filename)));
  411. }
  412. );
  413. this.recursiveWatcher.on('error', (err: unknown) => {
  414. if (isWatchResourceExhaustion(err)) {
  415. this.degrade(EXHAUSTION_REASON, { error: String(err) });
  416. return;
  417. }
  418. logWarn('File watcher error', { error: String(err) });
  419. });
  420. }
  421. /**
  422. * Linux: walk the (non-ignored) tree and watch each directory. One inotify
  423. * watch per directory reports create/modify/delete for that directory's
  424. * direct children, so we never watch individual files.
  425. */
  426. private startPerDirectory(): void {
  427. this.watchTree(this.projectRoot, /* markExisting */ false);
  428. }
  429. /**
  430. * Add an inotify watch for `dir` and recurse into its non-ignored
  431. * subdirectories. When `markExisting` is true (a directory that appeared
  432. * AFTER startup), the source files already inside it are recorded as pending
  433. * — this closes the `mkdir + write` race where files created before the new
  434. * directory's watch is installed would otherwise be missed until the next
  435. * full sync. The initial startup walk passes false (the engine's catch-up
  436. * sync owns the baseline).
  437. */
  438. private watchTree(dir: string, markExisting: boolean): void {
  439. // A degrade() mid-walk (exhaustion on an earlier directory) calls stop(),
  440. // which sets `stopped`; bail so the recursion unwinds without adding more
  441. // watches to a watcher that is shutting down. `inotifyLimitWarned` does the
  442. // same after ENOSPC — the kernel budget is gone, so stop trying the rest of
  443. // the tree (every add would fail) while keeping the watches already set.
  444. if (this.stopped || this.degradedReason || this.inotifyLimitWarned) return;
  445. if (this.dirWatchers.has(dir)) return;
  446. if (this.dirWatchers.size >= maxDirWatches()) {
  447. if (!this.dirCapWarned) {
  448. this.dirCapWarned = true;
  449. logWarn('File watcher hit directory-watch cap; remaining subtrees rely on manual/periodic sync', {
  450. cap: maxDirWatches(),
  451. });
  452. }
  453. return;
  454. }
  455. let w: fs.FSWatcher;
  456. try {
  457. w = watchImpl(dir, { persistent: true }, (_event, filename) =>
  458. this.handleDirEvent(dir, filename)
  459. );
  460. } catch (err) {
  461. // EMFILE/ENFILE means the PROCESS is out of descriptors — every further
  462. // directory would fail too, so degrade the whole watcher rather than
  463. // limping along with a partial watch set.
  464. if (isWatchResourceExhaustion(err)) {
  465. this.degrade(EXHAUSTION_REASON, { error: String(err), dir });
  466. } else if (isInotifyWatchExhaustion(err)) {
  467. // ENOSPC = inotify watch budget exhausted. NON-fatal: keep the watches
  468. // we have and tell the user the knob to raise (warn once).
  469. this.warnInotifyLimit({ error: String(err), dir });
  470. }
  471. // ENOENT / EACCES on a single directory stays non-fatal: skip it quietly.
  472. return;
  473. }
  474. w.on('error', (err: unknown) => {
  475. if (isWatchResourceExhaustion(err)) {
  476. this.degrade(EXHAUSTION_REASON, { error: String(err), dir });
  477. return;
  478. }
  479. if (isInotifyWatchExhaustion(err)) {
  480. this.warnInotifyLimit({ error: String(err), dir });
  481. }
  482. this.unwatchDir(dir);
  483. });
  484. this.dirWatchers.set(dir, w);
  485. let entries: fs.Dirent[];
  486. try {
  487. entries = fs.readdirSync(dir, { withFileTypes: true });
  488. } catch {
  489. return;
  490. }
  491. for (const entry of entries) {
  492. const child = path.join(dir, entry.name);
  493. if (entry.isDirectory()) {
  494. if (this.shouldIgnoreDir(child)) continue;
  495. this.watchTree(child, markExisting);
  496. } else if (markExisting && entry.isFile()) {
  497. this.handleChange(normalizePath(path.relative(this.projectRoot, child)));
  498. }
  499. }
  500. }
  501. /**
  502. * Linux per-directory event handler. `filename` is relative to `dir`. A new
  503. * sub-directory is picked up by extending the watch tree; everything else is
  504. * routed through the shared change handler.
  505. */
  506. private handleDirEvent(dir: string, filename: string | Buffer | null): void {
  507. if (this.stopped || filename == null) return;
  508. const full = path.join(dir, String(filename));
  509. // A newly-created directory needs its own watch (recursive isn't available
  510. // on Linux). statSync is cheap and these events are rare relative to file
  511. // edits. If the path vanished (rapid create/delete) the stat throws and we
  512. // fall through to the change handler, which no-ops on a non-source path.
  513. try {
  514. if (fs.statSync(full).isDirectory()) {
  515. if (!this.shouldIgnoreDir(full)) this.watchTree(full, /* markExisting */ true);
  516. return;
  517. }
  518. } catch {
  519. // deleted/inaccessible — treat as a normal change below
  520. }
  521. this.handleChange(normalizePath(path.relative(this.projectRoot, full)));
  522. }
  523. /**
  524. * Shared change handler for both watch strategies. `rel` is a
  525. * project-relative POSIX path. Applies the ignore + source-file filters and,
  526. * for a real source change, records it as pending (#403) and schedules a
  527. * debounced sync.
  528. *
  529. * The recursive (macOS/Windows) watcher reports events for ignored trees too
  530. * (one stream covers the whole repo), so the ignore check here is load-bearing
  531. * — it drops node_modules/dist/.git churn before any sync is scheduled.
  532. */
  533. private handleChange(rel: string): void {
  534. if (!rel || rel === '.' || rel.startsWith('..')) return;
  535. if (this.isAlwaysIgnored(rel)) return;
  536. if (this.ignoreMatcher && this.ignoreMatcher.ignores(rel)) return;
  537. if (!isSourceFile(rel, loadExtensionOverrides(this.projectRoot))) {
  538. this.maybeScheduleForRemovedDir(rel);
  539. return;
  540. }
  541. logDebug('File change detected', { file: rel });
  542. if (this.ready) {
  543. const now = Date.now();
  544. const existing = this.pendingFiles.get(rel);
  545. this.pendingFiles.set(rel, {
  546. firstSeenMs: existing?.firstSeenMs ?? now,
  547. lastSeenMs: now,
  548. });
  549. }
  550. this.scheduleSync();
  551. }
  552. /**
  553. * A deleted DIRECTORY arrives as one event on the directory's own path —
  554. * no source extension, so the source-file filter drops it, and the files
  555. * underneath may never get events of their own (Windows's recursive
  556. * watcher reports only the top-most removed entry; macOS FSEvents can
  557. * coalesce a tree deletion the same way). The index then kept every child
  558. * record until some unrelated edit happened to trigger a sync (#1285).
  559. *
  560. * If a non-source path no longer exists on disk, treat it as a potential
  561. * subtree removal and schedule the debounced sync — its scan-diff removes
  562. * whatever is gone, which is the ground truth for what was underneath.
  563. * pendingFiles is left alone (we can't know the children from the event).
  564. * An event for an EXISTING non-source file stays fully ignored, so build
  565. * churn on live files never schedules work; a deleted non-source file
  566. * costs at most one no-op scan-diff, absorbed by the debounce.
  567. */
  568. private maybeScheduleForRemovedDir(rel: string): void {
  569. try {
  570. fs.statSync(path.join(this.projectRoot, rel));
  571. return; // still on disk — an ordinary non-source change, ignore
  572. } catch {
  573. /* gone — fall through */
  574. }
  575. logDebug('Non-source path removed; scheduling sync for possible directory removal', {
  576. path: rel,
  577. });
  578. this.needsFullScan = true;
  579. this.scheduleSync();
  580. }
  581. /** Close and forget the watch for a directory that errored/was removed. */
  582. private unwatchDir(dir: string): void {
  583. const w = this.dirWatchers.get(dir);
  584. if (w) {
  585. try {
  586. w.close();
  587. } catch {
  588. /* already closed */
  589. }
  590. this.dirWatchers.delete(dir);
  591. }
  592. }
  593. /** Our own dirs are always ignored, regardless of .gitignore. */
  594. private isAlwaysIgnored(rel: string): boolean {
  595. // First path segment. Ignore any CodeGraph data dir — the active one AND a
  596. // sibling like `.codegraph-win` a second environment (Windows/WSL) created
  597. // in the same tree, so neither side watches the other's index (#636).
  598. const top = rel.split('/')[0] ?? rel;
  599. return (
  600. isCodeGraphDataDir(top) ||
  601. rel === '.git' || rel.startsWith('.git/')
  602. );
  603. }
  604. /**
  605. * True for any directory that should NOT be watched (used while building the
  606. * Linux per-directory watch tree). Tests the directory form of the path so a
  607. * dir-only ignore rule like `build/` matches.
  608. */
  609. private shouldIgnoreDir(dirPath: string): boolean {
  610. const rel = normalizePath(path.relative(this.projectRoot, dirPath));
  611. if (!rel || rel === '.' || rel.startsWith('..')) return false; // root / outside
  612. if (this.isAlwaysIgnored(rel)) return true;
  613. if (!this.ignoreMatcher) return false;
  614. return this.ignoreMatcher.ignores(rel + '/');
  615. }
  616. /**
  617. * Permanently disable live watching after a terminal runtime failure
  618. * (watch-resource exhaustion, lock contention past the retry budget, or a
  619. * persistent generic sync failure past the retry budget).
  620. * Idempotent: logs one actionable warning, fires {@link WatchOptions.onDegraded}
  621. * once, and stops the watcher. A subsequent start() clears the latch.
  622. */
  623. private degrade(reason: string, context: Record<string, unknown> = {}): void {
  624. if (this.degradedReason) return;
  625. this.degradedReason = reason;
  626. logWarn('File watcher disabled', { projectRoot: this.projectRoot, reason, ...context });
  627. this.onDegraded?.(reason);
  628. this.stop();
  629. }
  630. /**
  631. * Warn ONCE that the Linux inotify watch budget is exhausted (ENOSPC), and
  632. * stop adding new watches for the rest of this session — every further
  633. * `inotify_add_watch` would fail too, so walking the rest of the tree is
  634. * waste. Unlike {@link degrade} this is NON-fatal: the watches already
  635. * installed keep firing, and `codegraph sync` covers the unwatched remainder.
  636. * The message names the kernel knob to raise (`fs.inotify.max_user_watches`).
  637. */
  638. private warnInotifyLimit(context: Record<string, unknown> = {}): void {
  639. if (this.inotifyLimitWarned) return;
  640. this.inotifyLimitWarned = true;
  641. logWarn(INOTIFY_LIMIT_REASON, { watchedDirs: this.dirWatchers.size, ...context });
  642. }
  643. /**
  644. * Whether live watching has degraded permanently (until the next start()).
  645. * Distinct from {@link isActive}: a degraded watcher is inactive, but an
  646. * inactive watcher is not necessarily degraded (it may simply be stopped or
  647. * never started). Hosts use this to tell the user auto-sync is off.
  648. */
  649. isDegraded(): boolean {
  650. return this.degradedReason !== null;
  651. }
  652. /** The reason live watching degraded, or null if it is healthy. */
  653. getDegradedReason(): string | null {
  654. return this.degradedReason;
  655. }
  656. /**
  657. * Stop watching for file changes.
  658. */
  659. stop(): void {
  660. this.stopped = true;
  661. if (this.debounceTimer) {
  662. clearTimeout(this.debounceTimer);
  663. this.debounceTimer = null;
  664. }
  665. if (this.recursiveWatcher) {
  666. try {
  667. this.recursiveWatcher.close();
  668. } catch {
  669. /* already closed */
  670. }
  671. this.recursiveWatcher = null;
  672. }
  673. for (const w of this.dirWatchers.values()) {
  674. try {
  675. w.close();
  676. } catch {
  677. /* already closed */
  678. }
  679. }
  680. this.dirWatchers.clear();
  681. this.dirCapWarned = false;
  682. this.inotifyLimitWarned = false;
  683. this.lockRetryCount = 0;
  684. this.syncFailureRetryCount = 0;
  685. // NB: degradedReason is intentionally NOT reset here — it must survive the
  686. // stop() that degrade() triggers so isDegraded() stays true. start() clears it.
  687. this.inert = false;
  688. this.pendingFiles.clear();
  689. this.ready = false;
  690. this.ignoreMatcher = null;
  691. if (IS_TEST_RUNTIME) liveWatchersForTests.delete(this.projectRoot);
  692. logDebug('File watcher stopped');
  693. }
  694. /**
  695. * @internal Test-only: feed a synthetic project-relative change through the
  696. * same filter → pendingFiles → debounced-sync path a real fs.watch event
  697. * takes. Lets the watcher / staleness-banner suites stay deterministic
  698. * instead of racing on OS watch-delivery latency. See
  699. * {@link __emitWatchEventForTests}.
  700. */
  701. ingestEventForTests(relPath: string): void {
  702. this.handleChange(normalizePath(relPath));
  703. }
  704. /**
  705. * Whether the watcher is currently active.
  706. */
  707. isActive(): boolean {
  708. return (this.recursiveWatcher !== null || this.dirWatchers.size > 0 || this.inert) && !this.stopped;
  709. }
  710. /**
  711. * Resolves once the watch set has been installed (or immediately if it
  712. * already has). Useful for tests that need a deterministic boundary before
  713. * asserting on `pendingFiles`.
  714. *
  715. * Production callers don't need this: `pendingFiles` is read continuously,
  716. * the staleness banner is always correct (empty or populated), and there is
  717. * no asynchronous initial-scan window with `fs.watch`.
  718. */
  719. waitUntilReady(timeoutMs = 10000): Promise<void> {
  720. if (this.ready) return Promise.resolve();
  721. return new Promise((resolve, reject) => {
  722. const t = setTimeout(() => {
  723. const idx = this.readyWaiters.indexOf(handler);
  724. if (idx >= 0) this.readyWaiters.splice(idx, 1);
  725. reject(new Error(`FileWatcher.waitUntilReady timed out after ${timeoutMs}ms`));
  726. }, timeoutMs);
  727. const handler = () => { clearTimeout(t); resolve(); };
  728. this.readyWaiters.push(handler);
  729. });
  730. }
  731. /**
  732. * Schedule a normal debounced sync after a source edit.
  733. */
  734. private scheduleSync(): void {
  735. if (this.debounceTimer) {
  736. clearTimeout(this.debounceTimer);
  737. }
  738. // Adaptive quiet window: a lone save (or a pair — editor + its test file)
  739. // fires fast so the graph feels instant; anything bigger keeps the full
  740. // configured window so an agent's multi-file burst still coalesces into
  741. // one sync exactly as before. Re-arming on each event preserves the
  742. // trailing-edge semantics either way: if more events arrive inside the
  743. // quick window, the reschedule sees the larger pending set and extends to
  744. // the full window. Never exceeds the configured debounce (a user-lowered
  745. // CODEGRAPH_WATCH_DEBOUNCE_MS stays authoritative), floor 100ms.
  746. const quickMs = Math.max(100, Math.min(QUICK_SYNC_QUIET_MS, this.debounceMs));
  747. const delay = this.pendingFiles.size <= QUICK_SYNC_MAX_PENDING ? quickMs : this.debounceMs;
  748. this.debounceTimer = setTimeout(() => {
  749. this.debounceTimer = null;
  750. this.flush();
  751. }, delay);
  752. }
  753. /**
  754. * Schedule a retry after a recoverable sync failure (lock contention). Kept
  755. * separate from {@link scheduleSync} so prolonged contention backs off
  756. * exponentially instead of hammering the lock every debounce cycle.
  757. */
  758. private scheduleRetrySync(delayMs: number): void {
  759. if (this.debounceTimer) {
  760. clearTimeout(this.debounceTimer);
  761. }
  762. this.debounceTimer = setTimeout(() => {
  763. this.debounceTimer = null;
  764. this.flush();
  765. }, delayMs);
  766. }
  767. /**
  768. * Flush pending changes by running sync.
  769. *
  770. * pendingFiles is NOT cleared at the start of sync — entries are removed
  771. * only after sync commits successfully, and only for entries whose
  772. * lastSeenMs <= syncStartedMs. That way, a query that arrives mid-sync
  773. * still sees the affected files marked stale (the DB hasn't been updated
  774. * yet), and an event that lands mid-sync persists into the follow-up.
  775. *
  776. * On sync failure pendingFiles is left untouched — every edit is still
  777. * unindexed, and the rescheduled sync will absorb the same set next time.
  778. */
  779. private async flush(): Promise<void> {
  780. // If already syncing, the post-sync check will re-trigger
  781. if (this.syncing || this.stopped) return;
  782. this.syncStartedMs = Date.now();
  783. this.syncing = true;
  784. // Scoped fast path: when every pending change is a known file event, hand
  785. // the exact paths to sync and skip its O(repo) scan-diff. Anything the
  786. // events can't fully describe — a directory removal, an empty pending set
  787. // (retry paths), or an event storm past the ceiling — runs the full
  788. // scan-diff, which remains the ground truth.
  789. const scoped =
  790. !this.needsFullScan && this.pendingFiles.size > 0 && this.pendingFiles.size <= SCOPED_SYNC_MAX_PENDING
  791. ? [...this.pendingFiles.keys()]
  792. : undefined;
  793. try {
  794. const result = await this.syncFn(scoped);
  795. if (!scoped) this.needsFullScan = false;
  796. this.lockRetryCount = 0; // a clean sync clears any contention backoff
  797. this.syncFailureRetryCount = 0; // ...and any generic-failure backoff
  798. // Remove entries whose most recent event predates this sync — those
  799. // edits are now in the DB. Entries with lastSeenMs > syncStartedMs
  800. // arrived mid-sync; whether the in-flight sync captured them depends
  801. // on when sync read that file, so we keep them as pending and let
  802. // the follow-up sync handle them. We prefer false positives ("shown
  803. // stale, actually fresh" → at worst one extra Read) over false
  804. // negatives ("shown fresh, actually stale" → misleads the agent).
  805. for (const [filePath, info] of this.pendingFiles) {
  806. if (info.lastSeenMs <= this.syncStartedMs) {
  807. this.pendingFiles.delete(filePath);
  808. }
  809. }
  810. this.onSyncComplete?.(result);
  811. } catch (err) {
  812. if (err instanceof LockUnavailableError) {
  813. this.lockRetryCount += 1;
  814. // Lock-failure no-op (another writer holds the lock). pendingFiles
  815. // stays intact and the `finally` block reschedules with backoff. Keep
  816. // brief contention quiet (debug-only — a long external index would
  817. // otherwise spam stderr every cycle), but stop retrying forever: once a
  818. // writer holds the lock past the budget, degrade auto-sync explicitly.
  819. logDebug('Watch sync skipped: file lock unavailable', {
  820. pendingFiles: this.pendingFiles.size,
  821. retryCount: this.lockRetryCount,
  822. });
  823. if (this.lockRetryCount > MAX_LOCK_RETRIES) {
  824. this.degrade(
  825. 'CodeGraph file lock held by another process past the retry budget; ' +
  826. 'auto-sync disabled. Run `codegraph sync` once the other writer finishes ' +
  827. '(or install git sync hooks) to refresh the graph.',
  828. { pendingFiles: this.pendingFiles.size, retryCount: this.lockRetryCount }
  829. );
  830. }
  831. } else {
  832. this.lockRetryCount = 0; // a non-lock failure isn't contention; reset that streak
  833. this.syncFailureRetryCount += 1;
  834. const error = err instanceof Error ? err : new Error(String(err));
  835. logWarn('Watch sync failed', {
  836. error: error.message,
  837. retryCount: this.syncFailureRetryCount,
  838. });
  839. this.onSyncError?.(error);
  840. // A persistent (deterministic) sync failure — a broken extractor on a
  841. // specific file, DB corruption, SQLITE_FULL, an OOM in resolution —
  842. // would otherwise retry forever at the debounce cadence, spamming logs
  843. // and work while the auto-update guarantee is silently dead (#1127).
  844. // Bound it exactly like lock contention: the `finally` block backs off
  845. // exponentially, and past the budget we degrade so the dead guarantee
  846. // is surfaced (onDegraded / isDegraded) instead of hidden. A single
  847. // clean sync resets the streak, so a transient hiccup never degrades.
  848. if (this.syncFailureRetryCount > MAX_SYNC_FAILURE_RETRIES) {
  849. this.degrade(
  850. `CodeGraph auto-sync failed ${this.syncFailureRetryCount} times in a row; ` +
  851. 'auto-sync disabled. Run `codegraph sync` (or install git sync hooks) to ' +
  852. `refresh the graph after changes. Last error: ${error.message}`,
  853. { error: error.message, retryCount: this.syncFailureRetryCount }
  854. );
  855. }
  856. }
  857. // Failure: leave pendingFiles untouched. Every edit it tracks is
  858. // still unindexed; the rescheduled sync sees the same set.
  859. } finally {
  860. this.syncing = false;
  861. // If pending files remain (mid-sync events, or this sync failed),
  862. // schedule another pass. After EITHER failure mode — lock contention or a
  863. // generic sync failure — back off exponentially (debounceMs · 2^(n-1),
  864. // capped) instead of retrying at the normal debounce cadence; a clean
  865. // sync resets both counters so normal edits keep the fast debounce. Use
  866. // the larger streak so interleaved failures still back off. A degrade()
  867. // above already set `stopped`, so this won't reschedule a watcher that
  868. // has given up.
  869. if (this.pendingFiles.size > 0 && !this.stopped) {
  870. const retryCount = Math.max(this.lockRetryCount, this.syncFailureRetryCount);
  871. if (retryCount > 0) {
  872. const retryDelayMs = Math.min(
  873. this.debounceMs * 2 ** Math.max(0, retryCount - 1),
  874. MAX_RETRY_BACKOFF_MS
  875. );
  876. this.scheduleRetrySync(retryDelayMs);
  877. } else {
  878. this.scheduleSync();
  879. }
  880. }
  881. }
  882. }
  883. /**
  884. * Snapshot of files seen by the watcher since the last successful sync.
  885. *
  886. * Used by MCP tool responses to mark stale results without blocking on a
  887. * sync: a tool that returns a hit in `src/foo.ts` while `src/foo.ts` is in
  888. * this list tells the agent "Read this file directly, the index lags."
  889. *
  890. * `indexing` is true when a sync is currently in flight whose start time is
  891. * AFTER this file's most recent event — i.e. that sync will absorb the
  892. * edit. False means the file is still inside the debounce window and no
  893. * sync has started yet (a follow-up call a few hundred ms later may show
  894. * `indexing: true` or the file may have left the list entirely).
  895. *
  896. * Cheap: O(pendingFiles.size), no I/O, no locks.
  897. */
  898. getPendingFiles(): PendingFile[] {
  899. const result: PendingFile[] = [];
  900. for (const [filePath, info] of this.pendingFiles) {
  901. result.push({
  902. path: filePath,
  903. firstSeenMs: info.firstSeenMs,
  904. lastSeenMs: info.lastSeenMs,
  905. indexing: this.syncing && this.syncStartedMs >= info.lastSeenMs,
  906. });
  907. }
  908. return result;
  909. }
  910. }
  911. /**
  912. * Test-only: synthesize a source-file change for the live watcher running at
  913. * `projectRoot`, exercising the real filter → pendingFiles → debounced-sync
  914. * logic without depending on fs.watch delivery timing (which races under
  915. * parallel vitest). `relPath` is project-relative POSIX (e.g. "src/foo.ts").
  916. * Returns false if no live watcher is registered for that root (e.g. outside a
  917. * test runtime, where the registry is intentionally not populated).
  918. */
  919. export function __emitWatchEventForTests(projectRoot: string, relPath: string): boolean {
  920. const w = liveWatchersForTests.get(projectRoot);
  921. if (!w) return false;
  922. w.ingestEventForTests(relPath);
  923. return true;
  924. }