inotify alert (#15027)

Conrad Irwin created

Release Notes:

- linux: Show an error and troubleshooting steps for inotify limits
(#10310)

Change summary

crates/fs/src/fs.rs                   | 99 ++++++++++++++++++++--------
crates/worktree/src/worktree_tests.rs |  4 +
crates/zed/src/zed.rs                 | 19 +++++
docs/src/linux.md                     | 10 ++
4 files changed, 103 insertions(+), 29 deletions(-)

Detailed changes

crates/fs/src/fs.rs 🔗

@@ -140,10 +140,7 @@ pub struct RealFs {
     git_binary_path: Option<PathBuf>,
 }
 
-pub struct RealWatcher {
-    #[cfg(target_os = "linux")]
-    fs_watcher: parking_lot::Mutex<notify::INotifyWatcher>,
-}
+pub struct RealWatcher {}
 
 impl RealFs {
     pub fn new(
@@ -472,29 +469,29 @@ impl Fs for RealFs {
         let pending_paths: Arc<Mutex<Vec<PathBuf>>> = Default::default();
         let root_path = path.to_path_buf();
 
-        let file_watcher = notify::recommended_watcher({
+        watcher::global(|g| {
             let tx = tx.clone();
             let pending_paths = pending_paths.clone();
-            move |event: Result<notify::Event, _>| {
-                if let Some(event) = event.log_err() {
-                    let mut paths = event.paths;
-                    paths.retain(|path| path.starts_with(&root_path));
-                    if !paths.is_empty() {
-                        paths.sort();
-                        let mut pending_paths = pending_paths.lock();
-                        if pending_paths.is_empty() {
-                            tx.try_send(()).ok();
-                        }
-                        util::extend_sorted(&mut *pending_paths, paths, usize::MAX, PathBuf::cmp);
+            g.add(move |event: &notify::Event| {
+                let mut paths = event
+                    .paths
+                    .iter()
+                    .filter(|path| path.starts_with(&root_path))
+                    .cloned()
+                    .collect::<Vec<_>>();
+                if !paths.is_empty() {
+                    paths.sort();
+                    let mut pending_paths = pending_paths.lock();
+                    if pending_paths.is_empty() {
+                        tx.try_send(()).ok();
                     }
+                    util::extend_sorted(&mut *pending_paths, paths, usize::MAX, PathBuf::cmp);
                 }
-            }
+            })
         })
-        .expect("Could not start file watcher");
+        .log_err();
 
-        let watcher = Arc::new(RealWatcher {
-            fs_watcher: parking_lot::Mutex::new(file_watcher),
-        });
+        let watcher = Arc::new(RealWatcher {});
 
         watcher.add(path).ok(); // Ignore "file doesn't exist error" and rely on parent watcher.
 
@@ -622,18 +619,16 @@ impl Watcher for RealWatcher {
 impl Watcher for RealWatcher {
     fn add(&self, path: &Path) -> Result<()> {
         use notify::Watcher;
-
-        self.fs_watcher
-            .lock()
-            .watch(path, notify::RecursiveMode::NonRecursive)?;
-        Ok(())
+        Ok(watcher::global(|w| {
+            w.inotify
+                .lock()
+                .watch(path, notify::RecursiveMode::NonRecursive)
+        })??)
     }
 
     fn remove(&self, path: &Path) -> Result<()> {
         use notify::Watcher;
-
-        self.fs_watcher.lock().unwatch(path)?;
-        Ok(())
+        Ok(watcher::global(|w| w.inotify.lock().unwatch(path))??)
     }
 }
 
@@ -1795,3 +1790,49 @@ mod tests {
         );
     }
 }
+
+#[cfg(target_os = "linux")]
+pub mod watcher {
+    use std::sync::OnceLock;
+
+    use parking_lot::Mutex;
+    use util::ResultExt;
+
+    pub struct GlobalWatcher {
+        // two mutexes because calling inotify.add triggers an inotify.event, which needs watchers.
+        pub(super) inotify: Mutex<notify::INotifyWatcher>,
+        pub(super) watchers: Mutex<Vec<Box<dyn Fn(&notify::Event) + Send + Sync>>>,
+    }
+
+    impl GlobalWatcher {
+        pub(super) fn add(&self, cb: impl Fn(&notify::Event) + Send + Sync + 'static) {
+            self.watchers.lock().push(Box::new(cb))
+        }
+    }
+
+    static INOTIFY_INSTANCE: OnceLock<anyhow::Result<GlobalWatcher, notify::Error>> =
+        OnceLock::new();
+
+    fn handle_event(event: Result<notify::Event, notify::Error>) {
+        let Some(event) = event.log_err() else { return };
+        global::<()>(move |watcher| {
+            for f in watcher.watchers.lock().iter() {
+                f(&event)
+            }
+        })
+        .log_err();
+    }
+
+    pub fn global<T>(f: impl FnOnce(&GlobalWatcher) -> T) -> anyhow::Result<T> {
+        let result = INOTIFY_INSTANCE.get_or_init(|| {
+            notify::recommended_watcher(handle_event).map(|file_watcher| GlobalWatcher {
+                inotify: Mutex::new(file_watcher),
+                watchers: Default::default(),
+            })
+        });
+        match result {
+            Ok(g) => Ok(f(g)),
+            Err(e) => Err(anyhow::anyhow!("{}", e)),
+        }
+    }
+}

crates/worktree/src/worktree_tests.rs 🔗

@@ -841,6 +841,10 @@ async fn test_write_file(cx: &mut TestAppContext) {
     )
     .await
     .unwrap();
+
+    #[cfg(target_os = "linux")]
+    fs::watcher::global(|_| {}).unwrap();
+
     cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
         .await;
     tree.flush_fs_events(cx).await;

crates/zed/src/zed.rs 🔗

@@ -140,6 +140,25 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
         })
         .detach();
 
+        #[cfg(target_os = "linux")]
+        if let Err(e) = fs::watcher::global(|_| {}) {
+            let message = format!(db::indoc!{r#"
+                inotify_init returned {}
+
+                This may be due to system-wide limits on inotify instances. For troubleshooting see: https://zed.dev/docs/linux
+                "#}, e);
+            let prompt = cx.prompt(PromptLevel::Critical, "Could not start inotify", Some(&message),
+                &["Troubleshoot and Quit"]);
+            cx.spawn(|_, mut cx| async move {
+                if prompt.await == Ok(0) {
+                    cx.update(|cx| {
+                        cx.open_url("https://zed.dev/docs/linux#could-not-start-inotify");
+                        cx.quit();
+                    }).ok();
+                }
+            }).detach()
+        }
+
         if let Some(specs) = cx.gpu_specs() {
             log::info!("Using GPU: {:?}", specs);
             if specs.is_software_emulated && std::env::var("ZED_ALLOW_EMULATED_GPU").is_err() {

docs/src/linux.md 🔗

@@ -122,3 +122,13 @@ All of these features are provided by XDG desktop portals, specifically:
 - `org.freedesktop.portal.Secret`, or `org.freedesktop.Secrets`
 
 Some window managers, such as `Hyprland`, don't provide a file picker by default. See [this list](https://wiki.archlinux.org/title/XDG_Desktop_Portal#List_of_backends_and_interfaces) as a starting point for alternatives. `KDE` doesn't implement the secret portal, installing `gnome-keyring` may solve this.
+
+### Could not start inotify
+
+Zed relies on inotify to watch your filesystem for changes. If you cannot start inotify then Zed will not work reliably.
+
+If you are seeing "too many open files" then first try `sysctl fs.inotify`.
+*  You should see that max_user_instances is 128 or higher (you can change the limit with `sudo sysctl fs.inotify.max_user_instances=1024`). Zed needs only 1 inotify instance.
+* You should see that `max_user_watches` is 8000 or higher (you can change the limit with `sudo sysctl fs.inotify.max_user_watches=64000`). Zed needs one watch per directory in all your open projects + one per git repository + a handful more for settings, themes, keymaps, extensions.
+
+It is also possible that you are running out of file descriptors. You can check the limits with `ulimit` and update them by editing `/etc/security/limits.conf`.