Skip to content

fix: stop the fs.watch retry loop behind a dangling directory symlink - #346

Merged
alexander-akait merged 5 commits into
mainfrom
fix/dangling-symlink-retry-loop
Oct 13, 2026
Merged

alexander-akait merged 5 commits into
mainfrom
fix/dangling-symlink-retry-loop

Conversation

@alexander-akait

@alexander-akait alexander-akait commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Summary

Fixes #342.

Watching through a dangling directory symlink looped forever:

  1. fs.watch(link) follows the link and fails with ENOENT, so the watcher falls back to watchInParentDirectory() with startTime = 1.
  2. The parent directory does list link (an lstat of the symlink succeeds), so it reports it as "outdated on attach" right away.
  3. The handler retries createWatcher(), which fails again, and the cycle repeats.

The parent watcher now retries at once only when the path is really a directory. If the path exists but is not a directory yet (a dangling symlink, or a regular file), it re-checks once a second, because the path becoming a directory emits nothing on the path itself. The timer is cleared on close(). This also fixes a missing directory that reappears as a file: before, that logged Watchpack Error (initial scan): ENOTDIR and never reported files created after the path became a directory (as raised in review).

scenario (Linux, Node 24) before after
issue's reproducer: fs.watch calls in 3 s ~5,670 3
directory reappears as a file, then as a directory ENOTDIR logged, later file never reported no error, file reported

What kind of change does this PR introduce?

fix

Did you add tests for your changes?

Yes. test/Watchpack.test.js gets a "path that is not a directory yet" block: waiting behind a dangling symlink without a retry loop (3,812 fs.watch calls without the fix), a path that first appears as a file, and no further retries once the link is removed or the watcher is closed. test/DirectoryWatcherExtra.test.js covers both retry guards. Every changed line and branch is covered. The full suite passes with native watching (203) and with WATCHPACK_POLLING=200 (199).

Does this PR introduce a breaking change?

No.

If relevant, what needs to be documented once your changes are merged or what have you already documented?

n/a

Use of AI

Claude Code reproduced the loop, traced it to the parent-watcher retry, and wrote the fix and the tests, including the file case CodeRabbit raised. A maintainer asked for the fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_016ZdAw4de2GgWu4X7BMY8ys

Summary by CodeRabbit

  • Bug Fixes
    • Watching a path behind a dangling symlink or a path that is not yet a directory now retries once per second until it becomes a watchable directory.
    • Retries stop when the symlink is removed or the watcher is closed.

@changeset-bot

changeset-bot Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2f54d37

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
watchpack Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 14 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: ce058677-7fe6-4424-91e5-465eeb86a45b
📥 Commits

Reviewing files that changed from the base of the PR and between 88e3ef2 and 2f54d37.

📒 Files selected for processing (2)
  • lib/DirectoryWatcher.js
  • test/Watchpack.test.js

Walkthrough

DirectoryWatcher now retries watcher creation once per second when a watched path is not a directory. It creates and scans the watcher when the path becomes available, and clears pending retries when closed. The declarations include the retry method and timer property. Tests cover retry frequency, change reporting, link removal, and watcher closure.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: 🔵 Low · up to 88e3e

The change fixes the runaway fs.watch retry loop behind a dangling symlink. Two narrow edge cases remain: a link removed before a pending retry runs, and a symlink loop. The first can produce a spurious directory-created change and the second retries once per second without reporting an error. Neither blocks merge, but the owner should be aware of them.

Pre-merge checks · Passed 5
Passed Passed checks (5 passed)
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly identifies the main change: stopping the fs.watch retry loop behind a dangling directory symlink.
Linked Issues check Passed Issue [#342] requires bounded retries while a dangling directory symlink target is absent and resumed watching when the target appears. The PR adds delayed retry scheduling, prevents duplicate retry t…
Out of Scope Changes check Passed The changes stay within issue [#342]. lib/DirectoryWatcher.js implements the retry fix. The tests verify the required retry and recovery behavior. The declaration update describes the new internal m…
Docstring Coverage Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 4…
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 2678ecd2-a553-4bf1-a0cf-56d46f27de31
📥 Commits

Reviewing files that changed from the base of the PR and between 465afa5 and 281100b.

📒 Files selected for processing (4)
  • .changeset/dangling-symlink-retry-loop.md
  • lib/DirectoryWatcher.js
  • test/Watchpack.test.js
  • types/DirectoryWatcher.d.ts

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.

Comment thread lib/DirectoryWatcher.js
Comment thread test/Watchpack.test.js
@codspeed

codspeed Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

⚠️ 1 benchmark spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 36 untouched benchmarks


Comparing fix/dangling-symlink-retry-loop (2f54d37) with main (d951a39)

Open in CodSpeed

@codecov

codecov Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.45%. Comparing base (465afa5) to head (2f54d37).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #346      +/-   ##
==========================================
+ Coverage   92.24%   92.45%   +0.20%     
==========================================
  Files           8        8              
  Lines        1315     1338      +23     
  Branches      391      396       +5     
==========================================
+ Hits         1213     1237      +24     
+ Misses         96       95       -1     
  Partials        6        6              
Flag Coverage Δ
integration 92.45% <100.00%> (+0.20%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: b15cd850-bd9c-498a-a7ba-aae1aaaf4c61
📥 Commits

Reviewing files that changed from the base of the PR and between 281100b and c70d546.

📒 Files selected for processing (1)
  • test/Watchpack.test.js

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 0 remain after this review.

Comment thread test/Watchpack.test.js Outdated
Treat any path that is not a directory yet like a dangling symlink, and
cover both retry guards.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f771df8c-ad5b-4589-9d4c-9fef7e63f3bf
📥 Commits

Reviewing files that changed from the base of the PR and between c70d546 and 88e3ef2.

📒 Files selected for processing (4)
  • lib/DirectoryWatcher.js
  • test/DirectoryWatcherExtra.test.js
  • test/Watchpack.test.js
  • types/DirectoryWatcher.d.ts

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 0 remain after this review.

Comment thread lib/DirectoryWatcher.js Outdated
@alexander-akait
alexander-akait merged commit c5fb972 into main Oct 13, 2026
51 checks passed
@alexander-akait
alexander-akait deleted the fix/dangling-symlink-retry-loop branch October 13, 2026 14:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant