feat(fs-watcher): add inotify watch limit sensing with toast warning
This commit is contained in:
parent
d1ce031624
commit
b19aa632c8
4 changed files with 168 additions and 7 deletions
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue