Skip to main content

Mountain/Environment/
FileWatcherProvider.rs

1//! # FileWatcherProvider (Environment)
2//!
3//! Backing implementation of
4//! [`FileWatcherProvider`](CommonLibrary::FileSystem::FileWatcherProvider)
5//! for [`MountainEnvironment`].
6//!
7//! Native filesystem notifications are delegated to the `notify` crate, which
8//! picks up inotify on Linux, FSEvents on macOS, and ReadDirectoryChangesW
9//! on Windows. Events from the watcher thread flow through an unbounded
10//! channel into a tokio task that forwards them back to Cocoon over the
11//! reverse-RPC channel as `$fileWatcher:event` notifications.
12//!
13//! # Concurrency notes
14//!
15//! - `notify::recommended_watcher` executes callbacks on its own native thread,
16//!   so we tunnel events through a bounded channel before touching async code.
17//!   The forwarder task is spawned once on first registration and lives for the
18//!   entire process lifetime.
19//! - macOS FSEvents may emit duplicate Create/Change events for the same path
20//!   in very short succession. We debounce by path within a 100 ms window
21//!   per-handle, keyed on `(handle, path, kind)`.
22//! - Linux inotify has a small per-user watcher cap
23//!   (`fs.inotify.max_user_watches`); hitting it surfaces as
24//!   `notify::Error::MaxFilesWatch`. We propagate that verbatim to the caller
25//!   so the UI can show a guidance message.
26
27use std::{
28	collections::HashMap,
29	path::PathBuf,
30	sync::{Arc, Mutex as StandardMutex},
31	time::{Duration, Instant},
32};
33
34use CommonLibrary::{
35	Environment::Requires::Requires,
36	Error::CommonError::CommonError,
37	FileSystem::FileWatcherProvider::{FileWatcherProvider, WatchEvent, WatchEventKind},
38	IPC::{IPCProvider::IPCProvider, SkyEvent::SkyEvent},
39};
40use async_trait::async_trait;
41use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
42use serde_json::json;
43use tokio::sync::mpsc as TokioMPSC;
44
45use super::MountainEnvironment::MountainEnvironment;
46use crate::dev_log;
47
48/// Interval below which a second (path, kind) event for the same handle is
49/// ignored. Tuned for FSEvents coalescing.
50const DebounceWindow:Duration = Duration::from_millis(100);
51
52/// Internal entry tracked per registered watcher. The `Watcher` handle must
53/// be kept alive for the lifetime of the registration; dropping it releases
54/// the OS resources.
55pub struct WatcherEntry {
56	Watcher:RecommendedWatcher,
57
58	LastSeen:HashMap<(PathBuf, &'static str), Instant>,
59}
60
61/// Composite key used to detect duplicate watcher registrations. Two
62/// extensions (or the same extension activated twice) frequently register
63/// the same `(root, recursive, pattern)` triple within milliseconds of
64/// each other - the typescript-language-features and git extensions are
65/// the worst offenders. Without dedup, each registration spawns its own
66/// notify::Watcher with its own kqueue/inotify subscription tree, doubling
67/// (or worse) FS-event traffic and burning kernel handles.
68type DedupKey = (PathBuf, bool, Option<String>);
69
70/// Lazily-initialised process-wide state for file watching. Instances of the
71/// event-forwarder task are singletons keyed on the MountainEnvironment
72/// handle. Access through `WatcherState::Get`.
73pub struct WatcherState {
74	pub Entries:Arc<StandardMutex<HashMap<String, WatcherEntry>>>,
75
76	pub EventSender:TokioMPSC::UnboundedSender<WatchEvent>,
77
78	/// Maps `(root, recursive, pattern)` to the primary handle that owns
79	/// the live OS watcher. Subsequent registrations matching the same
80	/// triple are aliased to the primary; only the primary creates a
81	/// notify::Watcher.
82	pub DedupIndex:Arc<StandardMutex<HashMap<DedupKey, String>>>,
83
84	/// Reverse index: primary handle → all aliased handles. When the
85	/// forwarder task gets an event for a primary, it fans the same
86	/// event out to every aliased handle so each extension's
87	/// `vscode.workspace.createFileSystemWatcher` callback fires once.
88	pub Aliases:Arc<StandardMutex<HashMap<String, Vec<String>>>>,
89
90	/// Reverse lookup for unregister: any handle (primary or alias) →
91	/// its primary. Lets `UnregisterWatcher` clean up alias entries
92	/// without scanning the entire `Aliases` map.
93	pub HandleToPrimary:Arc<StandardMutex<HashMap<String, String>>>,
94}
95
96impl WatcherState {
97	/// Obtain (or create) the global WatcherState. The forwarder task is
98	/// spawned on first access. Must be called from within a tokio runtime.
99	pub fn Get(env:&MountainEnvironment) -> Arc<WatcherState> {
100		use std::sync::OnceLock;
101
102		// One WatcherState per process - the backing notify watchers are
103		// cheap and multiplex fine, and we want a single forwarder task.
104		static GLOBAL:OnceLock<Arc<WatcherState>> = OnceLock::new();
105
106		GLOBAL
107			.get_or_init(|| {
108				let (tx, mut rx) = TokioMPSC::unbounded_channel::<WatchEvent>();
109
110				let state = Arc::new(WatcherState {
111					Entries:Arc::new(StandardMutex::new(HashMap::new())),
112					EventSender:tx,
113					DedupIndex:Arc::new(StandardMutex::new(HashMap::new())),
114					Aliases:Arc::new(StandardMutex::new(HashMap::new())),
115					HandleToPrimary:Arc::new(StandardMutex::new(HashMap::new())),
116				});
117
118				// The forwarder task holds a weak ref to the environment so
119				// it unwinds cleanly if the env is ever torn down. State is
120				// captured by Arc clone for the alias fan-out lookup.
121				let env_clone = env.clone();
122
123				let state_clone = state.clone();
124
125				tokio::spawn(async move {
126					use tauri::Emitter;
127
128					while let Some(WatchEvent { Handle, Kind, Path }) = rx.recv().await {
129						let ipc_provider:Arc<dyn IPCProvider> = env_clone.Require();
130
131						// Fan events to the primary handle plus every alias
132						// registered against it. Without this, the second
133						// extension to register a duplicate watcher would
134						// silently miss every event.
135						let mut Recipients:Vec<String> = vec![Handle.clone()];
136
137						if let Ok(AliasGuard) = state_clone.Aliases.lock() {
138							if let Some(AliasList) = AliasGuard.get(&Handle) {
139								Recipients.extend(AliasList.iter().cloned());
140							}
141						}
142
143						for RecipientHandle in Recipients {
144							let payload = json!({
145								"handle": RecipientHandle,
146								"kind": Kind.AsString(),
147								"path": Path.to_string_lossy().to_string(),
148							});
149
150							if let Err(error) = ipc_provider
151								.SendNotificationToSideCar(
152									"cocoon-main".to_string(),
153									"$fileWatcher:event".to_string(),
154									payload.clone(),
155								)
156								.await
157							{
158								dev_log!(
159									"filewatcher",
160									"warn: [FileWatcherProvider] Failed to forward event handle={} kind={} path={:?}: \
161									 {:?}",
162									RecipientHandle,
163									Kind.AsString(),
164									Path,
165									error
166								);
167							}
168
169							// Dual-emit to Wind/Sky so the Explorer tree,
170							// search index, and any other webview-side
171							// consumer can react to disk mutations without
172							// going through Cocoon. Wind's `TauriChannel`
173							// subscribes to `sky://vfs/fileChange` under
174							// the localFilesystem channel. Aliased handles
175							// each get their own emit so per-handle
176							// listeners on the Sky side fire correctly.
177							if let Err(Error) =
178								env_clone.ApplicationHandle.emit(SkyEvent::VFSFileChange.AsStr(), &payload)
179							{
180								dev_log!(
181									"filewatcher",
182									"warn: [FileWatcherProvider] sky://vfs/fileChange emit failed: {}",
183									Error
184								);
185							}
186						}
187					}
188				});
189
190				state
191			})
192			.clone()
193	}
194}
195
196fn MapEventKind(raw:&EventKind) -> Option<WatchEventKind> {
197	match raw {
198		EventKind::Create(_) => Some(WatchEventKind::Create),
199
200		EventKind::Modify(_) => Some(WatchEventKind::Change),
201
202		EventKind::Remove(_) => Some(WatchEventKind::Delete),
203
204		// Access / Any / Other events are not exposed to extensions.
205		_ => None,
206	}
207}
208
209/// Translate a VS Code glob pattern into a `regex::Regex` so the native
210/// watcher can apply the caller's filter before paying for an IPC hop. A
211/// small subset of the glob grammar is supported (`**`, `*`, `?`, `[…]`,
212/// `{…,…}` alternation) - exactly what TypeScript-language-features and
213/// the other ship-time extensions rely on.
214fn CompileGlobToRegex(Pattern:&str) -> Option<regex::Regex> {
215	let mut Regex = String::with_capacity(Pattern.len() * 2 + 4);
216
217	// Case-insensitive on macOS + Windows where the OS is typically
218	// case-insensitive; on case-sensitive Linux filesystems extensions commonly
219	// still use lowercase patterns, so the flag is safe across all three targets.
220	if cfg!(any(target_os = "macos", target_os = "windows")) {
221		Regex.push_str("(?i)");
222	}
223
224	Regex.push('^');
225
226	let mut Chars = Pattern.chars().peekable();
227
228	let mut InClass = false;
229
230	while let Some(C) = Chars.next() {
231		if InClass {
232			if C == ']' {
233				InClass = false;
234			}
235
236			Regex.push(C);
237
238			continue;
239		}
240
241		match C {
242			'*' => {
243				if Chars.peek() == Some(&'*') {
244					Chars.next();
245
246					if Chars.peek() == Some(&'/') {
247						Chars.next();
248
249						Regex.push_str("(?:.*/)?");
250					} else {
251						Regex.push_str(".*");
252					}
253				} else {
254					Regex.push_str("[^/]*");
255				}
256			},
257
258			'?' => Regex.push_str("[^/]"),
259
260			'[' => {
261				Regex.push('[');
262
263				InClass = true;
264			},
265
266			'{' => Regex.push_str("(?:"),
267
268			'}' => Regex.push(')'),
269
270			',' => Regex.push('|'),
271
272			'.' | '+' | '(' | ')' | '^' | '$' | '|' | '\\' => {
273				Regex.push('\\');
274
275				Regex.push(C);
276			},
277
278			_ => Regex.push(C),
279		}
280	}
281
282	Regex.push('$');
283
284	regex::Regex::new(&Regex).ok()
285}
286
287#[async_trait]
288impl FileWatcherProvider for MountainEnvironment {
289	async fn RegisterWatcher(
290		&self,
291
292		Handle:String,
293
294		Root:PathBuf,
295
296		IsRecursive:bool,
297
298		Pattern:Option<String>,
299	) -> Result<(), CommonError> {
300		let state = WatcherState::Get(self);
301
302		// De-dup pass 1: same handle re-registered (cheap idempotency).
303		{
304			let guard = state
305				.Entries
306				.lock()
307				.map_err(|error| CommonError::StateLockPoisoned { Context:error.to_string() })?;
308
309			if guard.contains_key(&Handle) {
310				dev_log!(
311					"filewatcher",
312					"[FileWatcherProvider] handle={} already registered; skipping duplicate",
313					Handle
314				);
315
316				return Ok(());
317			}
318		}
319
320		// De-dup pass 2: same (root, recursive, pattern) triple already has
321		// a primary watcher. The git extension, typescript-language-features,
322		// and several `composer.*` extensions all hit this path during boot
323		// (observed: `**/composer.json`, `**/composer.lock`, `**/*.md`,
324		// `**/package.json` registered twice each within ~50ms). Aliasing
325		// avoids the duplicate notify::Watcher / kqueue subscription tree
326		// while still fanning events to every aliased handle.
327		let DedupKeyValue:DedupKey = (Root.clone(), IsRecursive, Pattern.clone());
328
329		{
330			let DedupGuard = state
331				.DedupIndex
332				.lock()
333				.map_err(|error| CommonError::StateLockPoisoned { Context:error.to_string() })?;
334
335			if let Some(PrimaryHandle) = DedupGuard.get(&DedupKeyValue).cloned() {
336				drop(DedupGuard);
337
338				let mut AliasGuard = state
339					.Aliases
340					.lock()
341					.map_err(|error| CommonError::StateLockPoisoned { Context:error.to_string() })?;
342
343				AliasGuard
344					.entry(PrimaryHandle.clone())
345					.or_insert_with(Vec::new)
346					.push(Handle.clone());
347
348				let mut H2PGuard = state
349					.HandleToPrimary
350					.lock()
351					.map_err(|error| CommonError::StateLockPoisoned { Context:error.to_string() })?;
352
353				H2PGuard.insert(Handle.clone(), PrimaryHandle.clone());
354
355				dev_log!(
356					"filewatcher",
357					"[FileWatcherProvider] dedup hit; handle={} aliased to primary={} root={} pattern={:?}",
358					Handle,
359					PrimaryHandle,
360					Root.display(),
361					Pattern
362				);
363
364				return Ok(());
365			}
366		}
367
368		// First registration for this triple. The DedupIndex insert
369		// happens AFTER successful OS-watcher creation below so an
370		// errored or benign-absent registration doesn't leave a stale
371		// dedup entry pointing at a non-existent primary.
372
373		let CompiledPattern = Pattern.as_deref().and_then(CompileGlobToRegex);
374
375		let pattern_for_callback = CompiledPattern.clone();
376
377		// Prepare the per-event callback. It owns clones of the handle and
378		// the forwarder channel; debouncing state lives in the entry under
379		// the global mutex (fine - the callback is not hot).
380		let handle_for_callback = Handle.clone();
381
382		let sender = state.EventSender.clone();
383
384		let entries = state.Entries.clone();
385
386		let mut watcher = notify::recommended_watcher(move |event_result:notify::Result<notify::Event>| {
387			let Ok(event) = event_result else { return };
388
389			let Some(kind) = MapEventKind(&event.kind) else { return };
390
391			let kind_tag = kind.AsString();
392
393			// Pattern filter + server-side ignore list - reject early so the
394			// event never crosses IPC. The ignore list catches `Target/`,
395			// `node_modules/`, `.git/objects/`, `dist/`, etc. - paths that
396			// produce thousands of events per cargo / pnpm build but whose
397			// contents the editor never surfaces to the user. See
398			// `FileWatcherIgnore.rs` for the full list and override hook
399			// (`WatchIgnore` env var).
400			let matched_paths:Vec<PathBuf> = event
401				.paths
402				.into_iter()
403				.filter(|path| {
404					let PathString = path.to_string_lossy();
405
406					if super::FileWatcherIgnore::Fn(&PathString) {
407						return false;
408					}
409
410					match &pattern_for_callback {
411						Some(re) => re.is_match(&PathString),
412						None => true,
413					}
414				})
415				.collect();
416
417			if matched_paths.is_empty() {
418				return;
419			}
420
421			// Debounce per (handle, path, kind). Lock is uncontested for
422			// single-path events; bursts from FSEvents coalesce cleanly.
423			let mut final_paths:Vec<PathBuf> = Vec::with_capacity(matched_paths.len());
424
425			if let Ok(mut guard) = entries.lock() {
426				if let Some(entry) = guard.get_mut(&handle_for_callback) {
427					let now = Instant::now();
428
429					entry
430						.LastSeen
431						.retain(|_, instant| now.duration_since(*instant) < Duration::from_secs(10));
432
433					for path in matched_paths {
434						let key = (path.clone(), kind_tag);
435
436						let keep = match entry.LastSeen.get(&key) {
437							Some(previous) if now.duration_since(*previous) < DebounceWindow => false,
438							_ => {
439								entry.LastSeen.insert(key, now);
440
441								true
442							},
443						};
444
445						if keep {
446							final_paths.push(path);
447						}
448					}
449				} else {
450					return;
451				}
452			} else {
453				return;
454			}
455
456			for path in final_paths {
457				let _ = sender.send(WatchEvent { Handle:handle_for_callback.clone(), Kind:kind, Path:path });
458			}
459		})
460		.map_err(|error| CommonError::Unknown { Description:format!("FileWatcher create failed: {}", error) })?;
461
462		let mode = if IsRecursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive };
463
464		// Watching a non-existent path is a common pattern: extensions
465		// register watchers on optional config dirs (`~/.roo/skills-*`,
466		// `.vscode/settings.json` in fresh workspaces, …) that may appear
467		// later. `notify` returns `Error::PathNotFound` / "No path was
468		// found"; failing the gRPC call counts against Cocoon's circuit
469		// breaker - 5 such probes at boot trip the breaker open and
470		// cascade into 60s of rejected reads. Record a "deferred" entry
471		// without a live OS watcher so Unregister still works; future
472		// events for that path won't fire, but the extension can re-
473		// register once the directory appears, just like in stock VS Code.
474		let WatchResult = watcher.watch(&Root, mode);
475
476		let mut guard = state
477			.Entries
478			.lock()
479			.map_err(|error| CommonError::StateLockPoisoned { Context:error.to_string() })?;
480
481		let _ = CompiledPattern;
482
483		match WatchResult {
484			Ok(()) => {
485				guard.insert(Handle.clone(), WatcherEntry { Watcher:watcher, LastSeen:HashMap::new() });
486
487				// Drop the Entries lock before grabbing DedupIndex to
488				// avoid lock-order divergence vs the alias path (which
489				// takes DedupIndex first). Re-acquire is cheap.
490				drop(guard);
491
492				if let Ok(mut DedupGuard) = state.DedupIndex.lock() {
493					DedupGuard.entry(DedupKeyValue.clone()).or_insert_with(|| Handle.clone());
494				}
495
496				dev_log!(
497					"filewatcher",
498					"[FileWatcherProvider] Registered watcher handle={} root={} recursive={} pattern={:?}",
499					Handle,
500					Root.display(),
501					IsRecursive,
502					Pattern
503				);
504
505				return Ok(());
506			},
507
508			Err(error) => {
509				let ErrorString = error.to_string().to_lowercase();
510
511				let IsBenignAbsent = ErrorString.contains("no path was found")
512					|| ErrorString.contains("no such file or directory")
513					|| ErrorString.contains("entity not found")
514					|| ErrorString.contains("path not found")
515					|| ErrorString.contains("os error 2")
516					|| !Root.exists();
517
518				if IsBenignAbsent {
519					dev_log!(
520						"filewatcher",
521						"[FileWatcherProvider] watch path absent (deferred) handle={} root={} err={}",
522						Handle,
523						Root.display(),
524						error
525					);
526
527					// Drop watcher (no live subscription); record handle so
528					// Unregister still finds something to remove. We do NOT
529					// reuse the closure's notify::Watcher here.
530					drop(watcher);
531				} else {
532					return Err(CommonError::Unknown {
533						Description:format!("FileWatcher watch failed for {}: {}", Root.display(), error),
534					});
535				}
536			},
537		}
538
539		dev_log!(
540			"filewatcher",
541			"[FileWatcherProvider] Registered watcher handle={} root={} recursive={} pattern={:?}",
542			Handle,
543			Root.display(),
544			IsRecursive,
545			Pattern
546		);
547
548		Ok(())
549	}
550
551	async fn UnregisterWatcher(&self, Handle:String) -> Result<(), CommonError> {
552		let state = WatcherState::Get(self);
553
554		// Step 1: alias removal. If the handle was aliased to a primary,
555		// just remove it from the alias list and the lookup map. The OS
556		// watcher stays alive because the primary still owns it.
557		let MaybePrimary = {
558			let mut H2PGuard = state
559				.HandleToPrimary
560				.lock()
561				.map_err(|error| CommonError::StateLockPoisoned { Context:error.to_string() })?;
562
563			H2PGuard.remove(&Handle)
564		};
565
566		if let Some(PrimaryHandle) = MaybePrimary {
567			let mut AliasGuard = state
568				.Aliases
569				.lock()
570				.map_err(|error| CommonError::StateLockPoisoned { Context:error.to_string() })?;
571
572			if let Some(AliasList) = AliasGuard.get_mut(&PrimaryHandle) {
573				AliasList.retain(|EntryHandle| EntryHandle != &Handle);
574
575				if AliasList.is_empty() {
576					AliasGuard.remove(&PrimaryHandle);
577				}
578			}
579
580			dev_log!(
581				"filewatcher",
582				"[FileWatcherProvider] Unregistered alias handle={} primary={}",
583				Handle,
584				PrimaryHandle
585			);
586
587			return Ok(());
588		}
589
590		// Step 2: primary removal. Drop the OS watcher and clear the
591		// dedup index entry. Any still-aliased handles are left dangling -
592		// callers requesting a primary unregister while aliases still
593		// exist is unusual but not fatal; the alias entries simply
594		// stop receiving events.
595		let mut Guard = state
596			.Entries
597			.lock()
598			.map_err(|error| CommonError::StateLockPoisoned { Context:error.to_string() })?;
599
600		if Guard.remove(&Handle).is_some() {
601			dev_log!("filewatcher", "[FileWatcherProvider] Unregistered watcher handle={}", Handle);
602		}
603
604		drop(Guard);
605
606		// Clear the dedup-index entry pointing at this primary so a
607		// future registration for the same triple opens a fresh OS
608		// watcher rather than aliasing to a removed handle.
609		let mut DedupGuard = state
610			.DedupIndex
611			.lock()
612			.map_err(|error| CommonError::StateLockPoisoned { Context:error.to_string() })?;
613
614		DedupGuard.retain(|_, PrimaryHandle| PrimaryHandle != &Handle);
615
616		Ok(())
617	}
618}