Windows: false change events for watched files when many sibling files are written
Windows: false change events for watched files when many sibling files are written
Describe the bug
On Windows, chokidar can emit high-level change events for watched files even when the watched files' mtimeMs, ctimeMs, and size have not changed.
I can reproduce this by watching many files directly and then writing generated sibling files in the same directory. In the example below, chokidar watches 300 *.scss files and then writes 300 sibling *.scss.d.ts files next to them.
The generated *.scss.d.ts files are not watched paths, but chokidar still emits change for the watched *.scss paths.
Versions
- Chokidar version: 5.0.0
- Node version: v22.14.0
- OS version: Windows_NT 10.0.19045
- Platform: win32
- Filesystem: local Windows filesystem
To reproduce
npm install [email protected] --save-exact
node repro.mjs --files=300 --wait=2500 --rewrite-on-change=falseimport fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { createRequire } from 'node:module';
import chokidar from 'chokidar';
const require = createRequire(import.meta.url);
const chokidarEntry = require.resolve('chokidar');
const chokidarPkgPath = path.join(path.dirname(chokidarEntry), 'package.json');
const chokidarPkg = JSON.parse(fs.readFileSync(chokidarPkgPath, 'utf8'));
const args = new Map(
process.argv.slice(2).map((arg) => {
const [key, value = 'true'] = arg.replace(/^--/, '').split('=');
return [key, value];
}),
);
const fileCount = Number(args.get('files') ?? 300);
const waitMs = Number(args.get('wait') ?? 2500);
const workspace = path.resolve(args.get('workspace') ?? './tmp-watched');
const watchedDir = path.join(workspace, 'watched');
const rewriteOnChange = args.get('rewrite-on-change') !== 'false';
fs.rmSync(workspace, { recursive: true, force: true });
fs.mkdirSync(watchedDir, { recursive: true });
const files = [];
const previousStats = new Map();
for (let i = 0; i < fileCount; i += 1) {
const file = path.join(watchedDir, `file${i}.scss`);
fs.writeFileSync(file, `.class-${i} { color: red; }\n`);
const stat = fs.statSync(file);
files.push(file);
previousStats.set(file, {
mtimeMs: stat.mtimeMs,
ctimeMs: stat.ctimeMs,
atimeMs: stat.atimeMs,
size: stat.size,
});
}
let rawScssEvents = 0;
let rawGeneratedEvents = 0;
let highLevelAddEvents = 0;
let highLevelChangeEvents = 0;
let generatedWritesFromChange = 0;
const samples = [];
function eventPathKind(value) {
const normalized = String(value ?? '').replaceAll('\\', '/');
if (normalized.endsWith('.scss.d.ts')) return 'generated';
if (normalized.endsWith('.scss')) return 'scss';
return 'other';
}
const watcher = chokidar.watch(files, { ignoreInitial: false });
watcher.on('raw', (_event, eventPath) => {
const kind = eventPathKind(eventPath);
if (kind === 'scss') rawScssEvents += 1;
if (kind === 'generated') rawGeneratedEvents += 1;
});
watcher.on('add', () => {
highLevelAddEvents += 1;
});
watcher.on('change', (changedPath) => {
highLevelChangeEvents += 1;
const stat = fs.statSync(changedPath);
const previous = previousStats.get(changedPath);
if (samples.length < 5) {
samples.push({
file: path.basename(changedPath),
previous,
current: {
mtimeMs: stat.mtimeMs,
ctimeMs: stat.ctimeMs,
atimeMs: stat.atimeMs,
size: stat.size,
},
mtimeUnchanged: previous?.mtimeMs === stat.mtimeMs,
ctimeUnchanged: previous?.ctimeMs === stat.ctimeMs,
sizeUnchanged: previous?.size === stat.size,
});
}
if (rewriteOnChange) {
fs.writeFileSync(`${changedPath}.d.ts`, 'export {};\n');
generatedWritesFromChange += 1;
}
});
await new Promise((resolve) => watcher.on('ready', resolve));
for (const file of files) {
fs.writeFileSync(`${file}.d.ts`, 'export {};\n');
}
await new Promise((resolve) => setTimeout(resolve, waitMs));
await watcher.close();
console.log(JSON.stringify({
environment: {
platform: process.platform,
osType: os.type(),
osRelease: os.release(),
node: process.version,
chokidar: chokidarPkg.version,
},
result: {
rawScssEvents,
rawGeneratedEvents,
highLevelAddEvents,
highLevelChangeEvents,
generatedWritesFromChange,
},
samples,
}, null, 2));Observed result without rewrite amplification
With --rewrite-on-change=false:
{
"rawScssEvents": 0,
"rawGeneratedEvents": 0,
"highLevelAddEvents": 300,
"highLevelChangeEvents": 300,
"generatedWritesFromChange": 0
}Sample watched file stats from one high-level change event:
{
"file": "file0.scss",
"previous": {
"mtimeMs": 1781776903285.5947,
"ctimeMs": 1781776903285.5947,
"atimeMs": 1781776903285.5947,
"size": 26
},
"current": {
"mtimeMs": 1781776903285.5947,
"ctimeMs": 1781776903285.5947,
"atimeMs": 1781776903285.5947,
"size": 26
},
"mtimeUnchanged": true,
"ctimeUnchanged": true,
"sizeUnchanged": true
}Observed result with rewrite amplification
If the consumer writes changedPath + '.d.ts' in response to each high-level change, the false positive can feed back into the same sidecar-file workflow:
node repro.mjs --files=300 --wait=2500{
"rawScssEvents": 0,
"rawGeneratedEvents": 0,
"highLevelAddEvents": 300,
"highLevelChangeEvents": 4517,
"generatedWritesFromChange": 4517
}Expected behavior
If a watched file's mtimeMs, ctimeMs, and size are unchanged, writing sibling files should not emit a high-level change event for that watched file.
Additional context
This came up in a generated sidecar-file workflow: watch *.scss, then write generated *.scss.d.ts files next to them. A single false change can make the consumer write the generated file again, which creates another burst of sibling writes and amplifies the loop.
This does not look like a glob issue. The repro passes an explicit array of watched *.scss file paths to chokidar.
If this is something chokidar should handle, I am happy to follow up with a small PR. I would probably start with a regression test, then try a conservative stats guard for unchanged watched files.
Source: paulmillr/chokidar