Skip to main content

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