feat(fs-watcher): add inotify watch limit sensing with toast warning

This commit is contained in:
Hibryda 2026-03-11 01:07:46 +01:00
parent d1ce031624
commit b19aa632c8
4 changed files with 168 additions and 7 deletions

View file

@ -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<String>,
}
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::<u64>().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());
}
}

View file

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

View file

@ -26,3 +26,16 @@ export function onFsWriteDetected(
): Promise<UnlistenFn> {
return listen<FsWriteEvent>('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<FsWatcherStatus> {
return invoke('fs_watcher_status');
}

View file

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