fs: Defer initializing poll watcher until after initial worktree scan (#56207)

Ben Kunkle created

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #56021
Closes #56100

Release Notes:

- N/A or Added/Fixed/Improved ...

Change summary

crates/worktree/src/worktree.rs           |  51 +++++++
crates/worktree/tests/integration/main.rs | 167 +++++++++++++++++++++++++
2 files changed, 214 insertions(+), 4 deletions(-)

Detailed changes

crates/worktree/src/worktree.rs 🔗

@@ -140,6 +140,7 @@ pub struct LocalWorktree {
     settings: WorktreeSettings,
     share_private_files: bool,
     scanning_enabled: bool,
+    force_defer_watch: bool,
 }
 
 pub struct PathPrefixScanRequest {
@@ -504,6 +505,7 @@ impl Worktree {
                 visible,
                 settings,
                 scanning_enabled,
+                force_defer_watch: false,
             };
             worktree.start_background_scanner(scan_requests_rx, path_prefixes_to_scan_rx, cx);
             Worktree::Local(worktree)
@@ -1151,6 +1153,7 @@ impl LocalWorktree {
         let next_entry_id = self.next_entry_id.clone();
         let fs = self.fs.clone();
         let scanning_enabled = self.scanning_enabled;
+        let force_defer_watch = self.force_defer_watch;
         let track_git_repositories = self.visible;
         let settings = self.settings.clone();
         let (scan_states_tx, mut scan_states_rx) = mpsc::unbounded();
@@ -1158,7 +1161,10 @@ impl LocalWorktree {
             let abs_path = snapshot.abs_path.as_path().to_path_buf();
             let background = cx.background_executor().clone();
             async move {
-                let (events, watcher) = if scanning_enabled {
+                let defer_watch =
+                    force_defer_watch || (scanning_enabled && fs::requires_poll_watcher(&abs_path));
+
+                let (events, watcher) = if scanning_enabled && !defer_watch {
                     fs.watch(&abs_path, FS_WATCH_LATENCY).await
                 } else {
                     (Box::pin(stream::pending()) as _, Arc::new(NullWatcher) as _)
@@ -1192,11 +1198,10 @@ impl LocalWorktree {
                     watcher,
                     track_git_repositories,
                     is_single_file,
+                    defer_watch,
                 };
 
-                scanner
-                    .run(Box::pin(events.map(|events| events.into_iter().collect())))
-                    .await;
+                scanner.run(events).await;
             }
         });
         let scan_state_updater = cx.spawn(async move |this, cx| {
@@ -2053,6 +2058,12 @@ impl LocalWorktree {
         self.snapshot.update_abs_path(new_path, root_name);
         self.restart_background_scanners(cx);
     }
+    #[cfg(feature = "test-support")]
+    pub fn set_defer_watch(&mut self, defer: bool, cx: &mut Context<Worktree>) {
+        self.force_defer_watch = defer;
+        self.restart_background_scanners(cx);
+    }
+
     #[cfg(feature = "test-support")]
     pub fn repositories(&self) -> Vec<Arc<Path>> {
         self.git_repositories
@@ -3946,6 +3957,7 @@ struct BackgroundScanner {
     /// Whether this is a single-file worktree (root is a file, not a directory).
     /// Used to determine if we should give up after repeated canonicalization failures.
     is_single_file: bool,
+    defer_watch: bool,
 }
 
 #[derive(Copy, Clone, PartialEq)]
@@ -4087,6 +4099,37 @@ impl BackgroundScanner {
 
         self.send_status_update(false, SmallVec::new(), &[]).await;
 
+        if self.defer_watch {
+            let (events, watcher) = self
+                .fs
+                .watch(root_abs_path.as_path(), FS_WATCH_LATENCY)
+                .await;
+            self.watcher = watcher;
+            fs_events_rx = Box::pin(events.map(|events| events.into_iter().collect()));
+
+            let state = self.state.lock().await;
+            for target in state.symlink_paths_by_target.keys() {
+                if !target.starts_with(root_abs_path.as_path()) {
+                    self.watcher.add(target).log_err();
+                }
+            }
+            for repo in state.snapshot.git_repositories.values() {
+                if !repo
+                    .common_dir_abs_path
+                    .starts_with(root_abs_path.as_path())
+                {
+                    self.watcher.add(&repo.common_dir_abs_path).log_err();
+                }
+                if !repo
+                    .repository_dir_abs_path
+                    .starts_with(root_abs_path.as_path())
+                {
+                    self.watcher.add(&repo.repository_dir_abs_path).log_err();
+                }
+            }
+            drop(state);
+        }
+
         // Process any any FS events that occurred while performing the initial scan.
         // For these events, update events cannot be as precise, because we didn't
         // have the previous state loaded yet.

crates/worktree/tests/integration/main.rs 🔗

@@ -4237,3 +4237,170 @@ async fn test_remote_worktree_with_git_emits_root_repo_event_when_repo_info_arri
         "should fire exactly once, not duplicate"
     );
 }
+
+#[gpui::test]
+async fn test_deferred_watch_repository_above_root(
+    executor: BackgroundExecutor,
+    cx: &mut TestAppContext,
+) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor);
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            ".git": {},
+            "subproject": {
+                "a.txt": "A"
+            }
+        }),
+    )
+    .await;
+    let worktree = Worktree::local(
+        path!("/root/subproject").as_ref(),
+        true,
+        fs.clone(),
+        Arc::default(),
+        true,
+        WorktreeId::from_proto(0),
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+    worktree
+        .update(cx, |worktree, _| {
+            worktree.as_local().unwrap().scan_complete()
+        })
+        .await;
+    cx.run_until_parked();
+
+    worktree.update(cx, |worktree, cx| {
+        worktree.as_local_mut().unwrap().set_defer_watch(true, cx);
+    });
+    worktree
+        .update(cx, |worktree, _| {
+            worktree.as_local().unwrap().scan_complete()
+        })
+        .await;
+    cx.run_until_parked();
+
+    let repos = worktree.update(cx, |worktree, _| {
+        worktree.as_local().unwrap().repositories()
+    });
+    pretty_assertions::assert_eq!(repos, [Path::new(path!("/root")).into()]);
+}
+
+#[gpui::test]
+async fn test_deferred_watch_symlinks_pointing_outside(cx: &mut TestAppContext) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.background_executor.clone());
+    fs.insert_tree(
+        "/root",
+        json!({
+            "dir1": {
+                "deps": {},
+                "src": {
+                    "a.rs": "",
+                },
+            },
+            "dir2": {
+                "src": {
+                    "c.rs": "",
+                }
+            },
+        }),
+    )
+    .await;
+
+    fs.create_symlink("/root/dir1/deps/dep-dir2".as_ref(), "../../dir2".into())
+        .await
+        .unwrap();
+
+    let tree = Worktree::local(
+        Path::new("/root/dir1"),
+        true,
+        fs.clone(),
+        Default::default(),
+        true,
+        WorktreeId::from_proto(0),
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+
+    cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+        .await;
+    cx.run_until_parked();
+
+    tree.update(cx, |tree, cx| {
+        tree.as_local_mut().unwrap().set_defer_watch(true, cx);
+    });
+    cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+        .await;
+    cx.run_until_parked();
+
+    tree.read_with(cx, |tree, _| {
+        assert_eq!(
+            tree.entries(true, 0)
+                .map(|entry| (entry.path.as_ref(), entry.is_external))
+                .collect::<Vec<_>>(),
+            vec![
+                (rel_path(""), false),
+                (rel_path("deps"), false),
+                (rel_path("deps/dep-dir2"), true),
+                (rel_path("src"), false),
+                (rel_path("src/a.rs"), false),
+            ]
+        );
+    });
+
+    tree.read_with(cx, |tree, _| {
+        tree.as_local()
+            .unwrap()
+            .refresh_entries_for_paths(vec![rel_path("deps/dep-dir2").into()])
+    })
+    .recv()
+    .await;
+
+    tree.read_with(cx, |tree, _| {
+        assert_eq!(
+            tree.entries(true, 0)
+                .map(|entry| (entry.path.as_ref(), entry.is_external))
+                .collect::<Vec<_>>(),
+            vec![
+                (rel_path(""), false),
+                (rel_path("deps"), false),
+                (rel_path("deps/dep-dir2"), true),
+                (rel_path("deps/dep-dir2/src"), true),
+                (rel_path("src"), false),
+                (rel_path("src/a.rs"), false),
+            ]
+        );
+    });
+
+    tree.read_with(cx, |tree, _| {
+        tree.as_local()
+            .unwrap()
+            .refresh_entries_for_paths(vec![rel_path("deps/dep-dir2/src").into()])
+    })
+    .recv()
+    .await;
+
+    tree.read_with(cx, |tree, _| {
+        assert!(
+            tree.entry_for_path(rel_path("deps/dep-dir2/src/c.rs"))
+                .is_some()
+        );
+    });
+
+    fs.insert_file(Path::new("/root/dir2/src/new.rs"), b"".to_vec())
+        .await;
+
+    wait_for_condition(cx, |cx| {
+        tree.read_with(cx, |tree, _| {
+            tree.entry_for_path(rel_path("deps/dep-dir2/src/new.rs"))
+                .is_some()
+        })
+    })
+    .await;
+}