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

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 ...
This commit is contained in:
Ben Kunkle 2026-05-08 14:28:01 -05:00 committed by GitHub
parent 1e018436d8
commit 5e62281357
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 214 additions and 4 deletions

View file

@ -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.

View file

@ -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;
}