Skip to content

Commit 6284453

Browse files
committed
refactor(@angular/build): guard closed chokidar operations and remove v5 unlink workaround
Chokidar 6 makes `FSWatcher.close()` terminal, throwing synchronously if `watcher.add()` is called once closing has started. Guard `add()`, `remove()`, `scheduleBaselineCheck()`, and `checkChokidarBaselines()` against `watcher.closed`, and clear tracked file state when the watcher is closed. Additionally, replace the deprecated `usePolling` and `interval` Chokidar options with `backend` and `pollingInterval`. Remove the Chokidar 5 `unlink` re-watch workaround and its corresponding baseline check branch, as Chokidar 6 natively awaits recreated paths from their nearest existing ancestor across all watched directories.
1 parent 7f4de14 commit 6284453

2 files changed

Lines changed: 33 additions & 55 deletions

File tree

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

Lines changed: 25 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,42 +1141,33 @@ function createChokidarIgnored(
11411141

11421142
interface ChokidarBaselineEntry {
11431143
posixPath: string;
1144-
mtimeMs?: number;
1145-
size?: number;
1144+
mtimeMs: number;
1145+
size: number;
11461146
}
11471147

11481148
// Chokidar initializes file watchers asynchronously (`await stat(path)` followed by
1149-
// `fs.watch` or `fs.watchFile`). If a file is modified or recreated immediately after
1150-
// `watcher.add()` is called, Chokidar may capture the post-modification state as its initial
1151-
// baseline (or suppress the initial `'add'` event due to `ignoreInitial: true`) and miss the
1152-
// change. Re-statting after a short delay catches any modifications or recreations that occurred
1153-
// during asynchronous watcher registration, while `fileStats` deduplicates if Chokidar also fired.
1149+
// `fs.watch` or polling). If a file is modified immediately after `watcher.add()` is called,
1150+
// Chokidar may capture the post-modification state as its initial baseline (with
1151+
// `ignoreInitial: true`) and miss the change. Re-statting after a short delay catches any
1152+
// modifications that occurred during asynchronous watcher registration, while `fileStats`
1153+
// deduplicates if Chokidar also fired.
11541154
async function checkChokidarBaselines(
11551155
baselineStats: ReadonlyMap<string, ChokidarBaselineEntry>,
11561156
watchedFiles: ReadonlySet<string>,
1157-
fileStats: ReadonlyMap<string, { mtimeMs: number; size: number }>,
11581157
watcher: Chokidar.FSWatcher,
11591158
handleEvent: (type: 'added' | 'modified' | 'removed', rawPath: string) => void,
11601159
): Promise<void> {
11611160
await Promise.all(
11621161
Array.from(baselineStats, async ([lookupKey, baseline]) => {
1163-
if (!watchedFiles.has(lookupKey)) {
1162+
if (watcher.closed || !watchedFiles.has(lookupKey)) {
11641163
return;
11651164
}
11661165
const currentStat = await fs.promises.stat(baseline.posixPath).catch(() => null);
1167-
if (!watchedFiles.has(lookupKey) || !currentStat?.isFile()) {
1166+
if (watcher.closed || !watchedFiles.has(lookupKey) || !currentStat?.isFile()) {
11681167
return;
11691168
}
11701169

1171-
if (baseline.mtimeMs === undefined || baseline.size === undefined) {
1172-
const recorded = fileStats.get(lookupKey);
1173-
if (!recorded) {
1174-
watcher.add(baseline.posixPath);
1175-
handleEvent('added', path.normalize(baseline.posixPath));
1176-
} else if (currentStat.mtimeMs !== recorded.mtimeMs || currentStat.size !== recorded.size) {
1177-
handleEvent('modified', path.normalize(baseline.posixPath));
1178-
}
1179-
} else if (currentStat.mtimeMs !== baseline.mtimeMs || currentStat.size !== baseline.size) {
1170+
if (currentStat.mtimeMs !== baseline.mtimeMs || currentStat.size !== baseline.size) {
11801171
handleEvent('modified', path.normalize(baseline.posixPath));
11811172
}
11821173
}),
@@ -1206,8 +1197,8 @@ async function createChokidarWatcher(
12061197
ignoreInitial: true,
12071198
ignored,
12081199
followSymlinks: options?.followSymlinks,
1209-
usePolling: !!options?.polling,
1210-
interval: options?.interval,
1200+
backend: options?.polling ? 'polling' : 'auto',
1201+
pollingInterval: options?.interval,
12111202
});
12121203

12131204
// Prevent unhandled watcher errors (such as EMFILE or EACCES) from crashing the process.
@@ -1252,52 +1243,21 @@ async function createChokidarWatcher(
12521243
};
12531244

12541245
const scheduleBaselineCheck = (baselineStats: ReadonlyMap<string, ChokidarBaselineEntry>) => {
1255-
if (baselineStats.size === 0) {
1246+
if (baselineStats.size === 0 || watcher.closed) {
12561247
return;
12571248
}
12581249

12591250
const timer = setTimeout(() => {
12601251
pendingTimers.delete(timer);
1261-
void checkChokidarBaselines(baselineStats, watchedFiles, fileStats, watcher, handleEvent);
1252+
void checkChokidarBaselines(baselineStats, watchedFiles, watcher, handleEvent);
12621253
}, 50);
12631254
timer.unref();
12641255
pendingTimers.add(timer);
12651256
};
12661257

12671258
watcher.on('add', (path) => handleEvent('added', path));
12681259
watcher.on('change', (path) => handleEvent('modified', path));
1269-
watcher.on('unlink', (rawPath) => {
1270-
const posixPath = toPosixPathNormalized(rawPath);
1271-
const lookupKey = toLookupKey(posixPath, isCaseSensitive);
1272-
if (!isPathWatched(lookupKey, watchedFiles)) {
1273-
return;
1274-
}
1275-
1276-
// When Chokidar processes a file removal, it closes the underlying file watcher unless only a
1277-
// single directory is tracked (`_watched.size === 1`). Re-adding a directly watched path
1278-
// ensures Chokidar continues watching either the recreated file (if replaced within the atomic
1279-
// window) or its parent directory for future recreation.
1280-
if (watchedFiles.has(lookupKey)) {
1281-
fileStats.delete(lookupKey);
1282-
watcher.add(posixPath);
1283-
const stat = fs.statSync(rawPath, { throwIfNoEntry: false });
1284-
if (stat?.isFile()) {
1285-
handleEvent('modified', rawPath);
1286-
scheduleBaselineCheck(
1287-
new Map([[lookupKey, { posixPath, mtimeMs: stat.mtimeMs, size: stat.size }]]),
1288-
);
1289-
1290-
return;
1291-
}
1292-
1293-
handleEvent('removed', rawPath);
1294-
scheduleBaselineCheck(new Map([[lookupKey, { posixPath }]]));
1295-
1296-
return;
1297-
}
1298-
1299-
handleEvent('removed', rawPath);
1300-
});
1260+
watcher.on('unlink', (path) => handleEvent('removed', path));
13011261

13021262
return {
13031263
[Symbol.asyncIterator]() {
@@ -1324,6 +1284,10 @@ async function createChokidarWatcher(
13241284
},
13251285

13261286
add(paths) {
1287+
if (watcher.closed) {
1288+
return;
1289+
}
1290+
13271291
const targets = typeof paths === 'string' ? [paths] : paths;
13281292
const newPaths: string[] = [];
13291293
const baselineStats = new Map<string, ChokidarBaselineEntry>();
@@ -1347,6 +1311,10 @@ async function createChokidarWatcher(
13471311
},
13481312

13491313
remove(paths) {
1314+
if (watcher.closed) {
1315+
return;
1316+
}
1317+
13501318
const targets = typeof paths === 'string' ? [paths] : paths;
13511319
const removePaths: string[] = [];
13521320
for (const p of targets) {
@@ -1368,6 +1336,8 @@ async function createChokidarWatcher(
13681336
clearTimeout(timer);
13691337
}
13701338
pendingTimers.clear();
1339+
watchedFiles.clear();
1340+
fileStats.clear();
13711341
try {
13721342
await watcher.close();
13731343
} finally {

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,14 @@ describe('Watcher', () => {
512512
expect(result.done).toBeTrue();
513513
});
514514

515+
it('should not throw if add() or remove() is called after close()', async () => {
516+
const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
517+
await watcher.close();
518+
519+
expect(() => watcher.add(path.join(tempDir, 'main.ts'))).not.toThrow();
520+
expect(() => watcher.remove(path.join(tempDir, 'main.ts'))).not.toThrow();
521+
});
522+
515523
it('should ignore stale modifications before initTime and emit changes after initTime (@parcel/watcher)', async () => {
516524
const testFile = path.join(tempDir, 'test.txt');
517525
fs.writeFileSync(testFile, 'initial');

0 commit comments

Comments
 (0)