fix(vfs): warn when the inotify watch limit leaves folders unwatched (#1918)

* fix(vfs): warn when the inotify watch limit leaves folders unwatched

When watch() hits the OS watch limit mid-tree (inotify ENOSPC/EMFILE), the affected directories were silently never watched: the project stayed stale with no hint. Record the failure on DirState and surface it through the new Watcher::unwatched_reason() — the host emits the 'sync may be slow' warning once the project opens and once on the first event batch after a late failure (deferred walk / update_watches).

Fixes #1915

* fix(vfs): latch the startup watch-limit warning so batches don't repeat it
This commit is contained in:
arookieofc authored and GitHub committed 2026-11-07 12:54:07 +08:00
1 parent b99fe1704b
commit 8d909598d5
2 files changed
+125 -7

No files matched your search

+38 -2
View File
@@ -56,7 +56,7 @@ pub struct Project {
pub notebooks: Arc<crate::notebook::NotebookService>,
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
pub(crate) phpstan: Option<Arc<crate::phpstan::Phpstan>>,
pub(crate) watcher: Watcher,
pub(crate) watcher: Arc<Watcher>,
/// Watchers of the Project Structure content roots outside the project folder.
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
/// Watchers of folders outside the project that language servers registered file
@@ -305,6 +305,12 @@ impl Host {
for repo in repos.read().all() {
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
}
// Shared handle: `unwatched_reason` is a live query on the watcher,
// filled after `start` returns so the on-change callback can reach it.
let watcher_weak = Arc::new(std::sync::OnceLock::<std::sync::Weak<Watcher>>::new());
// One-shot latch for the post-start watch-limit warning (#1915): the
// synchronous startup walk and a later batch can both observe it.
let notify_watch_exhausted = Arc::new(AtomicBool::new(false));
let watcher = {
let kotlin_bridge = kotlin_bridge.clone();
let phpstan = phpstan.clone();
@@ -322,6 +328,8 @@ impl Host {
let lsp = lsp.clone();
let trusted = trusted.clone();
let build_cache = build_cache.clone();
let notify_watch_exhausted = notify_watch_exhausted.clone();
let watcher_weak = watcher_weak.clone();
Watcher::start(
&root.clone(),
excludes.clone(),
@@ -408,9 +416,26 @@ impl Host {
if git_dirs || vcs_moved {
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
}
// A watch limit hit after startup (per-directory mode, Linux):
// folders past the limit never sync — surface it once, like
// the polling fallback below (#1915). The check itself is
// deferred to here, on a batch that already had events.
if !notify_watch_exhausted.load(Ordering::Relaxed)
&& let Some(msg) =
watcher_weak.get().and_then(std::sync::Weak::upgrade).and_then(|w| w.unwatched_reason())
{
sink.emit(IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
title: "External file changes sync may be slow".into(),
body: Some(msg),
});
notify_watch_exhausted.store(true, Ordering::Relaxed);
}
}),
)?
};
let watcher = Arc::new(watcher);
let _ = watcher_weak.set(Arc::downgrade(&watcher));
if let Some(reason) = watcher.polling() {
sink.emit(IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
@@ -418,6 +443,17 @@ impl Host {
body: Some(reason.into()),
});
}
// A watch can also be refused part-way through the synchronous startup
// walk (large tree, SYNC_WATCH_BUDGET) — after start() returns, before
// any batch. Emit the same warning so the limit isn't silent there either.
if let Some(reason) = watcher.unwatched_reason() {
sink.emit(IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
title: "External file changes sync may be slow".into(),
body: Some(reason),
});
notify_watch_exhausted.store(true, Ordering::Relaxed);
}
// Scratch files live outside the project; watch them too so the
// Project view and open editors follow changes made there.
@@ -644,7 +680,7 @@ impl Host {
notebooks,
kotlin_bridge: None,
phpstan: None,
watcher,
watcher: Arc::new(watcher),
content_watchers: Mutex::new(Vec::new()),
lsp_watchers: Mutex::new(Vec::new()),
_scratch_watcher: None,
+87 -5
View File
@@ -117,6 +117,13 @@ impl Watcher {
self.polling.as_deref()
}
/// A watch failure past startup (the OS watch limit ran out mid-tree):
/// directories past the limit never sync (issue #1915). `None` while every
/// directory has a live watch.
pub fn unwatched_reason(&self) -> Option<String> {
self.inner.state.exhausted.lock().clone()
}
/// Replaces the exclude globs (Settings › Project Structure / File Types
/// changed): later events below newly excluded folders are dropped. In
/// per-directory mode, folders no longer excluded are watched once they change.
@@ -163,6 +170,7 @@ impl Watcher {
queue: Mutex::default(),
wake: Condvar::new(),
stop: AtomicBool::new(false),
exhausted: Mutex::new(None),
});
let weak: Weak<DirState> = Arc::downgrade(&state);
let watcher = W::new(
@@ -234,8 +242,9 @@ const MAX_WATCHES_HINT: &str = "the inotify watch limit is exhausted (fs.inotify
/// instance per watcher (`EMFILE`, os error 24) or one watch per directory
/// (`ENOSPC`, mapped by notify to [`ErrorKind::MaxFilesWatch`]). A bare OS
/// error sends users off to search engines otherwise (issue #788).
fn watcher_error(what: &str, error: notify::Error) -> IdeError {
let hint = match error.kind {
/// The fix hint when an OS watch limit is hit, or `None` for other errors.
fn watch_limit_hint(error: &notify::Error) -> Option<&'static str> {
match error.kind {
ErrorKind::MaxFilesWatch => Some(MAX_WATCHES_HINT),
ErrorKind::Io(ref e) if cfg!(target_os = "linux") && e.raw_os_error() == Some(24) => Some(
"the inotify instance limit is exhausted (fs.inotify.max_user_instances); raise it, e.g. \
@@ -243,8 +252,11 @@ fn watcher_error(what: &str, error: notify::Error) -> IdeError {
),
ErrorKind::Io(ref e) if cfg!(target_os = "linux") && e.raw_os_error() == Some(28) => Some(MAX_WATCHES_HINT),
_ => None,
};
match hint {
}
}
fn watcher_error(what: &str, error: notify::Error) -> IdeError {
match watch_limit_hint(&error) {
Some(hint) => io_error(format!("{what}: {error}; {hint}")),
None => io_error(format!("{what}: {error}")),
}
@@ -259,6 +271,10 @@ struct DirState {
queue: Mutex<Vec<(notify::Event, Instant)>>,
wake: Condvar,
stop: AtomicBool,
/// Set when a `watch()` call reports the OS watch limit (inotify
/// `ENOSPC`/`EMFILE`): later directories are never seen, so the project is
/// silently stale past that point — surfaced via [`Watcher::unwatched_reason`].
exhausted: Mutex<Option<String>>,
}
impl DirState {
@@ -362,7 +378,15 @@ impl DirState {
true
}
Err(e) => {
tracing::debug!("cannot watch {}: {e}", dir.display());
if let Some(hint) = watch_limit_hint(&e) {
tracing::warn!("cannot watch {}: {e}", dir.display());
// Recorded, not delivered: the watch may fail after start,
// on the debounce thread — the UI picks it up via
// `unwatched_reason()`.
*self.exhausted.lock() = Some(format!("cannot watch {}: {e}; {hint}", dir.display()));
} else {
tracing::debug!("cannot watch {}: {e}", dir.display());
}
false
}
}
@@ -1320,4 +1344,62 @@ mod tests {
let other = notify::Error::io(std::io::Error::from_raw_os_error(13));
assert!(!watcher_error("cannot start file watcher", other).message.contains("inotify"));
}
/// Watch calls succeed until `budget` is spent, then `ENOSPC` — mirrors
/// `fs.inotify.max_user_watches` running out part-way through a tree.
struct ExhaustedWatcher {
budget: std::sync::atomic::AtomicUsize,
}
impl notify::Watcher for ExhaustedWatcher {
fn new<F: notify::EventHandler>(_: F, _: notify::Config) -> notify::Result<Self> {
Ok(Self { budget: std::sync::atomic::AtomicUsize::new(3) })
}
fn watch(&mut self, _dir: &Path, _: RecursiveMode) -> notify::Result<()> {
if self.budget.fetch_sub(1, Ordering::SeqCst) == 0 {
return Err(notify::Error::new(ErrorKind::MaxFilesWatch));
}
Ok(())
}
fn unwatch(&mut self, _: &Path) -> notify::Result<()> {
Ok(())
}
fn kind() -> WatcherKind {
WatcherKind::NullWatcher
}
}
/// #1915: a watch refused mid-tree must not stay silent — the limit is
/// reported through `unwatched_reason` so the UI can warn like the
/// polling fallback does.
#[test]
fn unwatched_reason_reports_the_watch_limit() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
for d in ["a", "b", "c", "d"] {
std::fs::create_dir_all(root.join(d)).unwrap();
}
let filter = EventFilter::new(root.clone(), &[]);
let (excludes, rules) = (filter.excludes.clone(), filter.gitignore.clone());
let (tx, _rx) = std::sync::mpsc::channel::<FsBatch>();
let emit: Emit = Arc::new(move |b| {
let _ = tx.send(b.clone());
});
let inner = Watcher::start_queued::<ExhaustedWatcher>(
&root,
filter,
emit,
WatchMode::PerDirectory,
notify::Config::default(),
)
.unwrap();
let watcher = Watcher { inner, excludes, rules, polling: None };
assert!(watcher.polling().is_none(), "the OS watcher did start");
let reason = watcher.unwatched_reason().expect("watch limit must be reported");
assert!(reason.contains("max_user_watches"), "{reason}");
assert!(reason.contains("cannot watch"), "{reason}");
}
}