From 60b40f32500073981793bc9ba24a169b3ea8e2e2 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:05:58 -0400 Subject: [PATCH] fix(@angular/build): watch only explicitly added paths in chokidar watcher When preserveSymlinks or polling is enabled, the build watcher falls back to Chokidar. Previously, createChokidarWatcher initialized Chokidar on the entire workspace root directory and expanded any watched file inside node_modules to its full package root directory via ChokidarNodeModulesManager. Because Chokidar v5 recursively walks watched directories and attaches individual fs.watch handles to every subdirectory and file, projects with many workspace subdirectories or large imported packages could exceed the macOS per-process directory watch limit and crash with an unhandled EMFILE error. This change initializes Chokidar with an empty watch set and registers only the specific paths passed to add() and remove(), eliminating recursive directory scans across the workspace root and node_modules packages. When a directory is explicitly watched (such as when NG_BUILD_WATCH_ROOT is enabled), node_modules subdirectories are pruned unless directly added to the watch set. An error listener is also attached to prevent unhandled watcher errors from terminating the process, and removed files that remain in the watch set are re-registered so subsequent file recreation is detected across multiple watched directories. Closes #34312 --- packages/angular/build/src/utils/watcher.ts | 299 +++++++++--------- .../angular/build/src/utils/watcher_spec.ts | 96 +++++- 2 files changed, 242 insertions(+), 153 deletions(-) diff --git a/packages/angular/build/src/utils/watcher.ts b/packages/angular/build/src/utils/watcher.ts index ca0e86f987ff..fd865ee80b56 100644 --- a/packages/angular/build/src/utils/watcher.ts +++ b/packages/angular/build/src/utils/watcher.ts @@ -8,7 +8,6 @@ import type * as ParcelWatcher from '@parcel/watcher'; import type * as Chokidar from 'chokidar'; -import { once } from 'node:events'; import * as fs from 'node:fs'; import * as path from 'node:path'; import picomatch from 'picomatch'; @@ -1086,92 +1085,94 @@ async function createParcelWatcher( return buildWatcher; } -/** - * Manages selective node_modules watching for Chokidar. - * - * During Chokidar's initial scan, the allowed packages set is empty, causing Chokidar to prune the entire - * node_modules directory immediately and avoid blocking the 'ready' event for minutes. - * Packages are dynamically allowed and watched at the package root level as files are added via `watcher.add`. - * Tracking files per package allows reference-counting so that package directories are unwatched when no - * longer needed. - */ -class ChokidarNodeModulesManager { - private readonly allowedPackages = new Map>(); - - constructor(private readonly isCaseSensitive: boolean) {} - - registerPackage( - posixPath: string, - lookupKey: string, - ): { isPackage: boolean; newPkgDir?: string } { - const packageDir = extractNodeModulesPackageDir(posixPath); - if (!packageDir) { - return { isPackage: false }; - } - - const packageKey = toLookupKey(packageDir, this.isCaseSensitive); - let watchedInPkg = this.allowedPackages.get(packageKey); - if (!watchedInPkg) { - watchedInPkg = new Set(); - this.allowedPackages.set(packageKey, watchedInPkg); - watchedInPkg.add(lookupKey); - - return { isPackage: true, newPkgDir: packageDir }; - } - - watchedInPkg.add(lookupKey); +function createChokidarIgnored( + ignoredPatterns: string[] | undefined, + isCaseSensitive: boolean, + watchedFiles: ReadonlySet, +): Chokidar.Matcher[] { + // Strip static node_modules ignore patterns (e.g. `**/node_modules/**` from setupWatcher). + // If left in place, picomatch would unconditionally ignore all node_modules files and prevent + // watching explicitly added files inside node_modules. + const customIgnored = + ignoredPatterns + ?.filter((pattern) => { + const base = toPosixPathNormalized(pattern).replace(/\/\*\*$/, ''); - return { isPackage: true }; - } + return base !== NODE_MODULES && !base.endsWith(`/${NODE_MODULES}`); + }) + .map((pattern) => { + if (/[*?[\]{}()]/.test(pattern)) { + const isMatch = picomatch(pattern, { dot: true }); - removePackageFile( - posixPath: string, - lookupKey: string, - ): { isPackage: boolean; unwatchPkgDir?: string } { - const packageDir = extractNodeModulesPackageDir(posixPath); - if (!packageDir) { - return { isPackage: false }; - } + return (filePath: string) => isMatch(toPosixPathNormalized(filePath)); + } - const packageKey = toLookupKey(packageDir, this.isCaseSensitive); - const watchedInPkg = this.allowedPackages.get(packageKey); - if (watchedInPkg) { - watchedInPkg.delete(lookupKey); - if (watchedInPkg.size === 0) { - this.allowedPackages.delete(packageKey); + return { path: toPosixPathNormalized(pattern), recursive: true }; + }) ?? []; - return { isPackage: true, unwatchPkgDir: packageDir }; + // When watching a directory (e.g. projectRoot via NG_BUILD_WATCH_ROOT), prune `node_modules` + // directories unless explicitly added to `watchedFiles`. Explicitly added files inside + // `node_modules` do not end with `/node_modules` and remain watchable. + return [ + ...customIgnored, + (filePath: string) => { + if (!filePath.includes(NODE_MODULES)) { + return false; } - } - - return { isPackage: true }; - } - - isIgnored(filePath: string): boolean { - // Fast path: avoid expensive normalization for non-node_modules files - if (!filePath.includes(NODE_MODULES)) { - return false; - } - const posixPath = toPosixPathNormalized(filePath); - const lookupKey = toLookupKey(posixPath, this.isCaseSensitive); + const posixPath = toPosixPathNormalized(filePath); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); - if (!`${lookupKey}/`.includes(NODE_MODULES_SEGMENT)) { - return false; - } + return ( + (lookupKey === NODE_MODULES || lookupKey.endsWith(`/${NODE_MODULES}`)) && + !watchedFiles.has(lookupKey) + ); + }, + ]; +} - if (this.allowedPackages.has(lookupKey)) { - return false; - } +interface ChokidarBaselineEntry { + posixPath: string; + mtimeMs?: number; + size?: number; +} - for (const pkg of this.allowedPackages.keys()) { - if (isPathInside(lookupKey, pkg)) { - return false; +// Chokidar initializes file watchers asynchronously (`await stat(path)` followed by +// `fs.watch` or `fs.watchFile`). If a file is modified or recreated immediately after +// `watcher.add()` is called, Chokidar may capture the post-modification state as its initial +// baseline (or suppress the initial `'add'` event due to `ignoreInitial: true`) and miss the +// change. Re-statting after a short delay catches any modifications or recreations that occurred +// during asynchronous watcher registration, while `fileStats` deduplicates if Chokidar also fired. +async function checkChokidarBaselines( + baselineStats: ReadonlyMap, + watchedFiles: ReadonlySet, + fileStats: ReadonlyMap, + watcher: Chokidar.FSWatcher, + handleEvent: (type: 'added' | 'modified' | 'removed', rawPath: string) => void, +): Promise { + await Promise.all( + Array.from(baselineStats, async ([lookupKey, baseline]) => { + if (!watchedFiles.has(lookupKey)) { + return; + } + const currentStat = await fs.promises.stat(baseline.posixPath).catch(() => null); + if (!watchedFiles.has(lookupKey) || !currentStat?.isFile()) { + return; } - } - return true; - } + if (baseline.mtimeMs === undefined || baseline.size === undefined) { + const recorded = fileStats.get(lookupKey); + if (!recorded) { + watcher.add(baseline.posixPath); + handleEvent('added', path.normalize(baseline.posixPath)); + } else if (currentStat.mtimeMs !== recorded.mtimeMs || currentStat.size !== recorded.size) { + handleEvent('modified', path.normalize(baseline.posixPath)); + } + } else if (currentStat.mtimeMs !== baseline.mtimeMs || currentStat.size !== baseline.size) { + handleEvent('modified', path.normalize(baseline.posixPath)); + } + }), + ); } async function createChokidarWatcher( @@ -1181,43 +1182,19 @@ async function createChokidarWatcher( const chokidar = chokidarModule ?? (await import('chokidar')); const watchedFiles = new Set(); const fileStats = new Map(); + const pendingTimers = new Set>(); - let queue: WatcherQueue; - if (options?.polling) { - const pollingInterval = options.interval ?? 100; - const debounceMs = Math.min(250, Math.max(100, Math.ceil(pollingInterval * 1.5))); - const maxWaitMs = Math.max(500, debounceMs * 3); - queue = new WatcherQueue(debounceMs, maxWaitMs); - } else { - queue = new WatcherQueue(); - } + const pollingInterval = options?.interval ?? 100; + const debounceMs = Math.min(250, Math.max(100, Math.ceil(pollingInterval * 1.5))); + const queue = options?.polling + ? new WatcherQueue(debounceMs, Math.max(500, debounceMs * 3)) + : new WatcherQueue(); const rootDir = options?.cwd ?? process.cwd(); const isCaseSensitive = isFileSystemCaseSensitive(rootDir); - const rootDirPosix = toPosixPathNormalized(rootDir); - const rootDirLookupKey = toLookupKey(rootDirPosix, isCaseSensitive); - - const nodeModulesManager = new ChokidarNodeModulesManager(isCaseSensitive); + const ignored = createChokidarIgnored(options?.ignored, isCaseSensitive, watchedFiles); - // Strip static node_modules ignore patterns (e.g. `**/node_modules/**` from setupWatcher). - // If left in place, picomatch would unconditionally ignore all node_modules files and override - // our dynamic package allowlist above. - const customIgnored = - options?.ignored - ?.filter((pattern) => !pattern.includes(NODE_MODULES)) - .map((pattern) => { - if (/[*?[\]{}()]/.test(pattern)) { - const isMatch = picomatch(pattern, { dot: true }); - - return (filePath: string) => isMatch(toPosixPathNormalized(filePath)); - } - - return { path: toPosixPathNormalized(pattern), recursive: true }; - }) ?? []; - - const ignored = [...customIgnored, (filePath: string) => nodeModulesManager.isIgnored(filePath)]; - - const watcher = chokidar.watch(rootDir, { + const watcher = chokidar.watch([], { ignoreInitial: true, ignored, followSymlinks: options?.followSymlinks, @@ -1225,13 +1202,10 @@ async function createChokidarWatcher( interval: options?.interval, }); - const initTime = Date.now(); + // Prevent unhandled watcher errors (such as EMFILE or EACCES) from crashing the process. + watcher.on('error', () => {}); - // Wait for the watcher to complete its initial filesystem scan before returning. - // With `ignoreInitial: true`, any file visited during the initial scan is treated as the initial baseline - // and will not emit 'add' or 'change' events. Awaiting 'ready' ensures that rapid file modifications - // made right after watcher setup (e.g. in rebuild tests) are not swallowed as initial files. - await once(watcher, 'ready'); + const initTime = Date.now(); const handleEvent = (type: 'added' | 'modified' | 'removed', rawPath: string) => { const posixPath = toPosixPathNormalized(rawPath); @@ -1248,14 +1222,10 @@ async function createChokidarWatcher( } const stat = fs.statSync(rawPath, { throwIfNoEntry: false }); - if (!stat) { - return; - } - // Ignore historical events from before watcher initialization, but allow a 1000 ms window // to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second // mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes. - if (stat.mtimeMs < initTime - 1000) { + if (!stat || stat.mtimeMs < initTime - 1000) { return; } @@ -1273,11 +1243,55 @@ async function createChokidarWatcher( queue.addChange(type, rawPath); }; + const scheduleBaselineCheck = (baselineStats: ReadonlyMap) => { + if (baselineStats.size === 0) { + return; + } + + const timer = setTimeout(() => { + pendingTimers.delete(timer); + void checkChokidarBaselines(baselineStats, watchedFiles, fileStats, watcher, handleEvent); + }, 50); + timer.unref(); + pendingTimers.add(timer); + }; + watcher.on('add', (path) => handleEvent('added', path)); watcher.on('change', (path) => handleEvent('modified', path)); - watcher.on('unlink', (path) => handleEvent('removed', path)); + watcher.on('unlink', (rawPath) => { + const posixPath = toPosixPathNormalized(rawPath); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (!isPathWatched(lookupKey, watchedFiles)) { + return; + } - const buildWatcher: BuildWatcher = { + // When Chokidar processes a file removal, it closes the underlying file watcher unless only a + // single directory is tracked (`_watched.size === 1`). Re-adding a directly watched path + // ensures Chokidar continues watching either the recreated file (if replaced within the atomic + // window) or its parent directory for future recreation. + if (watchedFiles.has(lookupKey)) { + fileStats.delete(lookupKey); + watcher.add(posixPath); + const stat = fs.statSync(rawPath, { throwIfNoEntry: false }); + if (stat?.isFile()) { + handleEvent('modified', rawPath); + scheduleBaselineCheck( + new Map([[lookupKey, { posixPath, mtimeMs: stat.mtimeMs, size: stat.size }]]), + ); + + return; + } + + handleEvent('removed', rawPath); + scheduleBaselineCheck(new Map([[lookupKey, { posixPath }]])); + + return; + } + + handleEvent('removed', rawPath); + }); + + return { [Symbol.asyncIterator]() { return this; }, @@ -1304,28 +1318,23 @@ async function createChokidarWatcher( add(paths) { const targets = typeof paths === 'string' ? [paths] : paths; const newPaths: string[] = []; + const baselineStats = new Map(); + for (const p of targets) { const posixPath = toPosixPathNormalized(p); const lookupKey = toLookupKey(posixPath, isCaseSensitive); if (!watchedFiles.has(lookupKey)) { watchedFiles.add(lookupKey); - - // For files inside node_modules, register their package directory. Only newly encountered - // package directories need to be added to Chokidar. - const { isPackage, newPkgDir } = nodeModulesManager.registerPackage(posixPath, lookupKey); - if (newPkgDir) { - newPaths.push(newPkgDir); - } else if ( - !isPackage && - !isPathInside(lookupKey, rootDirLookupKey) && - lookupKey !== rootDirLookupKey - ) { - newPaths.push(posixPath); + newPaths.push(posixPath); + const stat = fs.statSync(posixPath, { throwIfNoEntry: false }); + if (stat?.isFile()) { + baselineStats.set(lookupKey, { posixPath, mtimeMs: stat.mtimeMs, size: stat.size }); } } } if (newPaths.length > 0) { watcher.add(newPaths); + scheduleBaselineCheck(baselineStats); } }, @@ -1338,21 +1347,7 @@ async function createChokidarWatcher( if (watchedFiles.has(lookupKey)) { watchedFiles.delete(lookupKey); fileStats.delete(lookupKey); - - // When the last watched file in a package is removed, unwatch the package directory. - const { isPackage, unwatchPkgDir } = nodeModulesManager.removePackageFile( - posixPath, - lookupKey, - ); - if (unwatchPkgDir) { - removePaths.push(unwatchPkgDir); - } else if ( - !isPackage && - !isPathInside(lookupKey, rootDirLookupKey) && - lookupKey !== rootDirLookupKey - ) { - removePaths.push(posixPath); - } + removePaths.push(posixPath); } } if (removePaths.length > 0) { @@ -1361,13 +1356,15 @@ async function createChokidarWatcher( }, async close() { + for (const timer of pendingTimers) { + clearTimeout(timer); + } + pendingTimers.clear(); try { await watcher.close(); } finally { queue.close(); } }, - }; - - return buildWatcher; + } satisfies BuildWatcher; } diff --git a/packages/angular/build/src/utils/watcher_spec.ts b/packages/angular/build/src/utils/watcher_spec.ts index 3703eb9f4ce1..ef7a14d831d2 100644 --- a/packages/angular/build/src/utils/watcher_spec.ts +++ b/packages/angular/build/src/utils/watcher_spec.ts @@ -775,7 +775,7 @@ describe('Watcher', () => { } }, 10000); - it('should unwatch linked package directory when all its files are removed via watcher.remove', async () => { + it('should unwatch linked package file when removed via watcher.remove', async () => { const externalDir = fs.mkdtempSync(path.join(TMP_DIR, 'watcher-remove-npm-link-')); let watcher: BuildWatcher | undefined; @@ -813,7 +813,7 @@ describe('Watcher', () => { // Remove the linked file from the watcher watcher.remove(linkedFile); - // Allow a brief moment for Chokidar to asynchronously complete unwatching the package directory + // Allow a brief moment for Chokidar to asynchronously complete unwatching the file await setTimeout(250); // Modify both the removed linked file and the regular file @@ -876,6 +876,98 @@ describe('Watcher', () => { fs.rmSync(externalRoot, { recursive: true, force: true }); } }, 10000); + + it('should detect file deletion and recreation when watching multiple files with followSymlinks: true', async () => { + const subDirA = path.join(tempDir, 'dir-a'); + const subDirB = path.join(tempDir, 'dir-b'); + fs.mkdirSync(subDirA); + fs.mkdirSync(subDirB); + + const fileA = path.join(subDirA, 'a.ts'); + const fileB = path.join(subDirB, 'b.ts'); + fs.writeFileSync(fileA, 'export const a = 1;'); + fs.writeFileSync(fileB, 'export const b = 1;'); + + const watcher = await createWatcher({ + followSymlinks: true, + cwd: tempDir, + }); + + try { + watcher.add([fileA, fileB]); + await setTimeout(250); + + const iterator = watcher[Symbol.asyncIterator](); + + // Delete fileA and wait past Chokidar's atomic write window + fs.rmSync(fileA); + let result = await iterator.next(); + expect(result.done).toBeFalsy(); + expect(result.value?.removed.size).toBeGreaterThan(0); + + await setTimeout(150); + + // Recreate fileA and verify the watcher still detects it + fs.writeFileSync(fileA, 'export const a = 2;'); + result = await iterator.next(); + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('a.ts'))).toBeTrue(); + + // Modify recreated fileA again to ensure ongoing changes are tracked + await setTimeout(150); + fs.writeFileSync(fileA, 'export const a = 3;'); + result = await iterator.next(); + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('a.ts'))).toBeTrue(); + } finally { + await watcher.close(); + } + }, 10000); + + it('should prune node_modules when watching root directory while watching added package files', async () => { + const nodeModulesDir = path.join(tempDir, 'node_modules'); + const pkgDir = path.join(nodeModulesDir, 'some-pkg'); + fs.mkdirSync(pkgDir, { recursive: true }); + + const watchedPkgFile = path.join(pkgDir, 'index.js'); + const unwatchedPkgFile = path.join(pkgDir, 'other.js'); + const rootFile = path.join(tempDir, 'main.ts'); + fs.writeFileSync(watchedPkgFile, 'export const idx = 1;'); + fs.writeFileSync(unwatchedPkgFile, 'export const other = 1;'); + fs.writeFileSync(rootFile, 'export const main = 1;'); + + const pastTime = new Date(Date.now() - 5000); + fs.utimesSync(watchedPkgFile, pastTime, pastTime); + fs.utimesSync(unwatchedPkgFile, pastTime, pastTime); + fs.utimesSync(rootFile, pastTime, pastTime); + + const watcher = await createWatcher({ + followSymlinks: true, + cwd: tempDir, + }); + + try { + // Watch both the root directory and a specific file in node_modules + watcher.add([tempDir, watchedPkgFile]); + await setTimeout(250); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + // Modify unwatched file in node_modules (should be ignored even though tempDir is watched) + // and watched file in node_modules + fs.writeFileSync(unwatchedPkgFile, 'export const other = 2;'); + fs.writeFileSync(watchedPkgFile, 'export const idx = 2;'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + const changed = result.value?.all ?? []; + expect(changed.some((f: string) => f.includes('index.js'))).toBeTrue(); + expect(changed.some((f: string) => f.includes('other.js'))).toBeFalse(); + } finally { + await watcher.close(); + } + }, 10000); }); describe('ParcelExternalManager', () => {