Skip to content

Commit 3680773

Browse files
alan-agius4clydin
authored andcommitted
fix(@angular/build): deduplicate spurious chokidar polling change events (#34188)
When using Chokidar in polling mode, non-atomic file writes (such as open with O_TRUNC followed by write) can span across poll intervals and cause Chokidar to emit two 'change' events for a single file modification. If the initial rebuild completes quickly (e.g. in JIT mode), the trailing poll event can flush a second rebuild batch with the same file state. Track the last known mtimeMs and size of watched files in createChokidarWatcher and ignore 'modified' events when the file's mtimeMs and size have not changed since the previous queued or flushed event. PR Close #34188
1 parent ae6a2eb commit 3680773

1 file changed

Lines changed: 47 additions & 10 deletions

File tree

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

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,7 @@ async function createChokidarWatcher(
754754
): Promise<BuildWatcher> {
755755
const chokidar = chokidarModule ?? (await import('chokidar'));
756756
const watchedFiles = new Set<string>();
757+
const fileStats = new Map<string, { mtimeMs: number; size: number }>();
757758

758759
let queue: WatcherQueue;
759760
if (options?.polling) {
@@ -813,16 +814,36 @@ async function createChokidarWatcher(
813814
return;
814815
}
815816

816-
if (type !== 'removed') {
817-
const stat = fs.statSync(rawPath, { throwIfNoEntry: false });
818-
// Ignore historical events from before watcher initialization, but allow a 1000 ms window
819-
// to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second
820-
// mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes.
821-
if (stat && stat.mtimeMs < initTime - 1000) {
822-
return;
823-
}
817+
if (type === 'removed') {
818+
fileStats.delete(lookupKey);
819+
queue.addChange(type, rawPath);
820+
821+
return;
822+
}
823+
824+
const stat = fs.statSync(rawPath, { throwIfNoEntry: false });
825+
if (!stat) {
826+
return;
824827
}
825828

829+
// Ignore historical events from before watcher initialization, but allow a 1000 ms window
830+
// to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second
831+
// mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes.
832+
if (stat.mtimeMs < initTime - 1000) {
833+
return;
834+
}
835+
836+
const previousStat = fileStats.get(lookupKey);
837+
if (
838+
type === 'modified' &&
839+
previousStat &&
840+
previousStat.mtimeMs === stat.mtimeMs &&
841+
previousStat.size === stat.size
842+
) {
843+
return;
844+
}
845+
846+
fileStats.set(lookupKey, { mtimeMs: stat.mtimeMs, size: stat.size });
826847
queue.addChange(type, rawPath);
827848
};
828849

@@ -835,8 +856,23 @@ async function createChokidarWatcher(
835856
return this;
836857
},
837858

838-
next() {
839-
return queue.next();
859+
async next() {
860+
const result = await queue.next();
861+
if (result.value) {
862+
const files = [...result.value.added, ...result.value.modified];
863+
await Promise.all(
864+
files.map(async (file) => {
865+
const stat = await fs.promises.stat(file).catch(() => undefined);
866+
if (stat?.isFile()) {
867+
const posixPath = toPosixPathNormalized(file);
868+
const lookupKey = toLookupKey(posixPath, isCaseSensitive);
869+
fileStats.set(lookupKey, { mtimeMs: stat.mtimeMs, size: stat.size });
870+
}
871+
}),
872+
);
873+
}
874+
875+
return result;
840876
},
841877

842878
add(paths) {
@@ -875,6 +911,7 @@ async function createChokidarWatcher(
875911
const lookupKey = toLookupKey(posixPath, isCaseSensitive);
876912
if (watchedFiles.has(lookupKey)) {
877913
watchedFiles.delete(lookupKey);
914+
fileStats.delete(lookupKey);
878915

879916
// When the last watched file in a package is removed, unwatch the package directory.
880917
const { isPackage, unwatchPkgDir } = nodeModulesManager.removePackageFile(

0 commit comments

Comments
 (0)