From 8d909598d5344b93d1e1afe712311f0e09b9d319 Mon Sep 17 00:00:00 2001 From: arookieofc Date: Sat, 7 Nov 2026 12:54:07 +0800 Subject: [PATCH] fix(vfs): warn when the inotify watch limit leaves folders unwatched (#1918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- crates/ide-host/src/project.rs | 40 ++++++++++++++- crates/ide-vfs/src/watch.rs | 92 ++++++++++++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 2ca25b503..a6451cb3b 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -56,7 +56,7 @@ pub struct Project { pub notebooks: Arc, pub(crate) kotlin_bridge: Option>, pub(crate) phpstan: Option>, - pub(crate) watcher: Watcher, + pub(crate) watcher: Arc, /// Watchers of the Project Structure content roots outside the project folder. pub(crate) content_watchers: Mutex>, /// 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::>::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, diff --git a/crates/ide-vfs/src/watch.rs b/crates/ide-vfs/src/watch.rs index d00d8778a..a74b74933 100644 --- a/crates/ide-vfs/src/watch.rs +++ b/crates/ide-vfs/src/watch.rs @@ -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 { + 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 = 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: ¬ify::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>, 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>, } 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::Config) -> notify::Result { + 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::(); + let emit: Emit = Arc::new(move |b| { + let _ = tx.send(b.clone()); + }); + let inner = Watcher::start_queued::( + &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}"); + } }