Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 25 additions & 55 deletions packages/angular/build/src/utils/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1141,42 +1141,33 @@ function createChokidarIgnored(

interface ChokidarBaselineEntry {
posixPath: string;
mtimeMs?: number;
size?: number;
mtimeMs: number;
size: number;
}

// 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.
// `fs.watch` or polling). If a file is modified immediately after `watcher.add()` is called,
// Chokidar may capture the post-modification state as its initial baseline (with
// `ignoreInitial: true`) and miss the change. Re-statting after a short delay catches any
// modifications that occurred during asynchronous watcher registration, while `fileStats`
// deduplicates if Chokidar also fired.
async function checkChokidarBaselines(
baselineStats: ReadonlyMap<string, ChokidarBaselineEntry>,
watchedFiles: ReadonlySet<string>,
fileStats: ReadonlyMap<string, { mtimeMs: number; size: number }>,
watcher: Chokidar.FSWatcher,
handleEvent: (type: 'added' | 'modified' | 'removed', rawPath: string) => void,
): Promise<void> {
await Promise.all(
Array.from(baselineStats, async ([lookupKey, baseline]) => {
if (!watchedFiles.has(lookupKey)) {
if (watcher.closed || !watchedFiles.has(lookupKey)) {
return;
}
const currentStat = await fs.promises.stat(baseline.posixPath).catch(() => null);
if (!watchedFiles.has(lookupKey) || !currentStat?.isFile()) {
if (watcher.closed || !watchedFiles.has(lookupKey) || !currentStat?.isFile()) {
return;
}

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) {
if (currentStat.mtimeMs !== baseline.mtimeMs || currentStat.size !== baseline.size) {
handleEvent('modified', path.normalize(baseline.posixPath));
}
}),
Expand Down Expand Up @@ -1206,8 +1197,8 @@ async function createChokidarWatcher(
ignoreInitial: true,
ignored,
followSymlinks: options?.followSymlinks,
usePolling: !!options?.polling,
interval: options?.interval,
backend: options?.polling ? 'polling' : 'auto',
Comment thread
clydin marked this conversation as resolved.
pollingInterval: options?.interval,
});

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

const scheduleBaselineCheck = (baselineStats: ReadonlyMap<string, ChokidarBaselineEntry>) => {
if (baselineStats.size === 0) {
if (baselineStats.size === 0 || watcher.closed) {
return;
}

const timer = setTimeout(() => {
pendingTimers.delete(timer);
void checkChokidarBaselines(baselineStats, watchedFiles, fileStats, watcher, handleEvent);
void checkChokidarBaselines(baselineStats, watchedFiles, 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', (rawPath) => {
const posixPath = toPosixPathNormalized(rawPath);
const lookupKey = toLookupKey(posixPath, isCaseSensitive);
if (!isPathWatched(lookupKey, watchedFiles)) {
return;
}

// 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);
});
watcher.on('unlink', (path) => handleEvent('removed', path));

return {
[Symbol.asyncIterator]() {
Expand All @@ -1324,6 +1284,10 @@ async function createChokidarWatcher(
},

add(paths) {
if (watcher.closed) {
return;
}

const targets = typeof paths === 'string' ? [paths] : paths;
const newPaths: string[] = [];
const baselineStats = new Map<string, ChokidarBaselineEntry>();
Expand All @@ -1347,6 +1311,10 @@ async function createChokidarWatcher(
},

remove(paths) {
if (watcher.closed) {
return;
}

const targets = typeof paths === 'string' ? [paths] : paths;
const removePaths: string[] = [];
for (const p of targets) {
Expand All @@ -1368,6 +1336,8 @@ async function createChokidarWatcher(
clearTimeout(timer);
}
pendingTimers.clear();
watchedFiles.clear();
fileStats.clear();
try {
await watcher.close();
} finally {
Expand Down
8 changes: 8 additions & 0 deletions packages/angular/build/src/utils/watcher_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,14 @@ describe('Watcher', () => {
expect(result.done).toBeTrue();
});

it('should not throw if add() or remove() is called after close()', async () => {
const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir });
await watcher.close();

expect(() => watcher.add(path.join(tempDir, 'main.ts'))).not.toThrow();
expect(() => watcher.remove(path.join(tempDir, 'main.ts'))).not.toThrow();
});

it('should ignore stale modifications before initTime and emit changes after initTime (@parcel/watcher)', async () => {
const testFile = path.join(tempDir, 'test.txt');
fs.writeFileSync(testFile, 'initial');
Expand Down
Loading