Skip to content

Commit d170c55

Browse files
committed
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
1 parent d542827 commit d170c55

2 files changed

Lines changed: 203 additions & 157 deletions

File tree

‎packages/angular/build/src/utils/watcher.ts‎

Lines changed: 114 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
import type * as ParcelWatcher from '@parcel/watcher';
1010
import type * as Chokidar from 'chokidar';
11-
import { once } from 'node:events';
1211
import * as fs from 'node:fs';
1312
import * as path from 'node:path';
1413
import picomatch from 'picomatch';
@@ -1086,92 +1085,50 @@ async function createParcelWatcher(
10861085
return buildWatcher;
10871086
}
10881087

1089-
/**
1090-
* Manages selective node_modules watching for Chokidar.
1091-
*
1092-
* During Chokidar's initial scan, the allowed packages set is empty, causing Chokidar to prune the entire
1093-
* node_modules directory immediately and avoid blocking the 'ready' event for minutes.
1094-
* Packages are dynamically allowed and watched at the package root level as files are added via `watcher.add`.
1095-
* Tracking files per package allows reference-counting so that package directories are unwatched when no
1096-
* longer needed.
1097-
*/
1098-
class ChokidarNodeModulesManager {
1099-
private readonly allowedPackages = new Map<string, Set<string>>();
1100-
1101-
constructor(private readonly isCaseSensitive: boolean) {}
1102-
1103-
registerPackage(
1104-
posixPath: string,
1105-
lookupKey: string,
1106-
): { isPackage: boolean; newPkgDir?: string } {
1107-
const packageDir = extractNodeModulesPackageDir(posixPath);
1108-
if (!packageDir) {
1109-
return { isPackage: false };
1110-
}
1111-
1112-
const packageKey = toLookupKey(packageDir, this.isCaseSensitive);
1113-
let watchedInPkg = this.allowedPackages.get(packageKey);
1114-
if (!watchedInPkg) {
1115-
watchedInPkg = new Set();
1116-
this.allowedPackages.set(packageKey, watchedInPkg);
1117-
watchedInPkg.add(lookupKey);
1118-
1119-
return { isPackage: true, newPkgDir: packageDir };
1120-
}
1121-
1122-
watchedInPkg.add(lookupKey);
1123-
1124-
return { isPackage: true };
1125-
}
1126-
1127-
removePackageFile(
1128-
posixPath: string,
1129-
lookupKey: string,
1130-
): { isPackage: boolean; unwatchPkgDir?: string } {
1131-
const packageDir = extractNodeModulesPackageDir(posixPath);
1132-
if (!packageDir) {
1133-
return { isPackage: false };
1134-
}
1135-
1136-
const packageKey = toLookupKey(packageDir, this.isCaseSensitive);
1137-
const watchedInPkg = this.allowedPackages.get(packageKey);
1138-
if (watchedInPkg) {
1139-
watchedInPkg.delete(lookupKey);
1140-
if (watchedInPkg.size === 0) {
1141-
this.allowedPackages.delete(packageKey);
1142-
1143-
return { isPackage: true, unwatchPkgDir: packageDir };
1144-
}
1145-
}
1146-
1147-
return { isPackage: true };
1148-
}
1149-
1150-
isIgnored(filePath: string): boolean {
1151-
// Fast path: avoid expensive normalization for non-node_modules files
1152-
if (!filePath.includes(NODE_MODULES)) {
1153-
return false;
1154-
}
1088+
function createChokidarIgnored(
1089+
ignoredPatterns: string[] | undefined,
1090+
isCaseSensitive: boolean,
1091+
watchedFiles: ReadonlySet<string>,
1092+
): Chokidar.Matcher[] {
1093+
// Strip static node_modules ignore patterns (e.g. `**/node_modules/**` from setupWatcher).
1094+
// If left in place, picomatch would unconditionally ignore all node_modules files and prevent
1095+
// watching explicitly added files inside node_modules.
1096+
const customIgnored =
1097+
ignoredPatterns
1098+
?.filter((pattern) => {
1099+
const base = toPosixPathNormalized(pattern).replace(/\/\*\*$/, '');
11551100

1156-
const posixPath = toPosixPathNormalized(filePath);
1157-
const lookupKey = toLookupKey(posixPath, this.isCaseSensitive);
1101+
return base !== NODE_MODULES && !base.endsWith(`/${NODE_MODULES}`);
1102+
})
1103+
.map((pattern) => {
1104+
if (/[*?[\]{}()]/.test(pattern)) {
1105+
const isMatch = picomatch(pattern, { dot: true });
11581106

1159-
if (!`${lookupKey}/`.includes(NODE_MODULES_SEGMENT)) {
1160-
return false;
1161-
}
1107+
return (filePath: string) => isMatch(toPosixPathNormalized(filePath));
1108+
}
11621109

1163-
if (this.allowedPackages.has(lookupKey)) {
1164-
return false;
1165-
}
1110+
return { path: toPosixPathNormalized(pattern), recursive: true };
1111+
}) ?? [];
11661112

1167-
for (const pkg of this.allowedPackages.keys()) {
1168-
if (isPathInside(lookupKey, pkg)) {
1113+
// When watching a directory (e.g. projectRoot via NG_BUILD_WATCH_ROOT), prune `node_modules`
1114+
// directories unless explicitly added to `watchedFiles`. Explicitly added files inside
1115+
// `node_modules` do not end with `/node_modules` and remain watchable.
1116+
return [
1117+
...customIgnored,
1118+
(filePath: string) => {
1119+
if (!filePath.includes(NODE_MODULES)) {
11691120
return false;
11701121
}
1171-
}
11721122

1173-
return true;
1174-
}
1123+
const posixPath = toPosixPathNormalized(filePath);
1124+
const lookupKey = toLookupKey(posixPath, isCaseSensitive);
1125+
1126+
return (
1127+
(lookupKey === NODE_MODULES || lookupKey.endsWith(`/${NODE_MODULES}`)) &&
1128+
!watchedFiles.has(lookupKey)
1129+
);
1130+
},
1131+
];
11751132
}
11761133

11771134
async function createChokidarWatcher(
@@ -1181,57 +1138,30 @@ async function createChokidarWatcher(
11811138
const chokidar = chokidarModule ?? (await import('chokidar'));
11821139
const watchedFiles = new Set<string>();
11831140
const fileStats = new Map<string, { mtimeMs: number; size: number }>();
1141+
const pendingTimers = new Set<ReturnType<typeof setTimeout>>();
11841142

1185-
let queue: WatcherQueue;
1186-
if (options?.polling) {
1187-
const pollingInterval = options.interval ?? 100;
1188-
const debounceMs = Math.min(250, Math.max(100, Math.ceil(pollingInterval * 1.5)));
1189-
const maxWaitMs = Math.max(500, debounceMs * 3);
1190-
queue = new WatcherQueue(debounceMs, maxWaitMs);
1191-
} else {
1192-
queue = new WatcherQueue();
1193-
}
1143+
const pollingInterval = options?.interval ?? 100;
1144+
const debounceMs = Math.min(250, Math.max(100, Math.ceil(pollingInterval * 1.5)));
1145+
const queue = options?.polling
1146+
? new WatcherQueue(debounceMs, Math.max(500, debounceMs * 3))
1147+
: new WatcherQueue();
11941148

11951149
const rootDir = options?.cwd ?? process.cwd();
11961150
const isCaseSensitive = isFileSystemCaseSensitive(rootDir);
1197-
const rootDirPosix = toPosixPathNormalized(rootDir);
1198-
const rootDirLookupKey = toLookupKey(rootDirPosix, isCaseSensitive);
1199-
1200-
const nodeModulesManager = new ChokidarNodeModulesManager(isCaseSensitive);
1151+
const ignored = createChokidarIgnored(options?.ignored, isCaseSensitive, watchedFiles);
12011152

1202-
// Strip static node_modules ignore patterns (e.g. `**/node_modules/**` from setupWatcher).
1203-
// If left in place, picomatch would unconditionally ignore all node_modules files and override
1204-
// our dynamic package allowlist above.
1205-
const customIgnored =
1206-
options?.ignored
1207-
?.filter((pattern) => !pattern.includes(NODE_MODULES))
1208-
.map((pattern) => {
1209-
if (/[*?[\]{}()]/.test(pattern)) {
1210-
const isMatch = picomatch(pattern, { dot: true });
1211-
1212-
return (filePath: string) => isMatch(toPosixPathNormalized(filePath));
1213-
}
1214-
1215-
return { path: toPosixPathNormalized(pattern), recursive: true };
1216-
}) ?? [];
1217-
1218-
const ignored = [...customIgnored, (filePath: string) => nodeModulesManager.isIgnored(filePath)];
1219-
1220-
const watcher = chokidar.watch(rootDir, {
1153+
const watcher = chokidar.watch([], {
12211154
ignoreInitial: true,
12221155
ignored,
12231156
followSymlinks: options?.followSymlinks,
12241157
usePolling: !!options?.polling,
12251158
interval: options?.interval,
12261159
});
12271160

1228-
const initTime = Date.now();
1161+
// Prevent unhandled watcher errors (such as EMFILE or EACCES) from crashing the process.
1162+
watcher.on('error', () => {});
12291163

1230-
// Wait for the watcher to complete its initial filesystem scan before returning.
1231-
// With `ignoreInitial: true`, any file visited during the initial scan is treated as the initial baseline
1232-
// and will not emit 'add' or 'change' events. Awaiting 'ready' ensures that rapid file modifications
1233-
// made right after watcher setup (e.g. in rebuild tests) are not swallowed as initial files.
1234-
await once(watcher, 'ready');
1164+
const initTime = Date.now();
12351165

12361166
const handleEvent = (type: 'added' | 'modified' | 'removed', rawPath: string) => {
12371167
const posixPath = toPosixPathNormalized(rawPath);
@@ -1248,14 +1178,10 @@ async function createChokidarWatcher(
12481178
}
12491179

12501180
const stat = fs.statSync(rawPath, { throwIfNoEntry: false });
1251-
if (!stat) {
1252-
return;
1253-
}
1254-
12551181
// Ignore historical events from before watcher initialization, but allow a 1000 ms window
12561182
// to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second
12571183
// mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes.
1258-
if (stat.mtimeMs < initTime - 1000) {
1184+
if (!stat || stat.mtimeMs < initTime - 1000) {
12591185
return;
12601186
}
12611187

@@ -1275,9 +1201,31 @@ async function createChokidarWatcher(
12751201

12761202
watcher.on('add', (path) => handleEvent('added', path));
12771203
watcher.on('change', (path) => handleEvent('modified', path));
1278-
watcher.on('unlink', (path) => handleEvent('removed', path));
1204+
watcher.on('unlink', (rawPath) => {
1205+
const posixPath = toPosixPathNormalized(rawPath);
1206+
const lookupKey = toLookupKey(posixPath, isCaseSensitive);
1207+
if (!isPathWatched(lookupKey, watchedFiles)) {
1208+
return;
1209+
}
12791210

1280-
const buildWatcher: BuildWatcher = {
1211+
// When Chokidar processes a file removal, it closes the underlying file watcher unless only a
1212+
// single directory is tracked (`_watched.size === 1`). Re-adding a directly watched path
1213+
// ensures Chokidar continues watching either the recreated file (if replaced within the atomic
1214+
// window) or its parent directory for future recreation.
1215+
if (watchedFiles.has(lookupKey)) {
1216+
fileStats.delete(lookupKey);
1217+
watcher.add(posixPath);
1218+
if (fs.existsSync(rawPath)) {
1219+
handleEvent('modified', rawPath);
1220+
1221+
return;
1222+
}
1223+
}
1224+
1225+
handleEvent('removed', rawPath);
1226+
});
1227+
1228+
return {
12811229
[Symbol.asyncIterator]() {
12821230
return this;
12831231
},
@@ -1304,28 +1252,51 @@ async function createChokidarWatcher(
13041252
add(paths) {
13051253
const targets = typeof paths === 'string' ? [paths] : paths;
13061254
const newPaths: string[] = [];
1255+
const baselineStats = new Map<string, { posixPath: string; mtimeMs: number; size: number }>();
1256+
13071257
for (const p of targets) {
13081258
const posixPath = toPosixPathNormalized(p);
13091259
const lookupKey = toLookupKey(posixPath, isCaseSensitive);
13101260
if (!watchedFiles.has(lookupKey)) {
13111261
watchedFiles.add(lookupKey);
1312-
1313-
// For files inside node_modules, register their package directory. Only newly encountered
1314-
// package directories need to be added to Chokidar.
1315-
const { isPackage, newPkgDir } = nodeModulesManager.registerPackage(posixPath, lookupKey);
1316-
if (newPkgDir) {
1317-
newPaths.push(newPkgDir);
1318-
} else if (
1319-
!isPackage &&
1320-
!isPathInside(lookupKey, rootDirLookupKey) &&
1321-
lookupKey !== rootDirLookupKey
1322-
) {
1323-
newPaths.push(posixPath);
1262+
newPaths.push(posixPath);
1263+
const stat = fs.statSync(posixPath, { throwIfNoEntry: false });
1264+
if (stat?.isFile()) {
1265+
baselineStats.set(lookupKey, { posixPath, mtimeMs: stat.mtimeMs, size: stat.size });
13241266
}
13251267
}
13261268
}
13271269
if (newPaths.length > 0) {
13281270
watcher.add(newPaths);
1271+
1272+
if (baselineStats.size > 0) {
1273+
// Chokidar initializes file watchers asynchronously (`await stat(path)` followed by
1274+
// `fs.watch` or `fs.watchFile`). If a file is modified immediately after `add()` returns
1275+
// (for example, in tests that write to disk right after the initial build yields),
1276+
// Chokidar may capture the post-modification state as its initial baseline and miss the
1277+
// change. Re-statting after a short delay catches any modifications that occurred during
1278+
// asynchronous watcher registration, while `fileStats` deduplicates if Chokidar also fired.
1279+
const timer = setTimeout(() => {
1280+
pendingTimers.delete(timer);
1281+
void Promise.all(
1282+
Array.from(baselineStats, async ([lookupKey, baseline]) => {
1283+
if (!watchedFiles.has(lookupKey)) {
1284+
return;
1285+
}
1286+
const currentStat = await fs.promises.stat(baseline.posixPath).catch(() => null);
1287+
if (
1288+
watchedFiles.has(lookupKey) &&
1289+
currentStat?.isFile() &&
1290+
(currentStat.mtimeMs !== baseline.mtimeMs || currentStat.size !== baseline.size)
1291+
) {
1292+
handleEvent('modified', path.normalize(baseline.posixPath));
1293+
}
1294+
}),
1295+
);
1296+
}, 50);
1297+
timer.unref();
1298+
pendingTimers.add(timer);
1299+
}
13291300
}
13301301
},
13311302

@@ -1338,21 +1309,7 @@ async function createChokidarWatcher(
13381309
if (watchedFiles.has(lookupKey)) {
13391310
watchedFiles.delete(lookupKey);
13401311
fileStats.delete(lookupKey);
1341-
1342-
// When the last watched file in a package is removed, unwatch the package directory.
1343-
const { isPackage, unwatchPkgDir } = nodeModulesManager.removePackageFile(
1344-
posixPath,
1345-
lookupKey,
1346-
);
1347-
if (unwatchPkgDir) {
1348-
removePaths.push(unwatchPkgDir);
1349-
} else if (
1350-
!isPackage &&
1351-
!isPathInside(lookupKey, rootDirLookupKey) &&
1352-
lookupKey !== rootDirLookupKey
1353-
) {
1354-
removePaths.push(posixPath);
1355-
}
1312+
removePaths.push(posixPath);
13561313
}
13571314
}
13581315
if (removePaths.length > 0) {
@@ -1361,13 +1318,15 @@ async function createChokidarWatcher(
13611318
},
13621319

13631320
async close() {
1321+
for (const timer of pendingTimers) {
1322+
clearTimeout(timer);
1323+
}
1324+
pendingTimers.clear();
13641325
try {
13651326
await watcher.close();
13661327
} finally {
13671328
queue.close();
13681329
}
13691330
},
1370-
};
1371-
1372-
return buildWatcher;
1331+
} satisfies BuildWatcher;
13731332
}

0 commit comments

Comments
 (0)