From b19aa632c8f1a7aa31ac2347ad03f104e7c829c4 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 11 Mar 2026 01:07:46 +0100 Subject: [PATCH] feat(fs-watcher): add inotify watch limit sensing with toast warning --- v2/src-tauri/src/fs_watcher.rs | 137 +++++++++++++++++- v2/src-tauri/src/lib.rs | 8 +- v2/src/lib/adapters/fs-watcher-bridge.ts | 13 ++ .../components/Workspace/ProjectBox.svelte | 17 ++- 4 files changed, 168 insertions(+), 7 deletions(-) diff --git a/v2/src-tauri/src/fs_watcher.rs b/v2/src-tauri/src/fs_watcher.rs index 763751f..00cef7f 100644 --- a/v2/src-tauri/src/fs_watcher.rs +++ b/v2/src-tauri/src/fs_watcher.rs @@ -32,9 +32,26 @@ const IGNORED_DIRS: &[&str] = &[ "build", ]; +/// Status of inotify watch capacity +#[derive(Clone, Serialize)] +pub struct FsWatcherStatus { + /// Kernel limit from /proc/sys/fs/inotify/max_user_watches + pub max_watches: u64, + /// Estimated directories being watched across all projects + pub estimated_watches: u64, + /// Usage ratio (0.0 - 1.0) + pub usage_ratio: f64, + /// Number of actively watched projects + pub active_projects: usize, + /// Warning message if approaching limit, null otherwise + pub warning: Option, +} + struct ProjectWatch { _watcher: RecommendedWatcher, _cwd: String, + /// Estimated number of directories (inotify watches) for this project + dir_count: u64, } pub struct ProjectFsWatcher { @@ -147,13 +164,15 @@ impl ProjectFsWatcher { .watch(cwd_path, RecursiveMode::Recursive) .map_err(|e| format!("Failed to watch directory: {e}"))?; - log::info!("Started fs watcher for project {project_id} at {cwd}"); + let dir_count = count_watched_dirs(cwd_path); + log::info!("Started fs watcher for project {project_id} at {cwd} (~{dir_count} directories)"); watches.insert( project_id.to_string(), ProjectWatch { _watcher: watcher, _cwd: cwd_owned, + dir_count, }, ); @@ -168,6 +187,44 @@ impl ProjectFsWatcher { } } + /// Get current watcher status including inotify limit check + pub fn status(&self) -> FsWatcherStatus { + let max_watches = read_inotify_max_watches(); + let watches = self.watches.lock().unwrap(); + let active_projects = watches.len(); + let estimated_watches: u64 = watches.values().map(|w| w.dir_count).sum(); + let usage_ratio = if max_watches > 0 { + estimated_watches as f64 / max_watches as f64 + } else { + 0.0 + }; + + let warning = if usage_ratio > 0.90 { + Some(format!( + "inotify watch limit critical: using ~{estimated_watches}/{max_watches} watches ({:.0}%). \ + Increase with: echo {} | sudo tee /proc/sys/fs/inotify/max_user_watches", + usage_ratio * 100.0, + max_watches * 2 + )) + } else if usage_ratio > 0.75 { + Some(format!( + "inotify watch limit warning: using ~{estimated_watches}/{max_watches} watches ({:.0}%). \ + Consider increasing with: echo {} | sudo tee /proc/sys/fs/inotify/max_user_watches", + usage_ratio * 100.0, + max_watches * 2 + )) + } else { + None + }; + + FsWatcherStatus { + max_watches, + estimated_watches, + usage_ratio, + active_projects, + warning, + } + } } /// Check if a path contains any ignored directory component @@ -183,6 +240,51 @@ fn should_ignore_path(path: &str) -> bool { false } +/// Read the kernel inotify watch limit from /proc/sys/fs/inotify/max_user_watches. +/// Returns 0 on non-Linux or if the file can't be read. +fn read_inotify_max_watches() -> u64 { + std::fs::read_to_string("/proc/sys/fs/inotify/max_user_watches") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0) +} + +/// Count directories under a path that would become inotify watches. +/// Skips ignored directories. Caps the walk at 30,000 to avoid blocking on huge monorepos. +fn count_watched_dirs(root: &Path) -> u64 { + const MAX_WALK: u64 = 30_000; + let mut count: u64 = 1; // root itself + + fn walk_dir(dir: &Path, count: &mut u64, max: u64) { + if *count >= max { + return; + } + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + if *count >= max { + return; + } + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if IGNORED_DIRS.contains(&name_str.as_ref()) { + continue; + } + *count += 1; + walk_dir(&path, count, max); + } + } + + walk_dir(root, &mut count, MAX_WALK); + count +} + #[cfg(test)] mod tests { use super::*; @@ -213,4 +315,37 @@ mod tests { fn test_should_not_ignore_root_file() { assert!(!should_ignore_path("/project/Cargo.toml")); } + + #[test] + fn test_read_inotify_max_watches() { + // On Linux this should return a positive number + let max = read_inotify_max_watches(); + if cfg!(target_os = "linux") { + assert!(max > 0, "Expected positive inotify limit on Linux, got {max}"); + } + } + + #[test] + fn test_count_watched_dirs_tempdir() { + let tmp = std::env::temp_dir().join("bterminal_test_count_dirs"); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(tmp.join("src/lib")).unwrap(); + std::fs::create_dir_all(tmp.join("node_modules/pkg")).unwrap(); // should be skipped + std::fs::create_dir_all(tmp.join(".git/objects")).unwrap(); // should be skipped + + let count = count_watched_dirs(&tmp); + // root + src + src/lib = 3 (node_modules and .git skipped) + assert_eq!(count, 3, "Expected 3 watched dirs, got {count}"); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_watcher_status_no_projects() { + let watcher = ProjectFsWatcher::new(); + let status = watcher.status(); + assert_eq!(status.active_projects, 0); + assert_eq!(status.estimated_watches, 0); + assert!(status.warning.is_none()); + } } diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index 39c2ec7..06bf735 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -16,7 +16,7 @@ use pty::{PtyManager, PtyOptions}; use remote::{RemoteManager, RemoteMachineConfig}; use session::{Session, SessionDb, LayoutState, SshSession, AgentMessageRecord, ProjectAgentState}; use sidecar::{AgentQueryOptions, SidecarConfig, SidecarManager}; -use fs_watcher::ProjectFsWatcher; +use fs_watcher::{FsWatcherStatus, ProjectFsWatcher}; use watcher::FileWatcherManager; use std::sync::Arc; use tauri::{Manager, State}; @@ -131,6 +131,11 @@ fn fs_unwatch_project(state: State<'_, AppState>, project_id: String) { state.fs_watcher.unwatch_project(&project_id); } +#[tauri::command] +fn fs_watcher_status(state: State<'_, AppState>) -> FsWatcherStatus { + state.fs_watcher.status() +} + // --- Session persistence commands --- #[tauri::command] @@ -758,6 +763,7 @@ pub fn run() { file_read, fs_watch_project, fs_unwatch_project, + fs_watcher_status, session_list, session_save, session_delete, diff --git a/v2/src/lib/adapters/fs-watcher-bridge.ts b/v2/src/lib/adapters/fs-watcher-bridge.ts index cccae59..17d4e6b 100644 --- a/v2/src/lib/adapters/fs-watcher-bridge.ts +++ b/v2/src/lib/adapters/fs-watcher-bridge.ts @@ -26,3 +26,16 @@ export function onFsWriteDetected( ): Promise { return listen('fs-write-detected', (e) => callback(e.payload)); } + +export interface FsWatcherStatus { + max_watches: number; + estimated_watches: number; + usage_ratio: number; + active_projects: number; + warning: string | null; +} + +/** Get inotify watcher status including kernel limit check */ +export function fsWatcherStatus(): Promise { + return invoke('fs_watcher_status'); +} diff --git a/v2/src/lib/components/Workspace/ProjectBox.svelte b/v2/src/lib/components/Workspace/ProjectBox.svelte index 57904eb..dc51e91 100644 --- a/v2/src/lib/components/Workspace/ProjectBox.svelte +++ b/v2/src/lib/components/Workspace/ProjectBox.svelte @@ -13,7 +13,7 @@ import MemoriesTab from './MemoriesTab.svelte'; import { getTerminalTabs } from '../../stores/workspace.svelte'; import { getProjectHealth } from '../../stores/health.svelte'; - import { fsWatchProject, fsUnwatchProject, onFsWriteDetected } from '../../adapters/fs-watcher-bridge'; + import { fsWatchProject, fsUnwatchProject, onFsWriteDetected, fsWatcherStatus } from '../../adapters/fs-watcher-bridge'; import { recordExternalWrite } from '../../stores/conflicts.svelte'; import { notify } from '../../stores/notifications.svelte'; @@ -58,10 +58,17 @@ const projectId = project.id; if (!cwd) return; - // Start watching - fsWatchProject(projectId, cwd).catch(e => - console.warn(`Failed to start fs watcher for ${projectId}:`, e) - ); + // Start watching, then check inotify capacity + fsWatchProject(projectId, cwd) + .then(() => fsWatcherStatus()) + .then((status) => { + if (status.warning) { + notify('warning', status.warning); + } + }) + .catch(e => + console.warn(`Failed to start fs watcher for ${projectId}:`, e) + ); // Listen for fs write events (filter to this project) let unlisten: (() => void) | null = null;