mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
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:
1 parent
b99fe1704b
commit
8d909598d5
2 files changed
+125
-7
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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: ¬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<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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user