Skip to content

Commit 972302a

Browse files
clydindgp1130
authored andcommitted
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 00c2509 commit 972302a

2 files changed

Lines changed: 242 additions & 153 deletions

File tree

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

Lines changed: 148 additions & 151 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,94 @@ 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);
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(/\/\*\*$/, '');
11231100

1124-
return { isPackage: true };
1125-
}
1101+
return base !== NODE_MODULES && !base.endsWith(`/${NODE_MODULES}`);
1102+
})
1103+
.map((pattern) => {
1104+
if (/[*?[\]{}()]/.test(pattern)) {
1105+
const isMatch = picomatch(pattern, { dot: true });
11261106

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-
}
1107+
return (filePath: string) => isMatch(toPosixPathNormalized(filePath));
1108+
}
11351109

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);
1110+
return { path: toPosixPathNormalized(pattern), recursive: true };
1111+
}) ?? [];
11421112

1143-
return { isPackage: true, unwatchPkgDir: packageDir };
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)) {
1120+
return false;
11441121
}
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-
}
11551122

1156-
const posixPath = toPosixPathNormalized(filePath);
1157-
const lookupKey = toLookupKey(posixPath, this.isCaseSensitive);
1123+
const posixPath = toPosixPathNormalized(filePath);
1124+
const lookupKey = toLookupKey(posixPath, isCaseSensitive);
11581125

1159-
if (!`${lookupKey}/`.includes(NODE_MODULES_SEGMENT)) {
1160-
return false;
1161-
}
1126+
return (
1127+
(lookupKey === NODE_MODULES || lookupKey.endsWith(`/${NODE_MODULES}`)) &&
1128+
!watchedFiles.has(lookupKey)
1129+
);
1130+
},
1131+
];
1132+
}
11621133

1163-
if (this.allowedPackages.has(lookupKey)) {
1164-
return false;
1165-
}
1134+
interface ChokidarBaselineEntry {
1135+
posixPath: string;
1136+
mtimeMs?: number;
1137+
size?: number;
1138+
}
11661139

1167-
for (const pkg of this.allowedPackages.keys()) {
1168-
if (isPathInside(lookupKey, pkg)) {
1169-
return false;
1140+
// Chokidar initializes file watchers asynchronously (`await stat(path)` followed by
1141+
// `fs.watch` or `fs.watchFile`). If a file is modified or recreated immediately after
1142+
// `watcher.add()` is called, Chokidar may capture the post-modification state as its initial
1143+
// baseline (or suppress the initial `'add'` event due to `ignoreInitial: true`) and miss the
1144+
// change. Re-statting after a short delay catches any modifications or recreations that occurred
1145+
// during asynchronous watcher registration, while `fileStats` deduplicates if Chokidar also fired.
1146+
async function checkChokidarBaselines(
1147+
baselineStats: ReadonlyMap<string, ChokidarBaselineEntry>,
1148+
watchedFiles: ReadonlySet<string>,
1149+
fileStats: ReadonlyMap<string, { mtimeMs: number; size: number }>,
1150+
watcher: Chokidar.FSWatcher,
1151+
handleEvent: (type: 'added' | 'modified' | 'removed', rawPath: string) => void,
1152+
): Promise<void> {
1153+
await Promise.all(
1154+
Array.from(baselineStats, async ([lookupKey, baseline]) => {
1155+
if (!watchedFiles.has(lookupKey)) {
1156+
return;
1157+
}
1158+
const currentStat = await fs.promises.stat(baseline.posixPath).catch(() => null);
1159+
if (!watchedFiles.has(lookupKey) || !currentStat?.isFile()) {
1160+
return;
11701161
}
1171-
}
11721162

1173-
return true;
1174-
}
1163+
if (baseline.mtimeMs === undefined || baseline.size === undefined) {
1164+
const recorded = fileStats.get(lookupKey);
1165+
if (!recorded) {
1166+
watcher.add(baseline.posixPath);
1167+
handleEvent('added', path.normalize(baseline.posixPath));
1168+
} else if (currentStat.mtimeMs !== recorded.mtimeMs || currentStat.size !== recorded.size) {
1169+
handleEvent('modified', path.normalize(baseline.posixPath));
1170+
}
1171+
} else if (currentStat.mtimeMs !== baseline.mtimeMs || currentStat.size !== baseline.size) {
1172+
handleEvent('modified', path.normalize(baseline.posixPath));
1173+
}
1174+
}),
1175+
);
11751176
}
11761177

11771178
async function createChokidarWatcher(
@@ -1181,57 +1182,30 @@ async function createChokidarWatcher(
11811182
const chokidar = chokidarModule ?? (await import('chokidar'));
11821183
const watchedFiles = new Set<string>();
11831184
const fileStats = new Map<string, { mtimeMs: number; size: number }>();
1185+
const pendingTimers = new Set<ReturnType<typeof setTimeout>>();
11841186

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-
}
1187+
const pollingInterval = options?.interval ?? 100;
1188+
const debounceMs = Math.min(250, Math.max(100, Math.ceil(pollingInterval * 1.5)));
1189+
const queue = options?.polling
1190+
? new WatcherQueue(debounceMs, Math.max(500, debounceMs * 3))
1191+
: new WatcherQueue();
11941192

11951193
const rootDir = options?.cwd ?? process.cwd();
11961194
const isCaseSensitive = isFileSystemCaseSensitive(rootDir);
1197-
const rootDirPosix = toPosixPathNormalized(rootDir);
1198-
const rootDirLookupKey = toLookupKey(rootDirPosix, isCaseSensitive);
1199-
1200-
const nodeModulesManager = new ChokidarNodeModulesManager(isCaseSensitive);
1195+
const ignored = createChokidarIgnored(options?.ignored, isCaseSensitive, watchedFiles);
12011196

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, {
1197+
const watcher = chokidar.watch([], {
12211198
ignoreInitial: true,
12221199
ignored,
12231200
followSymlinks: options?.followSymlinks,
12241201
usePolling: !!options?.polling,
12251202
interval: options?.interval,
12261203
});
12271204

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

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');
1208+
const initTime = Date.now();
12351209

12361210
const handleEvent = (type: 'added' | 'modified' | 'removed', rawPath: string) => {
12371211
const posixPath = toPosixPathNormalized(rawPath);
@@ -1248,14 +1222,10 @@ async function createChokidarWatcher(
12481222
}
12491223

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

@@ -1273,11 +1243,55 @@ async function createChokidarWatcher(
12731243
queue.addChange(type, rawPath);
12741244
};
12751245

1246+
const scheduleBaselineCheck = (baselineStats: ReadonlyMap<string, ChokidarBaselineEntry>) => {
1247+
if (baselineStats.size === 0) {
1248+
return;
1249+
}
1250+
1251+
const timer = setTimeout(() => {
1252+
pendingTimers.delete(timer);
1253+
void checkChokidarBaselines(baselineStats, watchedFiles, fileStats, watcher, handleEvent);
1254+
}, 50);
1255+
timer.unref();
1256+
pendingTimers.add(timer);
1257+
};
1258+
12761259
watcher.on('add', (path) => handleEvent('added', path));
12771260
watcher.on('change', (path) => handleEvent('modified', path));
1278-
watcher.on('unlink', (path) => handleEvent('removed', path));
1261+
watcher.on('unlink', (rawPath) => {
1262+
const posixPath = toPosixPathNormalized(rawPath);
1263+
const lookupKey = toLookupKey(posixPath, isCaseSensitive);
1264+
if (!isPathWatched(lookupKey, watchedFiles)) {
1265+
return;
1266+
}
12791267

1280-
const buildWatcher: BuildWatcher = {
1268+
// When Chokidar processes a file removal, it closes the underlying file watcher unless only a
1269+
// single directory is tracked (`_watched.size === 1`). Re-adding a directly watched path
1270+
// ensures Chokidar continues watching either the recreated file (if replaced within the atomic
1271+
// window) or its parent directory for future recreation.
1272+
if (watchedFiles.has(lookupKey)) {
1273+
fileStats.delete(lookupKey);
1274+
watcher.add(posixPath);
1275+
const stat = fs.statSync(rawPath, { throwIfNoEntry: false });
1276+
if (stat?.isFile()) {
1277+
handleEvent('modified', rawPath);
1278+
scheduleBaselineCheck(
1279+
new Map([[lookupKey, { posixPath, mtimeMs: stat.mtimeMs, size: stat.size }]]),
1280+
);
1281+
1282+
return;
1283+
}
1284+
1285+
handleEvent('removed', rawPath);
1286+
scheduleBaselineCheck(new Map([[lookupKey, { posixPath }]]));
1287+
1288+
return;
1289+
}
1290+
1291+
handleEvent('removed', rawPath);
1292+
});
1293+
1294+
return {
12811295
[Symbol.asyncIterator]() {
12821296
return this;
12831297
},
@@ -1304,28 +1318,23 @@ async function createChokidarWatcher(
13041318
add(paths) {
13051319
const targets = typeof paths === 'string' ? [paths] : paths;
13061320
const newPaths: string[] = [];
1321+
const baselineStats = new Map<string, ChokidarBaselineEntry>();
1322+
13071323
for (const p of targets) {
13081324
const posixPath = toPosixPathNormalized(p);
13091325
const lookupKey = toLookupKey(posixPath, isCaseSensitive);
13101326
if (!watchedFiles.has(lookupKey)) {
13111327
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);
1328+
newPaths.push(posixPath);
1329+
const stat = fs.statSync(posixPath, { throwIfNoEntry: false });
1330+
if (stat?.isFile()) {
1331+
baselineStats.set(lookupKey, { posixPath, mtimeMs: stat.mtimeMs, size: stat.size });
13241332
}
13251333
}
13261334
}
13271335
if (newPaths.length > 0) {
13281336
watcher.add(newPaths);
1337+
scheduleBaselineCheck(baselineStats);
13291338
}
13301339
},
13311340

@@ -1338,21 +1347,7 @@ async function createChokidarWatcher(
13381347
if (watchedFiles.has(lookupKey)) {
13391348
watchedFiles.delete(lookupKey);
13401349
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-
}
1350+
removePaths.push(posixPath);
13561351
}
13571352
}
13581353
if (removePaths.length > 0) {
@@ -1361,13 +1356,15 @@ async function createChokidarWatcher(
13611356
},
13621357

13631358
async close() {
1359+
for (const timer of pendingTimers) {
1360+
clearTimeout(timer);
1361+
}
1362+
pendingTimers.clear();
13641363
try {
13651364
await watcher.close();
13661365
} finally {
13671366
queue.close();
13681367
}
13691368
},
1370-
};
1371-
1372-
return buildWatcher;
1369+
} satisfies BuildWatcher;
13731370
}

0 commit comments

Comments
 (0)