Skip to content

Commit 1241de3

Browse files
committed
fix: keep macOS watchers within FSEvents path limits
1 parent 12e823c commit 1241de3

2 files changed

Lines changed: 177 additions & 47 deletions

File tree

‎src/watcher/unified.rs‎

Lines changed: 64 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,10 @@ impl UnifiedWatcher {
100100
);
101101
}
102102

103-
// Watch all directories
104-
for dir in new_dirs {
105-
self.watch_directory(&dir)?;
106-
}
107-
103+
// FSEvents watches roots recursively; register those first so the
104+
// per-directory batch can skip paths they already cover.
108105
self.register_handler_roots().await;
106+
self.watch_directories(&new_dirs)?;
109107

110108
// Subscribe to broadcaster for IndexReloaded events
111109
let mut broadcast_rx = self.broadcaster.subscribe();
@@ -180,36 +178,53 @@ impl UnifiedWatcher {
180178
}
181179
}
182180

183-
/// Watch a directory for changes.
184-
fn watch_directory(&mut self, dir: &PathBuf) -> Result<(), WatchError> {
185-
let watch_path = if dir.is_absolute() {
186-
dir.clone()
181+
/// Register a batch without restarting the platform event stream per path.
182+
fn watch_directories(&mut self, dirs: &[PathBuf]) -> Result<(), WatchError> {
183+
let watch_paths: Vec<_> = dirs
184+
.iter()
185+
.map(|dir| {
186+
if dir.is_absolute() {
187+
dir.clone()
188+
} else {
189+
self.workspace_root.join(dir)
190+
}
191+
})
192+
.filter(|path| {
193+
!cfg!(target_os = "macos")
194+
|| !self.handler_roots.iter().any(|root| path.starts_with(root))
195+
})
196+
.collect();
197+
if watch_paths.is_empty() {
198+
return Ok(());
199+
}
200+
// FSEvents has a finite path list. Recursive roots avoid one native
201+
// watch per source directory; handlers still enforce ignore rules.
202+
let mode = if cfg!(target_os = "macos") {
203+
RecursiveMode::Recursive
187204
} else {
188-
self.workspace_root.join(dir)
205+
RecursiveMode::NonRecursive
189206
};
190-
191-
match self
192-
._watcher
193-
.watch(&watch_path, RecursiveMode::NonRecursive)
194-
{
195-
Ok(_) => {
196-
crate::debug_event!(
197-
"watcher",
198-
"watching",
199-
"{}",
200-
crate::parsing::paths::render_absolute_path(&watch_path).display()
201-
);
202-
Ok(())
203-
}
204-
Err(e) => {
205-
tracing::warn!(
206-
"[watcher] failed to watch {}: {e}",
207-
crate::parsing::paths::render_absolute_path(&watch_path).display()
208-
);
209-
// Continue - don't fail completely
210-
Ok(())
207+
let mut paths = self._watcher.paths_mut();
208+
for watch_path in watch_paths {
209+
match paths.add(&watch_path, mode) {
210+
Ok(_) => {
211+
crate::debug_event!(
212+
"watcher",
213+
"watching",
214+
"{}",
215+
crate::parsing::paths::render_absolute_path(&watch_path).display()
216+
);
217+
}
218+
Err(e) => {
219+
tracing::warn!(
220+
"[watcher] failed to watch {}: {e}",
221+
crate::parsing::paths::render_absolute_path(&watch_path).display()
222+
);
223+
}
211224
}
212225
}
226+
paths.commit()?;
227+
Ok(())
213228
}
214229

215230
/// Handle an incoming file event.
@@ -305,12 +320,16 @@ impl UnifiedWatcher {
305320
}
306321
roots.extend(handler_roots);
307322
}
308-
for root in &roots {
309-
if self.registry.add_watch_dir(root.clone()) {
310-
if let Err(e) = self.watch_directory(root) {
311-
tracing::warn!("[watcher] failed to watch root: {e}");
312-
}
313-
}
323+
let new_roots: Vec<_> = roots
324+
.iter()
325+
.filter(|root| {
326+
let new_dir = self.registry.add_watch_dir((*root).clone());
327+
new_dir || (cfg!(target_os = "macos") && !self.handler_roots.contains(root))
328+
})
329+
.cloned()
330+
.collect();
331+
if let Err(e) = self.watch_directories(&new_roots) {
332+
tracing::warn!("[watcher] failed to watch roots: {e}");
314333
}
315334
self.handler_roots = roots;
316335
self.batch_sync_roots = sync_roots;
@@ -333,12 +352,12 @@ impl UnifiedWatcher {
333352
)
334353
};
335354

336-
for dir in dirs {
337-
if self.registry.add_watch_dir(dir.clone()) {
338-
if let Err(e) = self.watch_directory(&dir) {
339-
tracing::warn!("[watcher] failed to watch created dir: {e}");
340-
}
341-
}
355+
let new_dirs: Vec<_> = dirs
356+
.into_iter()
357+
.filter(|dir| self.registry.add_watch_dir(dir.clone()))
358+
.collect();
359+
if let Err(e) = self.watch_directories(&new_dirs) {
360+
tracing::warn!("[watcher] failed to watch created directories: {e}");
342361
}
343362
if !files.is_empty() {
344363
crate::log_event!(
@@ -716,10 +735,8 @@ impl UnifiedWatcher {
716735
.collect();
717736

718737
// Watch any new directories
719-
for dir in dirs_to_watch {
720-
if let Err(e) = self.watch_directory(&dir) {
721-
tracing::warn!("[watcher] failed to watch new directory: {e}");
722-
}
738+
if let Err(e) = self.watch_directories(&dirs_to_watch) {
739+
tracing::warn!("[watcher] failed to watch new directories: {e}");
723740
}
724741

725742
// Config reload can add or drop roots; re-register them.

‎tests/watcher_startup.rs‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
#![cfg(target_os = "macos")]
2+
3+
use std::{
4+
fs,
5+
process::{Command, Stdio},
6+
thread,
7+
time::{Duration, Instant},
8+
};
9+
use tantivy::{Index, Term, collector::Count, query::TermQuery, schema::IndexRecordOption};
10+
11+
#[test]
12+
#[ignore = "creates 5,000 directories; run explicitly to check FSEvents scaling and delivery"]
13+
fn watches_large_tree_and_preserves_ignore_rules() {
14+
let dir = tempfile::tempdir().unwrap();
15+
let root = dir.path().canonicalize().unwrap();
16+
fs::create_dir(root.join(".codanna")).unwrap();
17+
fs::write(root.join(".codanna/settings.toml"), format!(
18+
"workspace_root = {}\n[indexing]\nindexed_paths = [\"src\"]\n[semantic_search]\nenabled = false\n",
19+
serde_json::to_string(&root).unwrap()
20+
)).unwrap();
21+
for i in 0..5000 {
22+
let path = root.join(format!("src/d{i}"));
23+
fs::create_dir_all(&path).unwrap();
24+
fs::write(
25+
path.join("seed.py"),
26+
format!("def seed_{i}():\n return {i}\n"),
27+
)
28+
.unwrap();
29+
}
30+
// Direct root files must not suppress the native root watch just because
31+
// their parent is already in the logical directory registry.
32+
fs::write(root.join("src/seed.py"), "def root_seed():\n return 0\n").unwrap();
33+
fs::write(root.join("src/.codannaignore"), "ignored/\n").unwrap();
34+
let binary = env!("CARGO_BIN_EXE_codanna");
35+
assert!(
36+
Command::new(binary)
37+
.current_dir(&root)
38+
.args(["index", "--no-progress"])
39+
.stdout(Stdio::null())
40+
.status()
41+
.unwrap()
42+
.success()
43+
);
44+
let index = Index::open_in_dir(root.join(".codanna/index/tantivy")).unwrap();
45+
let reader = index.reader().unwrap();
46+
let name_field = index.schema().get_field("name").unwrap();
47+
let contains = |name: &str| {
48+
reader.reload().unwrap();
49+
reader
50+
.searcher()
51+
.search(
52+
&TermQuery::new(
53+
Term::from_field_text(name_field, name),
54+
IndexRecordOption::Basic,
55+
),
56+
&Count,
57+
)
58+
.unwrap()
59+
> 0
60+
};
61+
let wait_for = |condition: &dyn Fn() -> bool| {
62+
let start = Instant::now();
63+
while start.elapsed() < Duration::from_secs(15) {
64+
if condition() {
65+
return true;
66+
}
67+
thread::sleep(Duration::from_millis(100));
68+
}
69+
false
70+
};
71+
let log = root.join("watcher.log");
72+
let mut child = Command::new(binary)
73+
.current_dir(&root)
74+
.args(["serve", "--watch"])
75+
.env("RUST_LOG", "codanna::watcher::unified=info")
76+
.stdin(Stdio::piped())
77+
.stdout(Stdio::null())
78+
.stderr(fs::File::create(&log).unwrap())
79+
.spawn()
80+
.unwrap();
81+
let start = Instant::now();
82+
let ready = wait_for(&|| {
83+
fs::read_to_string(&log)
84+
.unwrap()
85+
.contains("[watcher] started")
86+
});
87+
let ready_elapsed = start.elapsed();
88+
fs::create_dir(root.join("src/ignored")).unwrap();
89+
fs::write(
90+
root.join("src/ignored/skip.py"),
91+
"def ignored_probe():\n return 0\n",
92+
)
93+
.unwrap();
94+
fs::create_dir(root.join("src/new_subtree")).unwrap();
95+
let source = root.join("src/new_subtree/created.py");
96+
fs::write(&source, "def created_probe():\n return 1\n").unwrap();
97+
let created = wait_for(&|| contains("created_probe"));
98+
fs::write(&source, "def edited_probe():\n return 2\n").unwrap();
99+
let edited = wait_for(&|| contains("edited_probe") && !contains("created_probe"));
100+
fs::remove_file(&source).unwrap();
101+
let deleted = wait_for(&|| !contains("edited_probe"));
102+
let ignored = contains("ignored_probe");
103+
let _ = child.kill();
104+
child.wait().unwrap();
105+
assert!(
106+
ready && created && edited && deleted && !ignored,
107+
"ready={ready} created={created} edited={edited} deleted={deleted} ignored={ignored}: {}",
108+
fs::read_to_string(log).unwrap()
109+
);
110+
println!(
111+
"5,000-directory readiness: {ready_elapsed:?}; create/edit/delete and ignore checks passed"
112+
);
113+
}

0 commit comments

Comments
 (0)