Skip to content

[Bug]: Bundled Watchpack reports an existing differently cased directory as removed on macOS #15677

Description

@matthewdavis-oai

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;
});

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions