diff --git a/__tests__/watcher.test.ts b/__tests__/watcher.test.ts index a457468b9..b2285e45a 100644 --- a/__tests__/watcher.test.ts +++ b/__tests__/watcher.test.ts @@ -56,11 +56,22 @@ function waitFor( describe('FileWatcher', () => { let testDir: string; + const graphs = new Set(); + const unitWatchers = new Set(); + + const initGraph = (...args: Parameters) => { + const cg = CodeGraph.initSync(...args); + graphs.add(cg); + return cg; + }; // Inert by default — unit tests drive events via __emitWatchEventForTests // and never depend on real OS watch delivery. - const newWatcher = (syncFn: SyncFn, opts: WatchOptions = {}) => - new FileWatcher(testDir, syncFn, { inertForTests: true, ...opts }); + const newWatcher = (syncFn: SyncFn, opts: WatchOptions = {}) => { + const watcher = new FileWatcher(testDir, syncFn, { inertForTests: true, ...opts }); + unitWatchers.add(watcher); + return watcher; + }; beforeEach(() => { testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-watcher-')); @@ -70,7 +81,18 @@ describe('FileWatcher', () => { fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;'); }); - afterEach(() => { + afterEach(async () => { + // DB mutations become visible before async sync maintenance finishes. + // Stop new work and drain it BEFORE closing SQLite or removing the fixture; + // nested afterEach hooks run too late to protect this outer cleanup. + for (const watcher of unitWatchers) watcher.stop(); + unitWatchers.clear(); + for (const cg of graphs) { + cg.unwatch(); + await waitFor(() => !cg.isIndexing(), 8000); + cg.close(); + } + graphs.clear(); __setFsWatchForTests(null); // reset the injected fs.watch seam vi.restoreAllMocks(); if (fs.existsSync(testDir)) { @@ -482,7 +504,7 @@ describe('FileWatcher', () => { fs.mkdirSync(deep, { recursive: true }); fs.writeFileSync(path.join(deep, 'inner.ts'), 'export const i = 2;'); - const cg = CodeGraph.initSync(testDir); + const cg = initGraph(testDir); await cg.indexAll(); const before = cg.getFiles().map((f) => f.path); expect(before).toContain('docs/a/b/inner.ts'); @@ -507,7 +529,6 @@ describe('FileWatcher', () => { expect(after.some((p) => p.startsWith('docs/'))).toBe(false); watcher.stop(); - cg.close(); }); it('should ignore .codegraph directory changes', async () => { @@ -865,12 +886,8 @@ describe('FileWatcher', () => { describe('CodeGraph integration', () => { let cg: CodeGraph; - afterEach(() => { - if (cg) cg.close(); - }); - it('should watch and unwatch via CodeGraph API', async () => { - cg = CodeGraph.initSync(testDir, { + cg = initGraph(testDir, { config: { include: ['**/*.ts'], exclude: [] }, }); await cg.indexAll(); @@ -886,7 +903,7 @@ describe('FileWatcher', () => { }); it('should stop watching on close', async () => { - cg = CodeGraph.initSync(testDir, { + cg = initGraph(testDir, { config: { include: ['**/*.ts'], exclude: [] }, }); await cg.indexAll(); @@ -903,7 +920,7 @@ describe('FileWatcher', () => { it('should auto-sync when files change while watching (real fs.watch end-to-end)', async () => { // The one test that exercises the genuine native watcher: a real file // write must propagate through fs.watch → debounce → sync into the graph. - cg = CodeGraph.initSync(testDir, { + cg = initGraph(testDir, { config: { include: ['**/*.ts'], exclude: [] }, }); await cg.indexAll(); @@ -933,6 +950,161 @@ describe('FileWatcher', () => { cg.unwatch(); }); + + }); + + describe('symlink directory watching (#770)', () => { + let cg: CodeGraph | undefined; + const externalDirs: string[] = []; + const watchers: FileWatcher[] = []; + const external = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-linked-')); + externalDirs.push(dir); + return dir; + }; + // Junctions exercise Windows directory links without requiring symlink privileges. + const link = (target: string, name: string) => + fs.symlinkSync(target, name, process.platform === 'win32' ? 'junction' : 'dir'); + const has = (name: string) => cg!.getNodesByName(name).length > 0; + const start = async () => { + cg = initGraph(testDir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cg.indexAll(); + expect(cg.watch({ debounceMs: 100 })).toBe(true); + // FSEvents can coalesce writes made while a new stream is being registered. + await new Promise(resolve => setTimeout(resolve, 700)); + }; + + afterEach(() => { + cg = undefined; + for (const watcher of watchers.splice(0)) watcher.stop(); + for (const dir of externalDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + + it('auto-syncs edits, creates and deletes through an external directory link', async () => { + const dir = external(); + fs.mkdirSync(path.join(dir, 'src')); + const file = path.join(dir, 'src', 'item.ts'); + fs.writeFileSync(file, 'export function linkedBefore() {}'); + link(dir, path.join(testDir, 'linked-project')); + await start(); + expect(has('linkedBefore')).toBe(true); + fs.writeFileSync(file, 'export function linkedAfter() {}'); + await waitFor(() => has('linkedAfter') && !has('linkedBefore'), 8000); + expect(cg!.getNodesByName('linkedAfter')[0].filePath).toBe('linked-project/src/item.ts'); + fs.mkdirSync(path.join(dir, 'new')); + fs.writeFileSync(path.join(dir, 'new', 'added.ts'), 'export function linkedAdded() {}'); + await waitFor(() => has('linkedAdded'), 8000); + fs.unlinkSync(file); + await waitFor(() => !has('linkedAfter'), 8000); + }, 30000); + + it('watches both targets of a real cycle and deduplicates aliases', async () => { + const a = external(); + const b = external(); + link(b, path.join(a, 'toB')); + link(a, path.join(b, 'toA')); + link(testDir, path.join(a, 'toRoot')); + link(a, path.join(testDir, 'linked')); + link(a, path.join(testDir, 'duplicate')); + await start(); + fs.writeFileSync(path.join(a, 'a.ts'), 'export function cycleAddedA() {}'); + fs.writeFileSync(path.join(b, 'b.ts'), 'export function cycleAddedB() {}'); + await waitFor(() => has('cycleAddedA') && has('cycleAddedB'), 8000); + expect(cg!.getNodesByName('cycleAddedA')).toHaveLength(1); + expect(cg!.getNodesByName('cycleAddedB')).toHaveLength(1); + }, 15000); + + it('discovers new links, retargets them, removes stale paths and restarts', async () => { + const a = external(); + const b = external(); + fs.writeFileSync(path.join(a, 'a.ts'), 'export function firstTarget() {}'); + fs.writeFileSync(path.join(b, 'b.ts'), 'export function secondTarget() {}'); + await start(); + const alias = path.join(testDir, 'linked'); + link(a, alias); + await waitFor(() => has('firstTarget'), 8000); + fs.unlinkSync(alias); + link(b, alias); + await waitFor(() => has('secondTarget') && !has('firstTarget'), 8000); + fs.writeFileSync(path.join(b, 'b.ts'), 'export function retargetEdit() {}'); + await waitFor(() => has('retargetEdit'), 8000); + cg!.unwatch(); + expect(cg!.watch({ debounceMs: 100 })).toBe(true); + fs.writeFileSync(path.join(b, 'b.ts'), 'export function restartedEdit() {}'); + await waitFor(() => has('restartedEdit'), 8000); + fs.unlinkSync(alias); + await waitFor(() => !has('restartedEdit'), 8000); + }, 45000); + + it('refreshes excluded link scope without watching ignored targets', async () => { + const dir = external(); + link(dir, path.join(testDir, 'linked')); + fs.writeFileSync(path.join(dir, 'a.ts'), 'export function scopeLinked() {}'); + fs.writeFileSync(path.join(testDir, '.gitignore'), 'linked/\n'); + await start(); + expect(has('scopeLinked')).toBe(false); + fs.writeFileSync(path.join(testDir, '.gitignore'), ''); + await waitFor(() => has('scopeLinked'), 8000); + fs.writeFileSync(path.join(dir, 'a.ts'), 'export function scopeEdited() {}'); + await waitFor(() => has('scopeEdited'), 8000); + fs.writeFileSync(path.join(testDir, '.gitignore'), 'linked/\n'); + await waitFor(() => !has('scopeEdited'), 8000); + }, 30000); + + it('bounds watch handles, skips ignored targets and closes every handle on stop', () => { + const dir = external(); + link(dir, path.join(testDir, 'linked')); + link(dir, path.join(testDir, 'duplicate')); + link(testDir, path.join(dir, 'cycle')); + const ignored = external(); + link(ignored, path.join(testDir, 'node_modules')); + const opened: string[] = []; + const closed: string[] = []; + __setFsWatchForTests(((dir: fs.PathLike) => { + opened.push(String(dir)); + const w = new EventEmitter() as fs.FSWatcher; + w.close = () => { closed.push(String(dir)); }; + return w; + }) as typeof fs.watch); + const watcher = new FileWatcher(testDir, async () => ({ filesChanged: 0, durationMs: 0 })); + watchers.push(watcher); + expect(watcher.start()).toBe(true); + expect(opened.some(d => d.includes('node_modules'))).toBe(false); + expect(opened.filter(d => fs.realpathSync(d) === fs.realpathSync(dir))).toHaveLength(1); + watcher.stop(); + expect(closed.sort()).toEqual(opened.sort()); + expect(watcher.start()).toBe(true); + watcher.stop(); + expect(closed.sort()).toEqual(opened.sort()); + }); + + it.runIf(process.platform === 'darwin' || process.platform === 'win32')('caps supplemental recursive streams and degrades on their exhaustion', () => { + link(external(), path.join(testDir, 'first')); + link(external(), path.join(testDir, 'second')); + vi.stubEnv('CODEGRAPH_MAX_DIR_WATCHES', '1'); + const close = vi.fn(); + const watch = vi.fn(() => { + const w = new EventEmitter() as fs.FSWatcher; + w.close = close; + return w; + }); + __setFsWatchForTests(watch as typeof fs.watch); + const watcher = new FileWatcher(testDir, async () => ({ filesChanged: 0, durationMs: 0 })); + watchers.push(watcher); + expect(watcher.start()).toBe(true); + expect(watch).toHaveBeenCalledTimes(2); // root + capped supplemental stream + watcher.stop(); + expect(close).toHaveBeenCalledTimes(2); + watch.mockImplementationOnce(() => { + const w = new EventEmitter() as fs.FSWatcher; + w.close = close; + return w; + }).mockImplementation(() => { throw Object.assign(new Error('EMFILE'), { code: 'EMFILE' }); }); + expect(watcher.start()).toBe(false); + expect(watcher.isDegraded()).toBe(true); + expect(close).toHaveBeenCalledTimes(3); + }); }); describe('scoped sync fast path (#watcher-scoped)', () => { @@ -1040,6 +1212,29 @@ describe('FileWatcher', () => { } }); + it('reconciles again when directory topology changes during a full sync', async () => { + let release!: () => void; + const firstRun = new Promise(resolve => { release = resolve; }); + const calls: (string[] | undefined)[] = []; + const watcher = newWatcher(async paths => { + calls.push(paths); + if (calls.length === 1) await firstRun; + return { filesChanged: 0, durationMs: 1 }; + }, { debounceMs: 25 }); + watcher.start(); + try { + __emitWatchEventForTests(testDir, 'removed-link'); + await waitFor(() => calls.length === 1); + __emitWatchEventForTests(testDir, 'another-removed-link'); + release(); + await waitFor(() => calls.length === 2); + expect(calls).toEqual([undefined, undefined]); + } finally { + release(); + watcher.stop(); + } + }); + it('a lone file event fires on the quick window, well before the full debounce', async () => { const calls: (string[] | undefined)[] = []; const syncFn: SyncFn = async (paths?: string[]) => { diff --git a/src/sync/watcher.ts b/src/sync/watcher.ts index 920a8a69c..74257cc58 100644 --- a/src/sync/watcher.ts +++ b/src/sync/watcher.ts @@ -8,7 +8,8 @@ * addon) with a per-platform strategy chosen to keep the open-descriptor / * kernel-watch cost BOUNDED rather than growing with the number of files: * - * - macOS / Windows: a SINGLE recursive `fs.watch(root, {recursive:true})`. + * - macOS / Windows: a recursive `fs.watch(root, {recursive:true})`, plus + * bounded supplemental streams for directory symlinks (#770). * libuv maps this to one FSEvents stream (macOS) / one * ReadDirectoryChangesW handle (Windows), so it costs O(1) descriptors no * matter how large the tree. This is the fix for the macOS file-table @@ -252,8 +253,8 @@ export interface PendingFile { * debounced sync operations via a provided callback. * * Design goals: - * - Bounded resource usage: O(1) descriptors on macOS/Windows (one recursive - * watch), O(directories) inotify watches on Linux — never O(files), which + * - Bounded resource usage: one recursive stream plus capped symlink streams + * on macOS/Windows, O(directories) inotify watches on Linux — never O(files), which * was the system-crashing fd leak on macOS (#644/#496/#555/#628). * - Debounced to avoid thrashing on rapid saves * - Filters to supported source files by extension @@ -264,7 +265,7 @@ export interface PendingFile { export class FileWatcher { /** macOS/Windows: the single recursive watcher. Null on Linux. */ private recursiveWatcher: fs.FSWatcher | null = null; - /** Linux: one watcher per watched directory (keyed by absolute path). */ + /** Linux directory watches / macOS and Windows supplemental symlink streams. */ private dirWatchers = new Map(); /** Set once the per-directory watch cap is hit, so we log only once. */ private dirCapWarned = false; @@ -289,6 +290,15 @@ export class FileWatcher { private syncFailureRetryCount = 0; /** Test-only inert mode: started, but with no OS watcher installed. */ private inert = false; + /** + * Resolved realpath of directories already watched (or skipped). Prevents + * infinite recursion via symlink cycles — mirrors the same guard used in + * the indexer's `walk()` (extraction/index.ts). + */ + private visitedRealDirs = new Set(); + private knownDirs = new Map(); + private watchedRealDirs = new Map(); + private treeRefreshTimer: ReturnType | null = null; private debounceTimer: ReturnType | null = null; /** * True when the pending set does NOT exactly describe the change (a @@ -372,6 +382,7 @@ export class FileWatcher { this.degradedReason = null; this.lockRetryCount = 0; this.syncFailureRetryCount = 0; + this.needsFullScan = false; // Some environments make filesystem watching unusable — most notably // WSL2 /mnt/ drives, where the underlying fs.watch calls block long @@ -393,11 +404,11 @@ export class FileWatcher { } else if (supportsRecursiveWatch()) { this.startRecursive(); } else { - this.startPerDirectory(); + this.refreshWatchTree(); } // The per-directory (Linux) path catches watch-resource exhaustion inside - // watchTree and degrades synchronously rather than throwing, so it never + // watchDirectory and degrades synchronously rather than throwing, so it never // reaches the catch below. Surface that as a failed start here so both // strategies report exhaustion identically (start() === false). if (this.degradedReason) return false; @@ -434,7 +445,7 @@ export class FileWatcher { } /** - * macOS/Windows: one recursive watcher for the whole tree. O(1) descriptors. + * macOS/Windows: one root stream, supplemented for directory symlinks. * `filename` arrives relative to the project root (with subdirectories), so * it maps straight to a project-relative path. */ @@ -442,10 +453,7 @@ export class FileWatcher { this.recursiveWatcher = watchImpl( this.projectRoot, { recursive: true, persistent: true }, - (_event, filename) => { - if (this.stopped || filename == null) return; - this.handleChange(normalizePath(String(filename))); - } + (_event, filename) => this.handleDirEvent(this.projectRoot, filename) ); this.recursiveWatcher.on('error', (err: unknown) => { if (isWatchResourceExhaustion(err)) { @@ -454,47 +462,78 @@ export class FileWatcher { } logWarn('File watcher error', { error: String(err) }); }); + this.refreshWatchTree(); } - /** - * Linux: walk the (non-ignored) tree and watch each directory. One inotify - * watch per directory reports create/modify/delete for that directory's - * direct children, so we never watch individual files. - */ - private startPerDirectory(): void { - this.watchTree(this.projectRoot, /* markExisting */ false); + /** Reconcile logical directory paths with real targets, including retargeted links. */ + private refreshWatchTree(): void { + if (this.stopped || this.inert) return; + this.visitedRealDirs.clear(); + const directories = new Map(); + const desired = new Map(); + const recursive = supportsRecursiveWatch(); + const walk = (dir: string, linked: boolean): void => { + if (this.shouldIgnoreDir(dir)) return; + let real: string; + try { + real = fs.realpathSync(dir); + if (this.visitedRealDirs.has(real)) return; + this.visitedRealDirs.add(real); + directories.set(dir, real); + if (!recursive || linked) desired.set(dir, real); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const child = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(child, false); + } else if (entry.isSymbolicLink()) { + // Follow directory symlinks just like the indexer's walk. Realpath + // deduplication includes ordinary directories and the root itself. + try { + if (fs.statSync(child).isDirectory()) walk(child, true); + } catch { /* broken link */ } + } + } + } catch { /* removed or unreadable directory */ } + }; + walk(this.projectRoot, false); + this.knownDirs = directories; + for (const dir of this.dirWatchers.keys()) { + if (desired.get(dir) !== this.watchedRealDirs.get(dir)) this.unwatchDir(dir); + } + for (const [dir, real] of desired) this.watchDirectory(dir, real, recursive); } - /** - * Add an inotify watch for `dir` and recurse into its non-ignored - * subdirectories. When `markExisting` is true (a directory that appeared - * AFTER startup), the source files already inside it are recorded as pending - * — this closes the `mkdir + write` race where files created before the new - * directory's watch is installed would otherwise be missed until the next - * full sync. The initial startup walk passes false (the engine's catch-up - * sync owns the baseline). - */ - private watchTree(dir: string, markExisting: boolean): void { - // A degrade() mid-walk (exhaustion on an earlier directory) calls stop(), - // which sets `stopped`; bail so the recursion unwinds without adding more - // watches to a watcher that is shutting down. `inotifyLimitWarned` does the - // same after ENOSPC — the kernel budget is gone, so stop trying the rest of - // the tree (every add would fail) while keeping the watches already set. + /** Coalesce directory topology events before walking; file edits never walk the tree. */ + private scheduleTreeRefresh(): void { + if (this.treeRefreshTimer || this.inert || this.stopped) return; + this.treeRefreshTimer = setTimeout(() => { + this.treeRefreshTimer = null; + this.refreshWatchTree(); + if (this.stopped) return; + // A new/retargeted directory can already contain files before its watch + // is installed; a full reconcile closes that race and removes old paths. + this.needsFullScan = true; + this.scheduleSync(); + }, 50); + } + + private watchDirectory(dir: string, real: string, recursive: boolean): void { if (this.stopped || this.degradedReason || this.inotifyLimitWarned) return; if (this.dirWatchers.has(dir)) return; - if (this.dirWatchers.size >= maxDirWatches()) { + // Recursive streams cost more than inotify entries. Keep a separate hard + // ceiling while honouring a lower user-configured directory watch budget. + const cap = recursive ? Math.min(256, maxDirWatches()) : maxDirWatches(); + if (this.dirWatchers.size >= cap) { if (!this.dirCapWarned) { this.dirCapWarned = true; - logWarn('File watcher hit directory-watch cap; remaining subtrees rely on manual/periodic sync', { - cap: maxDirWatches(), - }); + logWarn('File watcher hit directory-watch cap; remaining subtrees rely on manual/periodic sync', { cap }); } return; } let w: fs.FSWatcher; try { - w = watchImpl(dir, { persistent: true }, (_event, filename) => + w = watchImpl(dir, { persistent: true, recursive }, (_event, filename) => this.handleDirEvent(dir, filename) ); } catch (err) { @@ -523,46 +562,36 @@ export class FileWatcher { }); this.dirWatchers.set(dir, w); - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const child = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (this.shouldIgnoreDir(child)) continue; - this.watchTree(child, markExisting); - } else if (markExisting && entry.isFile()) { - this.handleChange(normalizePath(path.relative(this.projectRoot, child))); - } - } + this.watchedRealDirs.set(dir, real); } - /** - * Linux per-directory event handler. `filename` is relative to `dir`. A new - * sub-directory is picked up by extending the watch tree; everything else is - * routed through the shared change handler. - */ + /** Events are relative to a watch's logical path, even for an external target. */ private handleDirEvent(dir: string, filename: string | Buffer | null): void { - if (this.stopped || filename == null) return; + if (this.stopped) return; + if (filename == null) { + this.scheduleTreeRefresh(); + return; + } const full = path.join(dir, String(filename)); - - // A newly-created directory needs its own watch (recursive isn't available - // on Linux). statSync is cheap and these events are rare relative to file - // edits. If the path vanished (rapid create/delete) the stat throws and we - // fall through to the change handler, which no-ops on a non-source path. + const rel = normalizePath(path.relative(this.projectRoot, full)); + if (!rel || rel === '.') { + this.scheduleTreeRefresh(); + return; + } + // Churn under an ignored directory (node_modules, dist, .git) is dropped + // before any per-event fs call; `.git/info/exclude` still reaches the scope + // refresh in handleChange (#1728). + if (rel !== '.git/info/exclude' && this.shouldIgnoreDir(path.dirname(full))) return; try { if (fs.statSync(full).isDirectory()) { - if (!this.shouldIgnoreDir(full)) this.watchTree(full, /* markExisting */ true); + if (this.shouldIgnoreDir(full)) return; + // Existing directory notifications can represent a retargeted link. + if (this.knownDirs.get(full) !== fs.realpathSync(full)) this.scheduleTreeRefresh(); return; } - } catch { - // deleted/inaccessible — treat as a normal change below - } - - this.handleChange(normalizePath(path.relative(this.projectRoot, full))); + } catch { /* deleted/inaccessible */ } + if (this.knownDirs.has(full) && !this.shouldIgnoreDir(full)) this.scheduleTreeRefresh(); + this.handleChange(rel); } /** @@ -645,6 +674,7 @@ export class FileWatcher { private refreshScope(rel: string): void { logDebug('Scope config changed; rebuilding watcher scope', { file: rel }); this.ignoreMatcher = buildScopeIgnore(this.projectRoot); + this.scheduleTreeRefresh(); this.needsFullScan = true; this.scheduleSync(); } @@ -689,19 +719,16 @@ export class FileWatcher { /* already closed */ } this.dirWatchers.delete(dir); + this.watchedRealDirs.delete(dir); } } /** Our own dirs are always ignored, regardless of .gitignore. */ private isAlwaysIgnored(rel: string): boolean { - // First path segment. Ignore any CodeGraph data dir — the active one AND a + // Ignore any CodeGraph data dir — the active one AND a // sibling like `.codegraph-win` a second environment (Windows/WSL) created // in the same tree, so neither side watches the other's index (#636). - const top = rel.split('/')[0] ?? rel; - return ( - isCodeGraphDataDir(top) || - rel === '.git' || rel.startsWith('.git/') - ); + return rel.split('/').some(part => isCodeGraphDataDir(part) || part === '.git'); } /** @@ -766,6 +793,10 @@ export class FileWatcher { */ stop(): void { this.stopped = true; + if (this.treeRefreshTimer) clearTimeout(this.treeRefreshTimer); + this.treeRefreshTimer = null; + this.knownDirs.clear(); + this.watchedRealDirs.clear(); if (this.debounceTimer) { clearTimeout(this.debounceTimer); @@ -794,6 +825,7 @@ export class FileWatcher { this.syncFailureRetryCount = 0; // NB: degradedReason is intentionally NOT reset here — it must survive the // stop() that degrade() triggers so isDegraded() stays true. start() clears it. + this.visitedRealDirs.clear(); this.inert = false; this.pendingFiles.clear(); @@ -910,9 +942,11 @@ export class FileWatcher { ? [...this.pendingFiles.keys()] : undefined; + // Consume this request before yielding so a topology/scope change during + // the full sync remains pending for the next reconcile. + if (!scoped) this.needsFullScan = false; try { const result = await this.syncFn(scoped); - if (!scoped) this.needsFullScan = false; this.lockRetryCount = 0; // a clean sync clears any contention backoff this.syncFailureRetryCount = 0; // ...and any generic-failure backoff // Remove entries whose most recent event predates this sync — those @@ -929,6 +963,7 @@ export class FileWatcher { } this.onSyncComplete?.(result); } catch (err) { + if (!scoped) this.needsFullScan = true; if (err instanceof LockUnavailableError) { this.lockRetryCount += 1; // Lock-failure no-op (another writer holds the lock). pendingFiles