#15677·rspack

[Bug]: Bundled Watchpack falsely removes directory case aliases on macOS (2.2.4)

Author: matthewdavis-oaiCreated Sep 14, 2026Updated Sep 15, 2026

System Info

  • macOS 26.6.2 (25G83), arm64
  • Node.js 25.9.0
  • Case-insensitive filesystem: native realpath confirms that Widget and widget resolve to the same directory
  • Reproduced with the Watchpack bundles published in @rspack/[email protected] and @rspack/[email protected]
  • Rspack 2.2.4 was the latest stable release checked on September 14, 2026; its package metadata lists bundled Watchpack 2.5.2
  • Also reproduced against Watchpack main at a8c0f786c15029cb74e8da31bf784aa74249378a

Details

The bundled Watchpack can report an existing directory as removed when a new file-dependency watcher attaches using a different capitalization after the parent directory scan has completed.

For example, these names coexist normally on a case-insensitive filesystem:

Widget.js
widget/

An extensionless lookup of Widget opens widget/. However, readdir returns its stored spelling, widget. Watchpack stores that spelling in DirectoryWatcher.directories, then performs an exact-string lookup when attaching a watcher for Widget. It incorrectly emits initial-missing, which the public Watchpack API surfaces as remove with reason watch (missing on attach).

This matters when a build tool tracks extensionless resolution candidates as file dependencies. Rspack's NodeWatchFileSystem.watch passes file dependencies to Watchpack and forwards aggregated removals to the compiler's watch callback. False removals on successive attachments can therefore cause repeated invalidation/rebuilds; polling makes it easier for scans to complete while a build is running.

The reproduction below isolates the false removal in Rspack's actual bundled Watchpack. It does not run a full Rspack compiler build or claim an end-to-end rebuild-loop test.

Upgrading from 2.1.8 to 2.2.4 does not change this implementation: their entire compiled/watchpack/index.js files are byte-for-byte identical, with SHA-256 e0613c9d9666f749e5d04d370844ba904842b866cb86acafd4a8be9e09a009ff.

Reproduce Steps

  1. Use a case-insensitive filesystem, such as the tested macOS filesystem.

  2. In an empty directory, install the affected package:

    bash
    npm init -y
    npm install --ignore-scripts @rspack/[email protected]
  3. Save the following as repro.cjs in that directory and run node repro.cjs. The script loads the bundled Watchpack directly, waits for the initial parent scan, and attaches the directory alias three times while keeping a sibling watcher alive. No filesystem edits occur during these attachments.

    Alternatively, pass an absolute path to an unpacked package's compiled/watchpack/index.js: node repro.cjs /absolute/path/to/compiled/watchpack/index.js. This is how the published bundles were tested here; the reproduction does not need Rspack's native binding.

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

Actual result

For both poll: false and poll: 50, the script reports sameDirectory: true and three removals:

json
{
  "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.

Expected result

removals: [] in both modes because the directory still exists and no files or directories changed.

Root cause and fix direction

The same check is present in Watchpack's current source: the attachment path uses !this.directories.has(target) before emitting initial-missing.

A fix validated locally keeps the exact-match fast path, finds a differently cased directory candidate, and compares the two native realpaths before reporting a removal. Case folding should only select a candidate: simply lowercasing the map lookup could suppress legitimate missing-path notifications on case-sensitive filesystems. Realpath errors or distinct canonical paths should preserve the missing notification, and asynchronous callbacks should avoid emitting after their subscription closes.

Validation of that approach against Watchpack main:

  • The inline reproduction produces zero false removals with native watching and polling.
  • Added regression coverage checks matching and distinct directory identities, realpath errors, closed subscriptions, start-time handling, and unchanged fast paths.
  • Real-filesystem tests cover repeated reattachment in both modes.
  • All 209 tests across 14 suites pass on macOS, along with lint, source/test type checks, declaration checks, and formatting.

Related Watchpack PR #229 concerns file timestamp entries with different casing; this report concerns the directory-attachment initial-missing event.

Could this be coordinated with Watchpack upstream and the corrected Watchpack bundled into a future Rspack release?