Problem
On a case-insensitive filesystem, attaching a file-dependency watcher to Widget can emit remove even though that path resolves to an existing directory named widget. The false event occurs when the parent directory has already been scanned. Reattaching the watcher repeats it without any filesystem changes.
The reproduction uses Rspack's bundled Watchpack directly. It demonstrates a false removal notification; it does not run a compiler or demonstrate a rebuild loop.
Tested environment
- macOS 26.6.2 (25G83), arm64; Node.js 25.9.0.
- A case-insensitive filesystem; the script checks that both spellings have the same native realpath.
- The Watchpack bundles in
@rspack/core@2.1.8 and @rspack/core@2.2.4.
- Also reproduced against Watchpack commit
a8c0f786c15029cb74e8da31bf784aa74249378a.
These are the versions tested for the original report, not a claim about later releases.
Reproduction
In an empty directory on a case-insensitive filesystem:
npm init -y
npm install --ignore-scripts @rspack/core@2.2.4
node repro.cjs
Save the script below as repro.cjs before running it. It creates Widget.js beside widget/, keeps the sibling watcher alive while the parent scan completes, then attaches and detaches the Widget path three times. It runs once with native watching and once with polling. No native Rspack binding is needed.
Expected: sameDirectory: true and removals: [] in both modes.
Actual: each mode reports three removals of Widget, all with reason watch (missing on attach), despite no intervening filesystem edits:
{"poll":false,"sameDirectory":true,"removals":[{"file":"Widget","reason":"watch (missing on attach)"},{"file":"Widget","reason":"watch (missing on attach)"},{"file":"Widget","reason":"watch (missing on attach)"}]}
The second output has the same removals with poll: 50.
Source and possible fix
DirectoryWatcher.watch checks this.directories.has(target) using the requested spelling. The parent scan stored the spelling returned by readdir, so the lookup misses and emits initial-missing, surfaced by Watchpack as remove.
One possible fix is to retain the exact-match fast path and compare native realpaths when a differently cased directory is a candidate. Case folding alone cannot establish that two paths identify the same directory on a case-sensitive filesystem. Errors, distinct realpaths, and subscriptions closed before the check completes must retain correct behavior. No upstream patch is attached here.
Could the directory-attachment check be fixed in Watchpack and the correction included in Rspack's bundle? Watchpack PR #229 concerns differently cased file timestamp entries; this report concerns directory attachment.
repro.cjs
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { setTimeout: delay } = require('node:timers/promises');
const watchpackPath = process.argv[2] || path.join(
path.dirname(require.resolve('@rspack/core/package.json')),
'compiled/watchpack/index.js',
);
const Watchpack = require(watchpackPath);
async function reproduce(poll) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'watchpack-case-'));
const source = path.join(root, 'Widget.js');
const directory = path.join(root, 'widget');
const alias = path.join(root, 'Widget');
fs.writeFileSync(source, 'module.exports = 1;');
fs.mkdirSync(directory);
const wp = new Watchpack({ poll, aggregateTimeout: 10 });
const removals = [];
wp.on('remove', (file, reason) => {
removals.push({ file: path.basename(file), reason });
});
try {
const sameDirectory = fs.existsSync(alias) &&
fs.realpathSync.native(alias) === fs.realpathSync.native(directory);
if (!sameDirectory) throw new Error('Run on a case-insensitive filesystem');
wp.watch({ files: [source] });
const dw = wp.fileWatchers.get(source).watcher.directoryWatcher;
await new Promise(resolve => {
const original = dw.onScanFinished;
dw.onScanFinished = function () {
this.onScanFinished = original;
original.call(this);
resolve();
};
});
// Reuse the completed parent scan while attaching the directory alias.
// No files or directories are changed during these three iterations.
for (let i = 0; i < 3; i++) {
wp.watch({ files: [source, alias], startTime: 1 });
await delay(100);
wp.watch({ files: [source] });
}
console.log(JSON.stringify({ poll, sameDirectory, removals }));
} finally {
wp.close();
fs.rmSync(root, { recursive: true, force: true });
}
}
(async () => {
await reproduce(false);
await reproduce(50);
})().catch(error => {
console.error(error);
process.exitCode = 1;
});
Problem
On a case-insensitive filesystem, attaching a file-dependency watcher to
Widgetcan emitremoveeven though that path resolves to an existing directory namedwidget. The false event occurs when the parent directory has already been scanned. Reattaching the watcher repeats it without any filesystem changes.The reproduction uses Rspack's bundled Watchpack directly. It demonstrates a false removal notification; it does not run a compiler or demonstrate a rebuild loop.
Tested environment
@rspack/core@2.1.8and@rspack/core@2.2.4.a8c0f786c15029cb74e8da31bf784aa74249378a.These are the versions tested for the original report, not a claim about later releases.
Reproduction
In an empty directory on a case-insensitive filesystem:
Save the script below as
repro.cjsbefore running it. It createsWidget.jsbesidewidget/, keeps the sibling watcher alive while the parent scan completes, then attaches and detaches theWidgetpath three times. It runs once with native watching and once with polling. No native Rspack binding is needed.Expected:
sameDirectory: trueandremovals: []in both modes.Actual: each mode reports three removals of
Widget, all with reasonwatch (missing on attach), despite no intervening filesystem edits:{"poll":false,"sameDirectory":true,"removals":[{"file":"Widget","reason":"watch (missing on attach)"},{"file":"Widget","reason":"watch (missing on attach)"},{"file":"Widget","reason":"watch (missing on attach)"}]}The second output has the same removals with
poll: 50.Source and possible fix
DirectoryWatcher.watchchecksthis.directories.has(target)using the requested spelling. The parent scan stored the spelling returned byreaddir, so the lookup misses and emitsinitial-missing, surfaced by Watchpack asremove.One possible fix is to retain the exact-match fast path and compare native realpaths when a differently cased directory is a candidate. Case folding alone cannot establish that two paths identify the same directory on a case-sensitive filesystem. Errors, distinct realpaths, and subscriptions closed before the check completes must retain correct behavior. No upstream patch is attached here.
Could the directory-attachment check be fixed in Watchpack and the correction included in Rspack's bundle? Watchpack PR #229 concerns differently cased file timestamp entries; this report concerns directory attachment.
repro.cjs