Skip to content

Runaway fs.watch retry loop behind dangling symlinks on Linux with followSymlinks: false #342

Description

@bayandin

Have you used AI?

Yes

Bug Description

On Linux, watching a file through a dangling directory symlink with followSymlinks: false (the default) causes Watchpack to repeatedly call fs.watch while the target directory is absent, instead of waiting for it to become available.

Link to Minimal Reproduction and step to reproduce

Steps:

  1. npm install watchpack@2.5.2
  2. Save the reproducer as repro.js
  3. Run node repro.js
  4. Observe the reported fs.watch attempt count
const fs = require("fs"), os = require("os"), path = require("path"), Watchpack = require("watchpack");

// Count every fs.watch call Watchpack makes.
let attempts = 0;
const realWatch = fs.watch;
fs.watch = (...args) => { attempts++; return realWatch(...args); };

// Create a dangling symlink and watch a file behind it.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "watchpack-dangling-"));
const target = path.join(dir, "target");
const link = path.join(dir, "link");
const file = path.join(link, "file.txt");
fs.symlinkSync(target, link);

const watcher = new Watchpack({ followSymlinks: false });
let targetCreated = false;
watcher.on("change", changed => { targetCreated = changed === file; });
watcher.watch({ files: [file] });

// Create the missing target after 1 second.
setTimeout(() => {
  fs.mkdirSync(target);
  fs.writeFileSync(path.join(target, "file.txt"), "created");
}, 1000);

// Stop after 3 seconds and report the result.
setTimeout(() => {
  watcher.close();
  fs.rmSync(dir, { recursive: true, force: true });

  console.log(`fs.watch attempts: ${attempts}`);
  console.log(`target creation reported: ${targetCreated}`);
  process.exitCode = attempts > 20 || !targetCreated ? 1 : 0;
}, 3000);

Expected Behavior

Watchpack should keep retry behaviour bounded while the target is absent, avoid a tight loop of fs.watch calls, and resume watching when the requested file becomes available.

Actual Behavior

Using upstream source at 2b27b7d on Linux with Node v24.14.0, the three-second run recorded approximately 16,405 fs.watch attempts. Target creation was also reported. The script creates the target after one second and stops after three seconds; the count is the total for the run.

The installation steps above use the published watchpack@2.5.2 package. The recorded Linux count is from the upstream source checkout, which is a separate revision from that release.

On macOS, the same script recorded 8 attempts and no target-creation notification during the three-second test.

Environment

System:
  OS: Linux 7.0.0-1012-aws x86_64
Binaries:
  Node: v24.14.0
  npm: 11.9.0
Watchpack:
  npm release in reproduction instructions: 2.5.2
  upstream revision used for recorded Linux count: 2b27b7d

Is this a regression?

No

We have not established whether this worked in an earlier version.

Last Working Version

Unknown.

Additional Context

We encountered this in a Rspack development server in a Bazel-based workspace. Rspack bundles Watchpack, and Bazel can create dangling directory symlinks that later become valid.

High CPU usage and V8 OOMs were reported in affected larger builds. This minimal example measures watch attempts, not CPU utilisation or memory consumption, and does not establish the cause of those OOMs.

Setting followSymlinks: true avoided the tight retry loop in the tested Linux scenario. However, it watches both symlinks and their targets, changing watcher behaviour, so it is a workaround rather than an equivalent fix.

For historical context, a comment on #185 describes a LinkResolver ENOENT failure involving Bazel symlink paths. That is a different failure mode from the retry loop reported here.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions