[rush-daemon] Use directory-level async watchers on Linux - #6089
Merged
Sean Larkin (TheLarkInn) merged 3 commits intoSep 24, 2026
Merged
Conversation
Fixes #6078 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Directory replacement races, incomplete error handling, and exclusion-loading gaps can leave Linux watches incomplete or incorrectly active.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 5
Open (5)
What changed in this PR
Replaces Linux recursive fs.watch usage with asynchronous directory-level watchers to reduce event-loop blocking and inotify consumption.
Changes:
- Adds Linux-specific tree watching with pruning and dynamic directory tracking.
- Integrates project output exclusions and expands watcher tests.
- Adds a patch change entry.
| File | Description |
|---|---|
libraries/rush-daemon/src/LinuxTreeWatcher.ts |
Implements the Linux directory watcher. |
libraries/rush-daemon/src/WorkspaceSessionFileWatcher.ts |
Selects the Linux watcher and loads exclusions. |
libraries/rush-daemon/src/test/LinuxTreeWatcher.test.ts |
Tests traversal, pruning, errors, and events. |
libraries/rush-daemon/src/test/WorkspaceSessionFileWatcher.test.ts |
Tests project exclusion discovery. |
common/changes/@rushstack/rush-daemon/linux-tree-watcher_2026-09-24.json |
Records the patch change. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Forward unknown filenames, re-check exclusions after they load, re-register replaced directories, fail on non-transient walk errors, and keep the .rush/temp exclusion when rush-project.json cannot be loaded. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Mo Jazayeri (mojaza)
approved these changes
Sep 24, 2026
Sean Larkin (TheLarkInn)
deleted the
thelarkinn-fix-rushd-linux-watcher-cost
branch
September 24, 2026 21:16
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
With
RUSH_DAEMON_WATCH=1on Linux, rushd used Node's recursivefs.watch. Node emulates that on Linux with one inotify watch per file, includingnode_modulesentries and build outputs, and walks the tree synchronously on the event loop. This PR replaces it on Linux with non-recursive per-directory watches created by an async walk. macOS and Windows are unchanged.Root cause
WorkspaceSessionFileWatcherdefaulted tofs.watch(folder, { recursive: true }). On Linux (internal/fs/recursive_watch), that:statSync/readdirSyncfor the whole project tree insidefs.watch(). rushd makes that call fromwatchProjects()in the warm-setconfigureIterationhook, so every daemon client (status, queue feedback, other builds' output) freezes while newly requested projects are walked (Removed unused @types/chalk references #232:daemon statusspikes to 5.7 s during alib/cache restore);renamein the tree, including thousands of output files, so it can hitfs.inotify.max_user_watches;ENOENTwhen an entry disappears between readdir and stat (Make quiet the default, add --verbose #111). That error marks the watcher unhealthy for the rest of the session.Fix
LinuxTreeWatcher(src/LinuxTreeWatcher.ts). It isfs.FSWatcher-compatible (emitserror/close, supportsref/unref):fs.watchper directory (an inotify watch on a directory already reports its files) using an asyncopendirwalk. The root is registered synchronously, so a missing root still throws likefs.watch.node_modulesand.gitat any depth. For project watchers, it also prunes the project's.rush/tempand every declaredoperationSettings[].outputFolderNamesfromrush-project.json, loaded through the publicRushProjectConfigurationAPI. It only prunes folders strictly inside the project, and a load failure just means nothing extra is pruned. Events for pruned paths are suppressed.renameevent triggerslstat, then a watch and walk of the new directory, then a coverage event for it. Each watch is created before its directory is listed, so files written during the walk are not lost. Removed directories drop their watch and every watch below it.ENOENT/ENOTDIR/EACCES/EPERMwhile registering or observing a child directory as the directory having gone away. The parent'srenameevent already reports the change, so the watcher is not marked unhealthy.ENOSPConce, as an explicit error namingfs.inotify.max_user_watchesand the remedy, and stops registering. The existingonErrorpath then marks the watcher unhealthy, so the daemon falls back to full invalidation.WorkspaceSessionFileWatcher: when nowatchFactoryis injected, Linux recursive paths useLinuxTreeWatcher. Project watchers report their root once the async walk finishes, to cover the registration window.startAsyncwaits for the (small) permanentcommon/configwalks before initialization is acknowledged. InjectedwatchFactorybehavior and the public API are unchanged, so no API report change is needed.Tests
LinuxTreeWatcher.test.ts(9 tests, uses a fake directory-watch function over a real temp tree, so it is deterministic on every OS):ENOENTwithout an errorENOSPConce, with an explicit messagecloseWorkspaceSessionFileWatcher.test.ts: covers the project exclusion set.rush build --to @rushstack/rush-daemon, including lint, succeeds with no warnings.heft test --test-path-pattern "LinuxTreeWatcher|WorkspaceSessionFileWatcher|WorkspaceWatchPolicy|WorkspaceWarmSet"passes 41/41.WorkspaceWatchPolicyandWorkspaceWarmSetexercise the new default watcher against real files.Linux validation: watch counts
Standalone measurement in one process that watches all 196 rushstack projects of a lab clone, which was built only up to
@rushstack/rush-daemon, so outputs exist only for part of the graph. The script loads the builtlib-commonjsfrom this PR and uses realrush-project.jsonexclusions:node linux-watcher-count.js <clone> <proto|native>. It countsinotifylines in/proc/self/fdinfo.fs.watch(before)LinuxTreeWatcher(after)This matches the A07 prototype measurement on a fully built rushstack clone under heavy host load: 182 projects, 32,822 → 2,921 watches, with the event-loop block going from 100.9 s → 22 ms.
Linux validation: in-situ daemon repro
linux-watcher-repro.shruns both clients in onelabinvocation on a freshmkws-synth --projects 12workspace withRUSH_DAEMON_WATCH=1. "Before" is the unfixedrush-client; "after" isapps/rush-cli-client/bin/rush-clientfrom this branch. The steps are:p03/lib, an output folder.p05/src/newdir/deep/added.ts, a new nested source directory.The change inside the newly created nested directory is detected and triggers exactly the same rebuild set. Output writes no longer add watches.
The full
rush test --only @rushstack/rush-daemonsuite also passes on Linux.Follow-ups (not in this PR)
ENOSPCstate indaemon status(for examplewatcherUnhealthy: 'inotify-limit'), not only as the error message.outputFolderNameswhenrush-project.jsonchanges without a generation reload.Fixes #6078
This came out of the automated rushd Linux performance and behavior analysis ("Rushd Hive", bug #200; related #111, #211, #232).