From 38136ab5a76297444d3a06c3c6db4cec11ebdd3c Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Tue, 9 Dec 2025 15:19:27 -0800 Subject: [PATCH] Enhance filesystem watching and integrate new sound effects - Introduced a new filesystem watcher service, replacing the previous location watcher with a more robust and platform-agnostic implementation. - Updated the core context to include the new filesystem watcher and refactored related services for better integration. - Added support for ephemeral event handling, allowing real-time updates for non-persistent locations. - Integrated new sound effects for pairing operations in the UI, enhancing user experience during device pairing. - Updated the File Operation Modal to support both copy and move operations with improved conflict resolution options. - Refactored related components to ensure consistency and improved performance across the application. --- AGENTS.md | 5 + Cargo.lock | Bin 325287 -> 326366 bytes .../modules/sd-mobile-core/core/Cargo.lock | Bin 233757 -> 234071 bytes core/Cargo.toml | 1 + core/src/config/app_config.rs | 6 +- core/src/context.rs | 19 +- core/src/lib.rs | 23 +- .../ops/indexing/change_detection/handler.rs | 14 +- .../ops/indexing/change_detection/types.rs | 22 +- core/src/ops/indexing/ephemeral/responder.rs | 32 +- core/src/ops/indexing/ephemeral/types.rs | 1 + core/src/ops/indexing/ephemeral/writer.rs | 15 + core/src/ops/indexing/handlers/ephemeral.rs | 177 ++ core/src/ops/indexing/handlers/mod.rs | 10 + core/src/ops/indexing/handlers/persistent.rs | 383 +++++ core/src/ops/indexing/job.rs | 8 +- core/src/ops/indexing/mod.rs | 4 +- core/src/ops/indexing/responder.rs | 11 +- core/src/service/mod.rs | 31 +- core/src/service/watcher/mod.rs | 1469 +--------------- core/src/service/watcher/service.rs | 399 +++++ .../{watcher => watcher_old}/event_handler.rs | 0 .../{watcher => watcher_old}/example.rs | 0 .../{watcher => watcher_old}/metrics.rs | 0 core/src/service/watcher_old/mod.rs | 1503 +++++++++++++++++ .../platform/linux.rs | 0 .../platform/macos.rs | 65 +- .../{watcher => watcher_old}/platform/mod.rs | 0 .../platform/windows.rs | 0 .../service/{watcher => watcher_old}/tests.rs | 0 .../service/{watcher => watcher_old}/utils.rs | 0 .../{watcher => watcher_old}/worker.rs | 0 core/src/testing/integration_utils.rs | 20 +- crates/fs-watcher/Cargo.toml | 41 + crates/fs-watcher/README.md | 203 +++ crates/fs-watcher/src/config.rs | 259 +++ crates/fs-watcher/src/error.rs | 61 + crates/fs-watcher/src/event.rs | 264 +++ crates/fs-watcher/src/lib.rs | 69 + crates/fs-watcher/src/platform/linux.rs | 160 ++ crates/fs-watcher/src/platform/macos.rs | 420 +++++ crates/fs-watcher/src/platform/mod.rs | 144 ++ crates/fs-watcher/src/platform/windows.rs | 194 +++ crates/fs-watcher/src/watcher.rs | 535 ++++++ packages/assets/sounds/index.ts | 3 + packages/assets/sounds/pairing.mp3 | Bin 0 -> 18875 bytes packages/assets/sounds/pairing.ogg | Bin 0 -> 16892 bytes .../src/components/FileOperationModal.tsx | 48 +- .../interface/src/components/PairingModal.tsx | 2 + 49 files changed, 5074 insertions(+), 1547 deletions(-) create mode 100644 core/src/ops/indexing/handlers/ephemeral.rs create mode 100644 core/src/ops/indexing/handlers/mod.rs create mode 100644 core/src/ops/indexing/handlers/persistent.rs create mode 100644 core/src/service/watcher/service.rs rename core/src/service/{watcher => watcher_old}/event_handler.rs (100%) rename core/src/service/{watcher => watcher_old}/example.rs (100%) rename core/src/service/{watcher => watcher_old}/metrics.rs (100%) create mode 100644 core/src/service/watcher_old/mod.rs rename core/src/service/{watcher => watcher_old}/platform/linux.rs (100%) rename core/src/service/{watcher => watcher_old}/platform/macos.rs (92%) rename core/src/service/{watcher => watcher_old}/platform/mod.rs (100%) rename core/src/service/{watcher => watcher_old}/platform/windows.rs (100%) rename core/src/service/{watcher => watcher_old}/tests.rs (100%) rename core/src/service/{watcher => watcher_old}/utils.rs (100%) rename core/src/service/{watcher => watcher_old}/worker.rs (100%) create mode 100644 crates/fs-watcher/Cargo.toml create mode 100644 crates/fs-watcher/README.md create mode 100644 crates/fs-watcher/src/config.rs create mode 100644 crates/fs-watcher/src/error.rs create mode 100644 crates/fs-watcher/src/event.rs create mode 100644 crates/fs-watcher/src/lib.rs create mode 100644 crates/fs-watcher/src/platform/linux.rs create mode 100644 crates/fs-watcher/src/platform/macos.rs create mode 100644 crates/fs-watcher/src/platform/mod.rs create mode 100644 crates/fs-watcher/src/platform/windows.rs create mode 100644 crates/fs-watcher/src/watcher.rs create mode 100644 packages/assets/sounds/pairing.mp3 create mode 100644 packages/assets/sounds/pairing.ogg diff --git a/AGENTS.md b/AGENTS.md index bfb553f30..c52ab0806 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,13 @@ cargo run --bin sd-cli -- # Run CLI (binary is sd-cli, not spaced - Using `println!` instead of `tracing` macros (`info!`, `debug!`, etc) - Implementing `Wire` manually instead of using `register_*` macros - Blocking the async runtime with synchronous I/O operations + +### Quick tips + - On frontend apps, such as the interface in React, you must ALWAYS ensure type-safety based on the auto generated TypeScript types from `ts-client`. Never cast to as any or redefine backend types. our hooks are typesafe with correct input/output types, but sometimes you might need to access types directly from the `ts-client`. - If you have changed types on the backend that are public to the frontend (have `Type` derive), then you must regenerate the types using `cargo run --bin generate_typescript_types` +- Read the `.mdx` files in /docs for context on any part of the app, they are kept up to date. +- ## Architecture Overview diff --git a/Cargo.lock b/Cargo.lock index f43637d591fc842867b48ddc18d1abcffc55e2ce..0577394cb1936d8dbbda2e5134b1a60b1fa3d654 100644 GIT binary patch delta 484 zcmYjMJ!lkB7-ZkuT#_Rg$YHV|;wF_vZu9o-`whnA5Q3dTKrHiqQIL3=gOE;0VPlmA zztTcM5aSPui`WZxTG&RkkB~wu3s=(E48sh=%sf5n9scUwdz)N4fa}kbX?VGWH&$aM zy>l^AsY_!8VNqLSLnPLeEZRV9sEGBIP~LG?25O1&L^@@RCMtsYfoJPi>%C_FAo=>B zlaoRh0)A^Wgr5q{B&z=YY~v#HRU6V&mb{EnamlSDN=C{!<05dTV=#gSA4za5aKQ}^ zffJ_~p%z|0NwSBZ@CEtyZOS_?a|G++aK)?!#12>M(66y zY#j1Do$H)fa6FHDu(XfI;KP1$WsL`-s4!Y-*m2|iil(LF-iz|!4%$&@m zoczR;%)E3313hy+6Nq3wh-a*4xqWgQQ_1G(yjIL_xARytd$6|KIx=s!b!1t%695L~ BBe?(o diff --git a/apps/mobile/modules/sd-mobile-core/core/Cargo.lock b/apps/mobile/modules/sd-mobile-core/core/Cargo.lock index f07cd6a5fa17142f14d12b3bda6bc3d2ac6efe91..fbd6cb57865f87ad37afb1d2d6a2d0715758fbbc 100644 GIT binary patch delta 229 zcmbQci0}FmzJ@J~@|#4H3rdTXxXMzCiZk=`6l@ig4D}4nCm*~NWM`O?W|EYYl4@?6 zn3kGiZf2ZhZeWpQo|>9!m}-$~Zf0tjYG9ONX`W`5VwwVyPqnZ#HZxDPG&N5$GM+5R zEXI|QT9lhvqMMkMIeq_LCgttYn;4a(8BMm^on>rmp8hJFNof0#0Hz0wD!Hk-xrqfv z3I=+XdX`E$Tnb8g`6ZcYl@MlWZamPL(*rb_M5gZzVv^=ANG!_E%uA2Y$uF6nu$ob1 Z`tu;B57ToNG4Zxv4`$kaJ($_e768E3OlbfB delta 71 zcmV-N0J#6xqz;{;4uG@)AF{U#vjH9(mmK;62$v?x0uHx7&H-_Qx57~Z-~k41Z**y9 dd6yBr0T-9vQvw&41yln6hY?i*w-HqXMm^Zo8NdJl diff --git a/core/Cargo.toml b/core/Cargo.toml index adf44d036..1edabffb8 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -115,6 +115,7 @@ sd-task-system = { path = "../crates/task-system" } blurhash = "0.2" image = "0.25" sd-ffmpeg = { path = "../crates/ffmpeg", optional = true } +sd-fs-watcher = { path = "../crates/fs-watcher" } sd-images = { path = "../crates/images" } sd-media-metadata = { path = "../crates/media-metadata" } tokio-rustls = "0.26" diff --git a/core/src/config/app_config.rs b/core/src/config/app_config.rs index bbb1c2914..89a3bb924 100644 --- a/core/src/config/app_config.rs +++ b/core/src/config/app_config.rs @@ -48,8 +48,8 @@ pub struct ServiceConfig { /// Whether volume monitoring is enabled pub volume_monitoring_enabled: bool, - /// Whether location watcher is enabled - pub location_watcher_enabled: bool, + /// Whether filesystem watcher is enabled + pub fs_watcher_enabled: bool, } impl Default for ServiceConfig { @@ -57,7 +57,7 @@ impl Default for ServiceConfig { Self { networking_enabled: true, volume_monitoring_enabled: true, - location_watcher_enabled: true, + fs_watcher_enabled: true, } } } diff --git a/core/src/context.rs b/core/src/context.rs index d5cb1546e..d0da34e37 100644 --- a/core/src/context.rs +++ b/core/src/context.rs @@ -5,7 +5,8 @@ use crate::{ infra::action::manager::ActionManager, infra::event::EventBus, infra::sync::TransactionManager, library::LibraryManager, ops::indexing::ephemeral::EphemeralIndexCache, service::network::NetworkingService, service::session::SessionStateService, - service::sidecar_manager::SidecarManager, volume::VolumeManager, + service::sidecar_manager::SidecarManager, service::watcher::FsWatcherService, + volume::VolumeManager, }; use std::{path::PathBuf, sync::Arc}; use tokio::sync::{Mutex, RwLock}; @@ -22,7 +23,7 @@ pub struct CoreContext { pub action_manager: Arc>>>, pub networking: Arc>>>, pub plugin_manager: Arc>>>>, - pub location_watcher: Arc>>>, + pub fs_watcher: Arc>>>, // Ephemeral index cache for unmanaged paths pub ephemeral_index_cache: Arc, // Job logging configuration @@ -50,7 +51,7 @@ impl CoreContext { action_manager: Arc::new(RwLock::new(None)), networking: Arc::new(RwLock::new(None)), plugin_manager: Arc::new(RwLock::new(None)), - location_watcher: Arc::new(RwLock::new(None)), + fs_watcher: Arc::new(RwLock::new(None)), ephemeral_index_cache: Arc::new( EphemeralIndexCache::new().expect("Failed to create ephemeral index cache"), ), @@ -102,14 +103,14 @@ impl CoreContext { *self.networking.write().await = Some(networking); } - /// Helper method for services to get the location watcher - pub async fn get_location_watcher(&self) -> Option> { - self.location_watcher.read().await.clone() + /// Helper method for services to get the filesystem watcher + pub async fn get_fs_watcher(&self) -> Option> { + self.fs_watcher.read().await.clone() } - /// Method for Core to set location watcher after it's initialized - pub async fn set_location_watcher(&self, watcher: Arc) { - *self.location_watcher.write().await = Some(watcher); + /// Method for Core to set filesystem watcher after it's initialized + pub async fn set_fs_watcher(&self, watcher: Arc) { + *self.fs_watcher.write().await = Some(watcher); } /// Helper method to get the action manager diff --git a/core/src/lib.rs b/core/src/lib.rs index 4b1736bf1..8ddb5a0f2 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -185,10 +185,8 @@ impl Core { .set_sidecar_manager(services.sidecar_manager.clone()) .await; - // Set location watcher in context so it can be accessed by jobs (for ephemeral watch registration) - context - .set_location_watcher(services.location_watcher.clone()) - .await; + // Set filesystem watcher in context so it can be accessed by jobs (for ephemeral watch registration) + context.set_fs_watcher(services.fs_watcher.clone()).await; // Auto-load all libraries with context for job manager initialization info!("Loading existing libraries..."); @@ -229,6 +227,23 @@ impl Core { info!("Library filesystem watcher started"); } + // Load locations from all libraries into the filesystem watcher + for library in &loaded_libraries { + info!("Loading locations for library {}", library.id()); + match services.fs_watcher.load_library_locations(library).await { + Ok(count) => { + info!("Loaded {} locations from library {}", count, library.id()); + } + Err(e) => { + error!( + "Failed to load locations for library {}: {}", + library.id(), + e + ); + } + } + } + // Initialize sidecar manager for each loaded library for library in &loaded_libraries { info!("Initializing sidecar manager for library {}", library.id()); diff --git a/core/src/ops/indexing/change_detection/handler.rs b/core/src/ops/indexing/change_detection/handler.rs index 87db2dd52..57e355522 100644 --- a/core/src/ops/indexing/change_detection/handler.rs +++ b/core/src/ops/indexing/change_detection/handler.rs @@ -193,10 +193,10 @@ pub async fn build_dir_entry( /// creates, and finally modifies. pub async fn apply_batch( handler: &mut H, - events: Vec, + events: Vec, config: &ChangeConfig<'_>, ) -> Result<()> { - use crate::infra::event::FsRawEventKind; + use sd_fs_watcher::FsEventKind; if events.is_empty() { return Ok(()); @@ -208,11 +208,11 @@ pub async fn apply_batch( let mut renames = Vec::new(); for event in events { - match event { - FsRawEventKind::Create { path } => creates.push(path), - FsRawEventKind::Modify { path } => modifies.push(path), - FsRawEventKind::Remove { path } => removes.push(path), - FsRawEventKind::Rename { from, to } => renames.push((from, to)), + match event.kind { + FsEventKind::Create => creates.push(event.path), + FsEventKind::Modify => modifies.push(event.path), + FsEventKind::Remove => removes.push(event.path), + FsEventKind::Rename { from, to } => renames.push((from, to)), } } diff --git a/core/src/ops/indexing/change_detection/types.rs b/core/src/ops/indexing/change_detection/types.rs index ba1ff63bf..02412a01f 100644 --- a/core/src/ops/indexing/change_detection/types.rs +++ b/core/src/ops/indexing/change_detection/types.rs @@ -13,7 +13,7 @@ use uuid::Uuid; /// /// This enum represents changes that can come from either: /// - The `ChangeDetector` during batch indexing scans -/// - The file watcher via `FsRawEventKind` conversion +/// - The file watcher via `FsEvent` conversion #[derive(Debug, Clone)] pub enum Change { /// New file/directory (not in storage). @@ -60,24 +60,24 @@ impl Change { } } - /// Create a Change from an FsRawEventKind (for watcher integration). + /// Create a Change from an FsEvent (for watcher integration). /// Note: These variants don't have entry_ids since they come from the watcher. - pub fn from_fs_event(event: crate::infra::event::FsRawEventKind) -> Self { - use crate::infra::event::FsRawEventKind; + pub fn from_fs_event(event: sd_fs_watcher::FsEvent) -> Self { + use sd_fs_watcher::FsEventKind; - match event { - FsRawEventKind::Create { path } => Change::New(path), - FsRawEventKind::Modify { path } => Change::Modified { - path, + match event.kind { + FsEventKind::Create => Change::New(event.path), + FsEventKind::Modify => Change::Modified { + path: event.path, entry_id: 0, // Placeholder - handler will look up real ID old_modified: None, new_modified: None, }, - FsRawEventKind::Remove { path } => Change::Deleted { - path, + FsEventKind::Remove => Change::Deleted { + path: event.path, entry_id: 0, // Placeholder - handler will look up real ID }, - FsRawEventKind::Rename { from, to } => Change::Moved { + FsEventKind::Rename { from, to } => Change::Moved { old_path: from, new_path: to, entry_id: 0, // Placeholder - handler will look up real ID diff --git a/core/src/ops/indexing/ephemeral/responder.rs b/core/src/ops/indexing/ephemeral/responder.rs index 504dc6078..42d6e3d06 100644 --- a/core/src/ops/indexing/ephemeral/responder.rs +++ b/core/src/ops/indexing/ephemeral/responder.rs @@ -11,15 +11,15 @@ //! //! // Check if an event should be handled by the ephemeral system //! if let Some(root) = responder::find_ephemeral_root(&path, &context) { -//! responder::process_event(&context, &root, event_kind).await?; +//! responder::process_event(&context, &root, event).await?; //! } //! ``` use crate::context::CoreContext; -use crate::infra::event::FsRawEventKind; use crate::ops::indexing::change_detection::{self, ChangeConfig}; use crate::ops::indexing::rules::RuleToggles; use anyhow::Result; +use sd_fs_watcher::{FsEvent, FsEventKind}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -34,16 +34,16 @@ pub fn find_ephemeral_root(path: &Path, context: &CoreContext) -> Option Option { let paths: Vec<&Path> = events .iter() - .flat_map(|e| match e { - FsRawEventKind::Create { path } => vec![path.as_path()], - FsRawEventKind::Modify { path } => vec![path.as_path()], - FsRawEventKind::Remove { path } => vec![path.as_path()], - FsRawEventKind::Rename { from, to } => vec![from.as_path(), to.as_path()], + .flat_map(|e| match &e.kind { + FsEventKind::Create => vec![e.path.as_path()], + FsEventKind::Modify => vec![e.path.as_path()], + FsEventKind::Remove => vec![e.path.as_path()], + FsEventKind::Rename { from, to } => vec![from.as_path(), to.as_path()], }) .collect(); @@ -60,13 +60,20 @@ pub fn find_ephemeral_root_for_events( pub async fn apply_batch( context: &Arc, root_path: &Path, - events: Vec, + events: Vec, rule_toggles: RuleToggles, ) -> Result<()> { if events.is_empty() { + tracing::debug!("ephemeral::responder::apply_batch() called with empty events"); return Ok(()); } + tracing::debug!( + "ephemeral::responder::apply_batch() processing {} events for root: {}", + events.len(), + root_path.display() + ); + let index = context.ephemeral_cache().get_global_index(); let event_bus = context.events.clone(); @@ -85,9 +92,14 @@ pub async fn apply_batch( pub async fn apply( context: &Arc, root_path: &Path, - event: FsRawEventKind, + event: FsEvent, rule_toggles: RuleToggles, ) -> Result<()> { + tracing::debug!( + "ephemeral::responder::apply() called for root: {}, event: {:?}", + root_path.display(), + event + ); apply_batch(context, root_path, vec![event], rule_toggles).await } diff --git a/core/src/ops/indexing/ephemeral/types.rs b/core/src/ops/indexing/ephemeral/types.rs index 1fc9076eb..d5304613c 100644 --- a/core/src/ops/indexing/ephemeral/types.rs +++ b/core/src/ops/indexing/ephemeral/types.rs @@ -469,3 +469,4 @@ mod tests { } } + diff --git a/core/src/ops/indexing/ephemeral/writer.rs b/core/src/ops/indexing/ephemeral/writer.rs index 58e9fe785..f2f5873c6 100644 --- a/core/src/ops/indexing/ephemeral/writer.rs +++ b/core/src/ops/indexing/ephemeral/writer.rs @@ -146,13 +146,28 @@ impl ChangeHandler for MemoryAdapter { let entry_uuid = Uuid::new_v4(); let entry_metadata = EntryMetadata::from(metadata.clone()); + tracing::debug!( + "MemoryAdapter::create() called for path: {}", + metadata.path.display() + ); + let (entry_id, content_kind) = self .add_entry_internal(&metadata.path, entry_uuid, entry_metadata.clone()) .await?; if let Some(content_kind) = content_kind { + tracing::debug!( + "Emitting ResourceChanged for ephemeral create: {} (content_kind: {:?})", + metadata.path.display(), + content_kind + ); self.emit_resource_changed(entry_uuid, &metadata.path, &entry_metadata, content_kind) .await; + } else { + tracing::warn!( + "No content_kind for ephemeral entry, skipping ResourceChanged: {}", + metadata.path.display() + ); } Ok(EntryRef { diff --git a/core/src/ops/indexing/handlers/ephemeral.rs b/core/src/ops/indexing/handlers/ephemeral.rs new file mode 100644 index 000000000..9263cacf9 --- /dev/null +++ b/core/src/ops/indexing/handlers/ephemeral.rs @@ -0,0 +1,177 @@ +//! Ephemeral event handler +//! +//! Subscribes to filesystem events and routes them to the ephemeral responder +//! for in-memory index updates. Used for browsing external drives, network shares, +//! and other non-persistent locations. +//! +//! ## Characteristics +//! +//! - **Shallow watching**: Only processes events for immediate children of watched directories +//! - **No batching**: Memory writes are fast, events processed immediately +//! - **Session-based**: Events only processed for active browsing sessions + +use crate::context::CoreContext; +use crate::ops::indexing::ephemeral::responder; +use crate::ops::indexing::rules::RuleToggles; +use crate::service::watcher::FsWatcherService; +use anyhow::Result; +use sd_fs_watcher::FsEvent; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tokio::sync::{broadcast, RwLock}; +use tracing::{debug, error, trace, warn}; + +/// Handler for ephemeral (in-memory) filesystem events +/// +/// Subscribes to `FsWatcher` events and routes matching events to the +/// ephemeral responder for immediate in-memory updates. +pub struct EphemeralEventHandler { + /// Core context (contains ephemeral_cache) + context: Arc, + /// Reference to the filesystem watcher service (set via connect()) + fs_watcher: RwLock>>, + /// Whether the handler is running + is_running: Arc, + /// Default rule toggles for filtering + rule_toggles: RuleToggles, +} + +impl EphemeralEventHandler { + /// Create a new ephemeral event handler (unconnected) + /// + /// Call `connect()` to attach to a FsWatcherService before starting. + pub fn new_unconnected(context: Arc) -> Self { + Self { + context, + fs_watcher: RwLock::new(None), + is_running: Arc::new(AtomicBool::new(false)), + rule_toggles: RuleToggles::default(), + } + } + + /// Create a new ephemeral event handler (connected) + pub fn new(context: Arc, fs_watcher: Arc) -> Self { + Self { + context, + fs_watcher: RwLock::new(Some(fs_watcher)), + is_running: Arc::new(AtomicBool::new(false)), + rule_toggles: RuleToggles::default(), + } + } + + /// Connect to a FsWatcherService + pub fn connect(&self, fs_watcher: Arc) { + // Use blocking_write since this is called during init, not async context + *self.fs_watcher.blocking_write() = Some(fs_watcher); + } + + /// Start the event handler + /// + /// Spawns a task that subscribes to filesystem events and routes + /// matching events to the ephemeral responder. + pub async fn start(&self) -> Result<()> { + if self.is_running.swap(true, Ordering::SeqCst) { + warn!("EphemeralEventHandler is already running"); + return Ok(()); + } + + let fs_watcher = self.fs_watcher.read().await.clone(); + let Some(fs_watcher) = fs_watcher else { + return Err(anyhow::anyhow!( + "EphemeralEventHandler not connected to FsWatcherService" + )); + }; + + debug!("Starting EphemeralEventHandler"); + + let mut rx = fs_watcher.subscribe(); + let context = self.context.clone(); + let rule_toggles = self.rule_toggles; + let is_running = self.is_running.clone(); + + tokio::spawn(async move { + debug!("EphemeralEventHandler task started"); + + while is_running.load(Ordering::SeqCst) { + match rx.recv().await { + Ok(event) => { + if let Err(e) = Self::handle_event(&context, &event, rule_toggles).await { + error!("Error handling ephemeral event: {}", e); + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!("EphemeralEventHandler lagged by {} events", n); + // Continue processing - we'll catch up + } + Err(broadcast::error::RecvError::Closed) => { + debug!("FsWatcher channel closed, stopping EphemeralEventHandler"); + break; + } + } + } + + debug!("EphemeralEventHandler task stopped"); + }); + + Ok(()) + } + + /// Stop the event handler + pub fn stop(&self) { + debug!("Stopping EphemeralEventHandler"); + self.is_running.store(false, Ordering::SeqCst); + } + + /// Check if the handler is running + pub fn is_running(&self) -> bool { + self.is_running.load(Ordering::SeqCst) + } + + /// Handle a single filesystem event + /// + /// Checks if the event's path is under an ephemeral watched directory. + /// For shallow watches, only processes events for immediate children. + async fn handle_event( + context: &Arc, + event: &FsEvent, + rule_toggles: RuleToggles, + ) -> Result<()> { + // Get the parent directory of the event path + let Some(parent) = event.path.parent() else { + trace!("Event path has no parent: {}", event.path.display()); + return Ok(()); + }; + + // Check if the parent is being watched (shallow watch = immediate children only) + let watched_paths = context.ephemeral_cache().watched_paths(); + + // Find if any watched path matches the parent + let matching_root = watched_paths.iter().find(|watched| { + // For shallow watches, parent must exactly match the watched path + parent == watched.as_path() + }); + + let Some(root_path) = matching_root else { + // Not under any ephemeral watch + trace!("Event not under ephemeral watch: {}", event.path.display()); + return Ok(()); + }; + + debug!( + "Ephemeral event matched: {} (root: {})", + event.path.display(), + root_path.display() + ); + + // Pass FsEvent directly to responder + responder::apply(context, root_path, event.clone(), rule_toggles).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Integration tests would require full context setup + // The handler logic is straightforward - subscribe, filter, route +} diff --git a/core/src/ops/indexing/handlers/mod.rs b/core/src/ops/indexing/handlers/mod.rs new file mode 100644 index 000000000..650801052 --- /dev/null +++ b/core/src/ops/indexing/handlers/mod.rs @@ -0,0 +1,10 @@ +//! Event handlers for filesystem changes +//! +//! These handlers subscribe to `FsWatcher` events and route them to the +//! appropriate storage layer (database for persistent, memory for ephemeral). + +mod ephemeral; +mod persistent; + +pub use ephemeral::EphemeralEventHandler; +pub use persistent::{LocationMeta, PersistentEventHandler}; diff --git a/core/src/ops/indexing/handlers/persistent.rs b/core/src/ops/indexing/handlers/persistent.rs new file mode 100644 index 000000000..9c978cf52 --- /dev/null +++ b/core/src/ops/indexing/handlers/persistent.rs @@ -0,0 +1,383 @@ +//! Persistent event handler +//! +//! Subscribes to filesystem events and routes them to location workers +//! for batched database persistence. Used for indexed locations. +//! +//! ## Characteristics +//! +//! - **Recursive watching**: Processes events for entire directory trees +//! - **Batching**: Events are collected and processed in batches for efficiency +//! - **Location-scoped**: Events are routed to the appropriate location's worker + +use crate::context::CoreContext; +use crate::ops::indexing::responder; +use crate::ops::indexing::rules::RuleToggles; +use crate::service::watcher::FsWatcherService; +use anyhow::Result; +use sd_fs_watcher::FsEvent; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{broadcast, mpsc, RwLock}; +use tracing::{debug, error, info, trace, warn}; +use uuid::Uuid; + +/// Metadata for a watched location +#[derive(Debug, Clone)] +pub struct LocationMeta { + /// Location UUID + pub id: Uuid, + /// Library UUID this location belongs to + pub library_id: Uuid, + /// Root path of the location + pub root_path: PathBuf, + /// Indexing rule toggles + pub rule_toggles: RuleToggles, +} + +/// Configuration for the persistent event handler +#[derive(Debug, Clone)] +pub struct PersistentHandlerConfig { + /// Debounce window for batching events (ms) + pub debounce_window_ms: u64, + /// Maximum batch size + pub max_batch_size: usize, + /// Worker channel buffer size + pub worker_buffer_size: usize, +} + +impl Default for PersistentHandlerConfig { + fn default() -> Self { + Self { + debounce_window_ms: 150, + max_batch_size: 10000, + worker_buffer_size: 100000, + } + } +} + +/// Handler for persistent (database-backed) filesystem events +/// +/// Subscribes to `FsWatcher` events, filters by location scope, +/// and routes to per-location workers for batched processing. +pub struct PersistentEventHandler { + /// Core context for database access + context: Arc, + /// Reference to the filesystem watcher service (set via connect()) + fs_watcher: RwLock>>, + /// Registered locations (root_path -> meta) + locations: Arc>>, + /// Per-location worker channels + workers: Arc>>>, + /// Whether the handler is running + is_running: Arc, + /// Configuration + config: PersistentHandlerConfig, +} + +impl PersistentEventHandler { + /// Create a new persistent event handler (unconnected) + /// + /// Call `connect()` to attach to a FsWatcherService before starting. + pub fn new_unconnected(context: Arc) -> Self { + Self { + context, + fs_watcher: RwLock::new(None), + locations: Arc::new(RwLock::new(HashMap::new())), + workers: Arc::new(RwLock::new(HashMap::new())), + is_running: Arc::new(AtomicBool::new(false)), + config: PersistentHandlerConfig::default(), + } + } + + /// Create a new persistent event handler (connected) + pub fn new(context: Arc, fs_watcher: Arc) -> Self { + Self { + context, + fs_watcher: RwLock::new(Some(fs_watcher)), + locations: Arc::new(RwLock::new(HashMap::new())), + workers: Arc::new(RwLock::new(HashMap::new())), + is_running: Arc::new(AtomicBool::new(false)), + config: PersistentHandlerConfig::default(), + } + } + + /// Connect to a FsWatcherService + pub fn connect(&self, fs_watcher: Arc) { + *self.fs_watcher.blocking_write() = Some(fs_watcher); + } + + /// Register a location for persistent indexing + pub async fn add_location(&self, meta: LocationMeta) -> Result<()> { + let location_id = meta.id; + let root_path = meta.root_path.clone(); + + info!( + "Registering location {} at {}", + location_id, + root_path.display() + ); + + // Add to locations map + { + let mut locations = self.locations.write().await; + locations.insert(root_path.clone(), meta.clone()); + } + + // Create worker if handler is running + if self.is_running.load(Ordering::SeqCst) { + self.ensure_worker(meta).await?; + } + + // Register path with FsWatcher if connected + if let Some(fs_watcher) = self.fs_watcher.read().await.as_ref() { + fs_watcher + .watch_path(&root_path, sd_fs_watcher::WatchConfig::recursive()) + .await?; + } + + Ok(()) + } + + /// Unregister a location + pub async fn remove_location(&self, location_id: Uuid) -> Result<()> { + info!("Unregistering location {}", location_id); + + // Find and remove the location + let root_path = { + let mut locations = self.locations.write().await; + let path = locations + .iter() + .find(|(_, meta)| meta.id == location_id) + .map(|(path, _)| path.clone()); + + if let Some(path) = &path { + locations.remove(path); + } + path + }; + + // Remove worker + { + let mut workers = self.workers.write().await; + workers.remove(&location_id); + } + + // Unwatch path if connected + if let Some(path) = root_path { + if let Some(fs_watcher) = self.fs_watcher.read().await.as_ref() { + if let Err(e) = fs_watcher.unwatch_path(&path).await { + warn!("Failed to unwatch path {}: {}", path.display(), e); + } + } + } + + Ok(()) + } + + /// Get all registered locations + pub async fn locations(&self) -> Vec { + self.locations.read().await.values().cloned().collect() + } + + /// Start the event handler + pub async fn start(&self) -> Result<()> { + if self.is_running.swap(true, Ordering::SeqCst) { + warn!("PersistentEventHandler is already running"); + return Ok(()); + } + + let fs_watcher = self.fs_watcher.read().await.clone(); + let Some(fs_watcher) = fs_watcher else { + return Err(anyhow::anyhow!( + "PersistentEventHandler not connected to FsWatcherService" + )); + }; + + debug!("Starting PersistentEventHandler"); + + // Create workers for all registered locations + let locations: Vec = self.locations.read().await.values().cloned().collect(); + for meta in locations { + self.ensure_worker(meta).await?; + } + + // Start the event routing task + let mut rx = fs_watcher.subscribe(); + let locations = self.locations.clone(); + let workers = self.workers.clone(); + let is_running = self.is_running.clone(); + + tokio::spawn(async move { + debug!("PersistentEventHandler routing task started"); + + while is_running.load(Ordering::SeqCst) { + match rx.recv().await { + Ok(event) => { + if let Err(e) = Self::route_event(&event, &locations, &workers).await { + error!("Error routing persistent event: {}", e); + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!("PersistentEventHandler lagged by {} events", n); + } + Err(broadcast::error::RecvError::Closed) => { + debug!("FsWatcher channel closed, stopping PersistentEventHandler"); + break; + } + } + } + + debug!("PersistentEventHandler routing task stopped"); + }); + + Ok(()) + } + + /// Stop the event handler + pub async fn stop(&self) { + debug!("Stopping PersistentEventHandler"); + self.is_running.store(false, Ordering::SeqCst); + + // Clear workers (dropping senders will stop worker tasks) + let mut workers = self.workers.write().await; + workers.clear(); + } + + /// Check if the handler is running + pub fn is_running(&self) -> bool { + self.is_running.load(Ordering::SeqCst) + } + + /// Ensure a worker exists for a location + async fn ensure_worker(&self, meta: LocationMeta) -> Result<()> { + let mut workers = self.workers.write().await; + if workers.contains_key(&meta.id) { + return Ok(()); + } + + debug!("Creating worker for location {}", meta.id); + + let (tx, rx) = mpsc::channel(self.config.worker_buffer_size); + workers.insert(meta.id, tx); + + // Spawn worker task + let context = self.context.clone(); + let config = self.config.clone(); + + tokio::spawn(async move { + if let Err(e) = Self::run_worker(rx, meta, context, config).await { + error!("Location worker failed: {}", e); + } + }); + + Ok(()) + } + + /// Route an event to the appropriate location worker + async fn route_event( + event: &FsEvent, + locations: &Arc>>, + workers: &Arc>>>, + ) -> Result<()> { + let locs = locations.read().await; + + // Find the best matching location (longest prefix match) + let mut best_match: Option<&LocationMeta> = None; + let mut longest_len = 0; + + for (root_path, meta) in locs.iter() { + if event.path.starts_with(root_path) { + let len = root_path.as_os_str().len(); + if len > longest_len { + longest_len = len; + best_match = Some(meta); + } + } + } + + let Some(location) = best_match else { + trace!("Event not under any location: {}", event.path.display()); + return Ok(()); + }; + + // Send to worker + let workers_map = workers.read().await; + if let Some(tx) = workers_map.get(&location.id) { + if let Err(e) = tx.send(event.clone()).await { + warn!( + "Failed to send event to worker for location {}: {}", + location.id, e + ); + } + } + + Ok(()) + } + + /// Run the location worker (batching + responder calls) + async fn run_worker( + mut rx: mpsc::Receiver, + meta: LocationMeta, + context: Arc, + config: PersistentHandlerConfig, + ) -> Result<()> { + info!("Location worker started for {}", meta.id); + + while let Some(first_event) = rx.recv().await { + // Start batching window + let mut batch = vec![first_event]; + let deadline = Instant::now() + Duration::from_millis(config.debounce_window_ms); + + // Collect events within the debounce window + while Instant::now() < deadline && batch.len() < config.max_batch_size { + match rx.try_recv() { + Ok(event) => batch.push(event), + Err(mpsc::error::TryRecvError::Empty) => { + // Brief sleep to avoid busy waiting + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(mpsc::error::TryRecvError::Disconnected) => break, + } + } + + debug!( + "Processing batch of {} events for location {}", + batch.len(), + meta.id + ); + + // Pass FsEvent batch directly to responder + if let Err(e) = responder::apply_batch( + &context, + meta.library_id, + meta.id, + batch, + meta.rule_toggles, + &meta.root_path, + None, // volume_backend - TODO: resolve from context + ) + .await + { + error!("Failed to apply batch for location {}: {}", meta.id, e); + } + } + + info!("Location worker stopped for {}", meta.id); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_default() { + let config = PersistentHandlerConfig::default(); + assert_eq!(config.debounce_window_ms, 150); + assert_eq!(config.max_batch_size, 10000); + } +} diff --git a/core/src/ops/indexing/job.rs b/core/src/ops/indexing/job.rs index 4c589a1dd..31409f0a7 100644 --- a/core/src/ops/indexing/job.rs +++ b/core/src/ops/indexing/job.rs @@ -522,11 +522,9 @@ impl JobHandler for IndexerJob { // Automatically add filesystem watch for successfully indexed ephemeral paths // This enables real-time updates when files change in browsed directories - if let Some(watcher) = ctx.library().core_context().get_location_watcher().await { - if let Err(e) = watcher.add_ephemeral_watch( - local_path.to_path_buf(), - self.config.rule_toggles - ).await { + if let Some(watcher) = ctx.library().core_context().get_fs_watcher().await { + if let Err(e) = watcher.watch_ephemeral(local_path.to_path_buf()).await + { ctx.log(format!( "Warning: Failed to add ephemeral watch for {}: {}", local_path.display(), diff --git a/core/src/ops/indexing/mod.rs b/core/src/ops/indexing/mod.rs index 20d904dfe..c669d6c2f 100644 --- a/core/src/ops/indexing/mod.rs +++ b/core/src/ops/indexing/mod.rs @@ -24,6 +24,7 @@ pub mod action; pub mod change_detection; pub mod database_storage; pub mod ephemeral; +pub mod handlers; pub mod hierarchy; pub mod input; pub mod job; @@ -41,10 +42,11 @@ pub mod verify; pub use action::IndexingAction; pub use change_detection::{ apply_batch as apply_change_batch, Change, ChangeConfig, ChangeDetector, ChangeHandler, - ChangeType, EntryRef, DatabaseAdapter, DatabaseAdapterForJob, + ChangeType, DatabaseAdapter, DatabaseAdapterForJob, EntryRef, }; pub use database_storage::{DatabaseStorage, EntryMetadata}; pub use ephemeral::{EphemeralIndex, EphemeralIndexCache, EphemeralIndexStats, MemoryAdapter}; +pub use handlers::{EphemeralEventHandler, LocationMeta, PersistentEventHandler}; pub use hierarchy::HierarchyQuery; pub use input::IndexInput; pub use job::{IndexMode, IndexScope, IndexerJob, IndexerJobConfig, IndexerOutput}; diff --git a/core/src/ops/indexing/responder.rs b/core/src/ops/indexing/responder.rs index d92682ebe..29c2c13d5 100644 --- a/core/src/ops/indexing/responder.rs +++ b/core/src/ops/indexing/responder.rs @@ -1,15 +1,14 @@ //! Persistent location responder. //! -//! Thin adapter over `DatabaseAdapter` that translates raw filesystem +//! Thin adapter over `DatabaseAdapter` that translates filesystem //! events into database mutations. The watcher calls `apply_batch` with events; //! this module delegates to the unified change handling infrastructure. use crate::context::CoreContext; - -use crate::infra::event::FsRawEventKind; use crate::ops::indexing::change_detection::{self, ChangeConfig, DatabaseAdapter}; use crate::ops::indexing::rules::RuleToggles; use anyhow::Result; +use sd_fs_watcher::FsEvent; use std::path::Path; use std::sync::Arc; use uuid::Uuid; @@ -22,7 +21,7 @@ pub async fn apply( context: &Arc, library_id: Uuid, location_id: Uuid, - kind: FsRawEventKind, + event: FsEvent, rule_toggles: RuleToggles, location_root: &Path, volume_backend: Option<&Arc>, @@ -31,7 +30,7 @@ pub async fn apply( context, library_id, location_id, - vec![kind], + vec![event], rule_toggles, location_root, volume_backend, @@ -48,7 +47,7 @@ pub async fn apply_batch( context: &Arc, library_id: Uuid, location_id: Uuid, - events: Vec, + events: Vec, rule_toggles: RuleToggles, location_root: &Path, volume_backend: Option<&Arc>, diff --git a/core/src/service/mod.rs b/core/src/service/mod.rs index 1479037b8..ce9fae808 100644 --- a/core/src/service/mod.rs +++ b/core/src/service/mod.rs @@ -18,19 +18,20 @@ pub mod sidecar_manager; pub mod sync; pub mod volume_monitor; pub mod watcher; +// NOTE: watcher_old/ is kept as reference during migration but not compiled use device::DeviceService; use file_sharing::FileSharingService; use network::NetworkingService; use sidecar_manager::SidecarManager; use volume_monitor::{VolumeMonitorConfig, VolumeMonitorService}; -use watcher::{LocationWatcher, LocationWatcherConfig}; +use watcher::{FsWatcherService, FsWatcherServiceConfig}; /// Container for all background services #[derive(Clone)] pub struct Services { - /// File system watcher for locations - pub location_watcher: Arc, + /// Filesystem watcher - detects changes and emits events + pub fs_watcher: Arc, /// File sharing service pub file_sharing: Arc, /// Device management service @@ -52,18 +53,18 @@ impl Services { pub fn new(context: Arc) -> Self { info!("Initializing background services"); - let location_watcher_config = LocationWatcherConfig::default(); - let location_watcher = Arc::new(LocationWatcher::new( - location_watcher_config, - context.events.clone(), - context.clone(), - )); + let fs_watcher_config = FsWatcherServiceConfig::default(); + let fs_watcher = Arc::new(FsWatcherService::new(context.clone(), fs_watcher_config)); + + // Connect handlers to the watcher (they need Arc) + fs_watcher.init_handlers(); + let file_sharing = Arc::new(FileSharingService::new(context.clone())); let device = Arc::new(DeviceService::new(context.clone())); let sidecar_manager = Arc::new(SidecarManager::new(context.clone())); let key_manager = context.key_manager.clone(); Self { - location_watcher, + fs_watcher, file_sharing, device, networking: None, // Initialized separately when needed @@ -83,7 +84,7 @@ impl Services { pub async fn start_all(&self) -> Result<()> { info!("Starting all background services"); - self.location_watcher.start().await?; + self.fs_watcher.start().await?; // Start volume monitor if initialized if let Some(monitor) = &self.volume_monitor { @@ -97,10 +98,10 @@ impl Services { pub async fn start_all_with_config(&self, config: &crate::config::ServiceConfig) -> Result<()> { info!("Starting background services based on configuration"); - if config.location_watcher_enabled { - self.location_watcher.start().await?; + if config.fs_watcher_enabled { + self.fs_watcher.start().await?; } else { - info!("Location watcher disabled in configuration"); + info!("Filesystem watcher disabled in configuration"); } // Start volume monitor if initialized and enabled @@ -131,7 +132,7 @@ impl Services { pub async fn stop_all(&self) -> Result<()> { info!("Stopping all background services"); - self.location_watcher.stop().await?; + self.fs_watcher.stop().await?; // Stop volume monitor if initialized if let Some(monitor) = &self.volume_monitor { diff --git a/core/src/service/watcher/mod.rs b/core/src/service/watcher/mod.rs index 8bcc125f9..74dd8cfc8 100644 --- a/core/src/service/watcher/mod.rs +++ b/core/src/service/watcher/mod.rs @@ -1,1454 +1,15 @@ -//! Location Watcher Service - Monitors file system changes in indexed locations - -use crate::context::CoreContext; -use crate::infra::event::{Event, EventBus, FsRawEventKind}; -use crate::service::Service; -use anyhow::Result; -use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher}; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::{mpsc, RwLock}; -use tracing::{debug, error, info, trace, warn}; -use uuid::Uuid; - -mod event_handler; -mod metrics; -mod platform; -pub mod utils; -mod worker; - -#[cfg(feature = "examples")] -pub mod example; - -pub use metrics::{LocationWorkerMetrics, MetricsCollector, WatcherMetrics}; -pub use worker::LocationWorker; - -pub use event_handler::WatcherEvent; -pub use platform::PlatformHandler; - -/// Configuration for the location watcher -#[derive(Debug, Clone)] -pub struct LocationWatcherConfig { - /// Debounce duration for file system events - pub debounce_duration: Duration, - /// Maximum number of events to buffer per location (never drops events) - pub event_buffer_size: usize, - /// Whether to enable detailed debug logging - pub debug_mode: bool, - /// Debounce window for batching events (100-250ms) - pub debounce_window_ms: u64, - /// Maximum batch size for processing efficiency - pub max_batch_size: usize, - /// Metrics logging interval - pub metrics_log_interval_ms: u64, - /// Whether to enable metrics collection - pub enable_metrics: bool, - /// Maximum queue depth before triggering re-index - pub max_queue_depth_before_reindex: usize, - /// Whether to enable focused re-indexing on overflow - pub enable_focused_reindex: bool, -} - -impl Default for LocationWatcherConfig { - fn default() -> Self { - Self { - debounce_duration: Duration::from_millis(100), - event_buffer_size: 100000, // Large buffer to never drop events - debug_mode: false, - debounce_window_ms: 150, // 150ms default debounce window - max_batch_size: 10000, // Large batches for efficiency - metrics_log_interval_ms: 30000, // 30 seconds - enable_metrics: true, - max_queue_depth_before_reindex: 50000, // 50% of buffer size - enable_focused_reindex: true, - } - } -} - -impl LocationWatcherConfig { - /// Create a new configuration with custom values - pub fn new(debounce_window_ms: u64, event_buffer_size: usize, max_batch_size: usize) -> Self { - Self { - debounce_duration: Duration::from_millis(100), - event_buffer_size, - debug_mode: false, - debounce_window_ms, - max_batch_size, - metrics_log_interval_ms: 30000, - enable_metrics: true, - max_queue_depth_before_reindex: event_buffer_size / 2, - enable_focused_reindex: true, - } - } - - /// Create a configuration optimized for resource-constrained environments - /// This is for future resource manager integration - pub fn resource_optimized(memory_quota: usize, cpu_quota: usize) -> Self { - // Calculate buffer size based on available memory (1KB per event estimate) - let event_buffer_size = std::cmp::max(10000, memory_quota / 1000); - - // Calculate batch size based on CPU quota (100 events per CPU unit) - let max_batch_size = std::cmp::max(1000, cpu_quota / 100); - - Self { - debounce_duration: Duration::from_millis(100), - event_buffer_size, - debug_mode: false, - debounce_window_ms: 150, - max_batch_size, - metrics_log_interval_ms: 30000, - enable_metrics: true, - max_queue_depth_before_reindex: event_buffer_size / 2, - enable_focused_reindex: true, - } - } - - /// Validate the configuration - pub fn validate(&self) -> Result<()> { - if self.debounce_window_ms < 50 { - return Err(anyhow::anyhow!("Debounce window must be at least 50ms")); - } - if self.debounce_window_ms > 1000 { - return Err(anyhow::anyhow!("Debounce window must be at most 1000ms")); - } - if self.event_buffer_size < 100 { - return Err(anyhow::anyhow!("Event buffer size must be at least 100")); - } - if self.max_batch_size < 1 { - return Err(anyhow::anyhow!("Max batch size must be at least 1")); - } - if self.max_batch_size > self.event_buffer_size { - return Err(anyhow::anyhow!( - "Max batch size cannot exceed event buffer size" - )); - } - Ok(()) - } -} - -/// Location watcher service that monitors file system changes -pub struct LocationWatcher { - /// Watcher configuration - config: LocationWatcherConfig, - /// Event bus for emitting events - events: Arc, - /// Core context for DB and library access - context: Arc, - /// Currently watched locations - watched_locations: Arc>>, - /// Ephemeral watches (shallow, non-recursive) keyed by path - ephemeral_watches: Arc>>, - /// File system watcher - watcher: Arc>>, - /// Whether the service is running - is_running: Arc>, - /// Platform-specific event handler - platform_handler: Arc, - /// Per-location workers - workers: Arc>>>, - /// Global watcher metrics - metrics: Arc, - /// Worker metrics by location - worker_metrics: Arc>>>, - /// Metrics collector for periodic logging - metrics_collector: Arc>>>, -} - -/// Information about a watched location -#[derive(Debug, Clone)] -pub struct WatchedLocation { - /// Location UUID - pub id: Uuid, - /// Library UUID this location belongs to - pub library_id: Uuid, - /// Path being watched - pub path: PathBuf, - /// Whether watching is enabled for this location - pub enabled: bool, - /// Indexing rule toggles for filtering events - pub rule_toggles: crate::ops::indexing::rules::RuleToggles, -} - -/// Information about an ephemeral watch (shallow, non-recursive) -#[derive(Debug, Clone)] -pub struct EphemeralWatch { - /// Path being watched - pub path: PathBuf, - /// Indexing rule toggles for filtering events - pub rule_toggles: crate::ops::indexing::rules::RuleToggles, -} - -impl LocationWatcher { - /// Create a new location watcher - pub fn new( - config: LocationWatcherConfig, - events: Arc, - context: Arc, - ) -> Self { - let platform_handler = Arc::new(PlatformHandler::new()); - - Self { - config, - events, - context, - watched_locations: Arc::new(RwLock::new(HashMap::new())), - ephemeral_watches: Arc::new(RwLock::new(HashMap::new())), - watcher: Arc::new(RwLock::new(None)), - is_running: Arc::new(RwLock::new(false)), - platform_handler, - workers: Arc::new(RwLock::new(HashMap::new())), - metrics: Arc::new(WatcherMetrics::new()), - worker_metrics: Arc::new(RwLock::new(HashMap::new())), - metrics_collector: Arc::new(RwLock::new(None)), - } - } - - /// Ensure a worker exists for the given location - async fn ensure_worker_for_location( - &self, - location_id: Uuid, - library_id: Uuid, - ) -> Result> { - // Check if worker already exists - { - let workers = self.workers.read().await; - if let Some(sender) = workers.get(&location_id) { - debug!( - "Worker already exists for location {}, reusing", - location_id - ); - return Ok(sender.clone()); - } - } - - info!("Creating new worker for location {}", location_id); - - // Get rule toggles and location root from watched locations - let (rule_toggles, location_root) = { - let locations = self.watched_locations.read().await; - locations - .get(&location_id) - .map(|loc| (loc.rule_toggles, loc.path.clone())) - .ok_or_else(|| { - anyhow::anyhow!("Location {} not found in watched locations", location_id) - })? - }; - - // Create metrics for this worker - let worker_metrics = Arc::new(LocationWorkerMetrics::new()); - { - let mut metrics_map = self.worker_metrics.write().await; - metrics_map.insert(location_id, worker_metrics.clone()); - } - - // Create new worker - let (tx, rx) = mpsc::channel(self.config.event_buffer_size); - let worker = LocationWorker::new( - location_id, - library_id, - rx, - self.context.clone(), - self.events.clone(), - self.config.clone(), - worker_metrics.clone(), - rule_toggles, - location_root, - ); - - // Record worker creation - self.metrics.record_worker_created(); - - // Register worker metrics with collector - if let Some(collector) = self.metrics_collector.read().await.as_ref() { - collector.add_worker_metrics(location_id, worker_metrics.clone()); - } - - // Spawn the worker task - tokio::spawn(async move { - if let Err(e) = worker.run().await { - error!("Location worker {} failed: {}", location_id, e); - } - }); - - // Store the sender - { - let mut workers = self.workers.write().await; - workers.insert(location_id, tx.clone()); - } - - Ok(tx) - } - - /// Remove a worker for a location - async fn remove_worker_for_location(&self, location_id: Uuid) { - let mut workers = self.workers.write().await; - workers.remove(&location_id); - - // Remove metrics - let mut metrics_map = self.worker_metrics.write().await; - metrics_map.remove(&location_id); - - // Unregister from metrics collector - if let Some(collector) = self.metrics_collector.read().await.as_ref() { - collector.remove_worker_metrics(&location_id); - } - - // Record worker destruction - self.metrics.record_worker_destroyed(); - } - - /// Get metrics for a specific location - pub async fn get_location_metrics( - &self, - location_id: Uuid, - ) -> Option> { - let metrics_map = self.worker_metrics.read().await; - metrics_map.get(&location_id).cloned() - } - - /// Get global watcher metrics - pub fn get_global_metrics(&self) -> Arc { - self.metrics.clone() - } - - /// Manually trigger metrics logging (useful for testing) - pub async fn log_metrics_now(&self) { - // Log global metrics - self.metrics.log_metrics(); - - // Log worker metrics - let worker_metrics = self.worker_metrics.read().await; - for (location_id, metrics) in worker_metrics.iter() { - metrics.log_metrics(*location_id); - } - } - - /// Start the metrics collector for periodic logging - async fn start_metrics_collector(&self) -> Result<()> { - if !self.config.enable_metrics { - return Ok(()); - } - - let log_interval = Duration::from_millis(self.config.metrics_log_interval_ms); - let metrics_collector = Arc::new(MetricsCollector::new(self.metrics.clone(), log_interval)); - - // Store reference for worker registration first - *self.metrics_collector.write().await = Some(metrics_collector.clone()); - - // Start the metrics collection task - tokio::spawn(async move { - metrics_collector.start_collection().await; - }); - - info!( - "Metrics collector started with {}ms interval", - self.config.metrics_log_interval_ms - ); - Ok(()) - } - - /// Add a location to watch - pub async fn add_location(&self, location: WatchedLocation) -> Result<()> { - if !location.enabled { - debug!( - "Location {} is disabled, not adding to watcher", - location.id - ); - return Ok(()); - } - - // Skip cloud locations - they don't have filesystem paths to watch - // Cloud paths use service-native URIs like s3://, gdrive://, etc. - let path_str = location.path.to_string_lossy(); - if path_str.contains("://") && !path_str.starts_with("local://") { - debug!( - "Skipping cloud location {} from filesystem watcher: {}", - location.id, path_str - ); - return Ok(()); - } - - // Verify this device owns the location (defense in depth) - // This prevents watching locations owned by other devices - let libraries = self.context.libraries().await; - if let Some(library) = libraries.get_library(location.library_id).await { - let db = library.db().conn(); - let current_device_uuid = crate::device::get_current_device_id(); - - // Query the location to check ownership - if let Ok(Some(location_record)) = crate::infra::db::entities::location::Entity::find() - .filter(crate::infra::db::entities::location::Column::Uuid.eq(location.id)) - .one(db) - .await - { - // Get the owning device - if let Ok(Some(owning_device)) = - crate::infra::db::entities::device::Entity::find_by_id( - location_record.device_id, - ) - .one(db) - .await - { - if owning_device.uuid != current_device_uuid { - warn!( - "Refusing to watch location {} owned by device {} (current device: {})", - location.id, owning_device.uuid, current_device_uuid - ); - return Err(anyhow::anyhow!( - "Cannot watch location {} - owned by different device", - location.id - )); - } - } - } - } - - // First, add to watched_locations map - { - let mut locations = self.watched_locations.write().await; - - if locations.contains_key(&location.id) { - warn!("Location {} is already being watched", location.id); - return Ok(()); - } - - locations.insert(location.id, location.clone()); - } // Drop write lock here to avoid deadlock when ensure_worker_for_location reads it - - // Create worker for this location (after dropping write lock to avoid deadlock) - if *self.is_running.read().await { - self.ensure_worker_for_location(location.id, location.library_id) - .await?; - } - - // Register database connection for this location (needed for rename detection) - let libraries = self.context.libraries().await; - if let Some(library) = libraries.get_library(location.library_id).await { - let db = library.db().conn().clone(); - self.platform_handler - .register_location_db(location.id, db) - .await; - debug!( - "Registered database connection for location {} (rename detection)", - location.id - ); - } - - // Add to file system watcher if running - if *self.is_running.read().await { - if let Some(watcher) = self.watcher.write().await.as_mut() { - watcher.watch(&location.path, RecursiveMode::Recursive)?; - info!("Started watching location: {}", location.path.display()); - } - } - - Ok(()) - } - - /// Remove a location from watching - pub async fn remove_location(&self, location_id: Uuid) -> Result<()> { - let mut locations = self.watched_locations.write().await; - - if let Some(location) = locations.remove(&location_id) { - // Remove worker for this location - self.remove_worker_for_location(location_id).await; - - // Unregister database connection for this location - self.platform_handler - .unregister_location_db(location_id) - .await; - debug!( - "Unregistered database connection for location {}", - location_id - ); - - // Remove from file system watcher if running - if *self.is_running.read().await { - if let Some(watcher) = self.watcher.write().await.as_mut() { - watcher.unwatch(&location.path)?; - info!("Stopped watching location: {}", location.path.display()); - } - } - } - - Ok(()) - } - - /// Update a location's settings - pub async fn update_location(&self, location_id: Uuid, enabled: bool) -> Result<()> { - let mut locations = self.watched_locations.write().await; - - if let Some(location) = locations.get_mut(&location_id) { - let was_enabled = location.enabled; - location.enabled = enabled; - - if *self.is_running.read().await { - if let Some(watcher) = self.watcher.write().await.as_mut() { - match (was_enabled, enabled) { - (false, true) => { - // Enable watching - watcher.watch(&location.path, RecursiveMode::Recursive)?; - info!("Enabled watching for location: {}", location.path.display()); - } - (true, false) => { - // Disable watching - watcher.unwatch(&location.path)?; - info!( - "Disabled watching for location: {}", - location.path.display() - ); - } - _ => {} // No change needed - } - } - } - } - - Ok(()) - } - - /// Get all watched locations - pub async fn get_watched_locations(&self) -> Vec { - self.watched_locations - .read() - .await - .values() - .cloned() - .collect() - } - - // ======================================================================== - // Ephemeral Watch Support (shallow, non-recursive) - // ======================================================================== - - /// Add an ephemeral watch for a directory (shallow, immediate children only). - /// - /// Unlike location watches which are recursive, ephemeral watches only monitor - /// immediate children of the watched directory. This is appropriate for ephemeral - /// browsing where only the current directory's contents are indexed. - /// - /// The path should already be indexed in the ephemeral cache before calling this. - pub async fn add_ephemeral_watch( - &self, - path: PathBuf, - rule_toggles: crate::ops::indexing::rules::RuleToggles, - ) -> Result<()> { - // Check if path is valid - if !path.exists() { - return Err(anyhow::anyhow!( - "Cannot watch non-existent path: {}", - path.display() - )); - } - - if !path.is_dir() { - return Err(anyhow::anyhow!( - "Cannot watch non-directory path: {}", - path.display() - )); - } - - // Check if already watching - { - let watches = self.ephemeral_watches.read().await; - if watches.contains_key(&path) { - debug!("Already watching ephemeral path: {}", path.display()); - return Ok(()); - } - } - - // Register in ephemeral cache - self.context - .ephemeral_cache() - .register_for_watching(path.clone()); - - // Add to our tracking - { - let mut watches = self.ephemeral_watches.write().await; - watches.insert( - path.clone(), - EphemeralWatch { - path: path.clone(), - rule_toggles, - }, - ); - // Update metrics - self.metrics.update_ephemeral_watches(watches.len()); - } - - // Add to file system watcher with NonRecursive mode - if *self.is_running.read().await { - if let Some(watcher) = self.watcher.write().await.as_mut() { - watcher.watch(&path, RecursiveMode::NonRecursive)?; - info!("Started shallow ephemeral watch for: {}", path.display()); - } - } - - Ok(()) - } - - /// Remove an ephemeral watch - pub async fn remove_ephemeral_watch(&self, path: &Path) -> Result<()> { - let watch = { - let mut watches = self.ephemeral_watches.write().await; - let watch = watches.remove(path); - // Update metrics - self.metrics.update_ephemeral_watches(watches.len()); - watch - }; - - if let Some(watch) = watch { - // Unregister from ephemeral cache - self.context - .ephemeral_cache() - .unregister_from_watching(&watch.path); - - // Remove from file system watcher - if *self.is_running.read().await { - if let Some(watcher) = self.watcher.write().await.as_mut() { - if let Err(e) = watcher.unwatch(&watch.path) { - warn!( - "Failed to unwatch ephemeral path {}: {}", - watch.path.display(), - e - ); - } else { - info!("Stopped ephemeral watch for: {}", watch.path.display()); - } - } - } - } - - Ok(()) - } - - /// Get all ephemeral watches - pub async fn get_ephemeral_watches(&self) -> Vec { - self.ephemeral_watches - .read() - .await - .values() - .cloned() - .collect() - } - - /// Check if a path has an ephemeral watch - pub async fn has_ephemeral_watch(&self, path: &Path) -> bool { - self.ephemeral_watches.read().await.contains_key(path) - } - - /// Find the ephemeral watch that covers a given path (if any). - /// - /// For shallow watches, only returns a match if the path is an immediate - /// child of a watched directory. - pub async fn find_ephemeral_watch_for_path(&self, path: &Path) -> Option { - let watches = self.ephemeral_watches.read().await; - - // Get the parent directory of the event path - let parent = path.parent()?; - - // Check if the parent is being watched - watches.get(parent).cloned() - } - - /// Load existing locations from the database and add them to the watcher - async fn load_existing_locations(&self) -> Result<()> { - info!("Loading existing locations from database..."); - - // Get all libraries from the context - let libraries = self.context.libraries().await; - let library_list = libraries.list().await; - - let mut total_locations = 0; - - for library in library_list { - // Query locations for this library - let db = library.db().conn(); - - // Get current device UUID (this device) - let current_device_uuid = crate::device::get_current_device_id(); - - // First, get the current device's database ID by UUID - let current_device = match crate::infra::db::entities::device::Entity::find() - .filter(crate::infra::db::entities::device::Column::Uuid.eq(current_device_uuid)) - .one(db) - .await - { - Ok(Some(device)) => device, - Ok(None) => { - warn!( - "Current device {} not found in library {} database, skipping location loading", - current_device_uuid, - library.id() - ); - continue; - } - Err(e) => { - warn!( - "Failed to query device {} in library {}: {}, skipping", - current_device_uuid, - library.id(), - e - ); - continue; - } - }; - - // Add timeout to the database query - // Only watch locations owned by THIS device - let locations_result = tokio::time::timeout( - std::time::Duration::from_secs(10), - crate::infra::db::entities::location::Entity::find() - .filter( - crate::infra::db::entities::location::Column::DeviceId - .eq(current_device.id), - ) - .all(db), - ) - .await; - - match locations_result { - Ok(Ok(locations)) => { - debug!( - "Found {} locations in library {}", - locations.len(), - library.id() - ); - - for location in locations { - // Skip locations without entry_id (not yet synced) - let Some(entry_id) = location.entry_id else { - debug!("Skipping location {} without entry_id", location.uuid); - continue; - }; - - // Skip locations with IndexMode::None (not persistently indexed) - if location.index_mode == "none" { - debug!( - "Skipping location {} with IndexMode::None (ephemeral browsing only)", - location.uuid - ); - continue; - } - - // Get the full path using PathResolver with timeout - let path_result = tokio::time::timeout( - std::time::Duration::from_secs(5), - crate::ops::indexing::path_resolver::PathResolver::get_full_path( - db, entry_id, - ), - ) - .await; - - match path_result { - Ok(Ok(path)) => { - // Skip cloud locations - they don't have filesystem paths to watch - // Cloud paths use service-native URIs like s3://, gdrive://, etc. - let path_str = path.to_string_lossy(); - if path_str.contains("://") && !path_str.starts_with("local://") { - debug!( - "Skipping cloud location {} from filesystem watcher: {}", - location.uuid, path_str - ); - continue; - } - - // Register database connection for this location first - let db = library.db().conn().clone(); - self.platform_handler - .register_location_db(location.uuid, db) - .await; - - // Convert database location to WatchedLocation - let watched_location = WatchedLocation { - id: location.uuid, - library_id: library.id(), - path: path.clone(), - enabled: true, // TODO: Add enabled field to database schema - rule_toggles: Default::default(), // Use default rules for existing locations - }; - - // Add to watched locations - if let Err(e) = self.add_location(watched_location).await { - warn!( - "Failed to add location {} to watcher: {}", - location.uuid, e - ); - } else { - total_locations += 1; - debug!( - "Added location {} to watcher: {} (with DB connection)", - location.uuid, - path.display() - ); - } - } - Ok(Err(e)) => { - warn!( - "Failed to get path for location {}: {}, skipping", - location.uuid, e - ); - } - Err(_) => { - warn!( - "Timeout getting path for location {}, skipping", - location.uuid - ); - } - } - } - } - Ok(Err(e)) => { - warn!( - "Database error loading locations for library {}: {}, continuing with other libraries", - library.id(), - e - ); - } - Err(_) => { - warn!( - "Timeout loading locations for library {}, continuing with other libraries", - library.id() - ); - } - } - } - - info!("Loaded {} locations from database", total_locations); - - // Update metrics with the total count - self.metrics.update_total_locations(total_locations); - - Ok(()) - } - - /// Start the event processing loop - async fn start_event_loop(&self) -> Result<()> { - let platform_handler = self.platform_handler.clone(); - let watched_locations = self.watched_locations.clone(); - let ephemeral_watches = self.ephemeral_watches.clone(); - let workers = self.workers.clone(); - let is_running = self.is_running.clone(); - let debug_mode = self.config.debug_mode; - let metrics = self.metrics.clone(); - let events = self.events.clone(); - let context = self.context.clone(); - - let (tx, mut rx) = mpsc::channel(self.config.event_buffer_size); - let tx_clone = tx.clone(); - - // Create file system watcher - let mut watcher = - notify::recommended_watcher(move |res: Result| { - match res { - Ok(event) => { - // Always log raw events for now to debug rename issues - debug!( - "Raw notify event: kind={:?}, paths={:?}", - event.kind, event.paths - ); - - // Record event received - metrics.record_event_received(); - - // Convert notify event to our WatcherEvent - let watcher_event = WatcherEvent::from_notify_event(event); - - // Send event directly to avoid runtime context issues - // Use try_send since we're in a sync context - match tx_clone.try_send(watcher_event) { - Ok(_) => { - debug!("Successfully sent event to channel"); - } - Err(e) => { - error!("Failed to send watcher event: {}", e); - // This could happen if the channel is full or receiver is dropped - } - } - } - Err(e) => { - error!("File system watcher error: {}", e); - } - } - })?; - - // Configure watcher - watcher.configure(Config::default().with_poll_interval(Duration::from_millis(500)))?; - - // Watch all enabled locations and create workers - let locations = watched_locations.read().await; - for location in locations.values() { - if location.enabled { - watcher.watch(&location.path, RecursiveMode::Recursive)?; - info!("Started watching location: {}", location.path.display()); - } - } - drop(locations); - - // Watch all ephemeral paths (non-recursive/shallow) - let ephemeral = ephemeral_watches.read().await; - for watch in ephemeral.values() { - watcher.watch(&watch.path, RecursiveMode::NonRecursive)?; - info!( - "Started shallow ephemeral watch for: {}", - watch.path.display() - ); - } - drop(ephemeral); - - // Store watcher - *self.watcher.write().await = Some(watcher); - - // Start event processing loop - tokio::spawn(async move { - info!("Location watcher event loop task spawned"); - - while *is_running.read().await { - tokio::select! { - Some(event) = rx.recv() => { - debug!("Received event from channel: {:?}", event.kind); - // Process the event through platform handler - match platform_handler.process_event(event, &watched_locations, &ephemeral_watches).await { - Ok(processed_events) => { - for processed_event in processed_events { - match processed_event { - Event::FsRawChange { library_id, kind } => { - // Emit the event to the event bus for subscribers - events.emit(Event::FsRawChange { - library_id, - kind: kind.clone(), - }); - - // Extract path from event for location matching - let event_path = match &kind { - FsRawEventKind::Create { path } => Some(path.as_path()), - FsRawEventKind::Modify { path } => Some(path.as_path()), - FsRawEventKind::Remove { path } => Some(path.as_path()), - FsRawEventKind::Rename { from, .. } => Some(from.as_path()), - }; - - // First, check if this is an ephemeral watch event - // For shallow watches, only process if path is immediate child - let mut handled_by_ephemeral = false; - if let Some(event_path) = event_path { - let parent = event_path.parent(); - if let Some(parent_path) = parent { - let ephemeral = ephemeral_watches.read().await; - if let Some(watch) = ephemeral.get(parent_path) { - debug!( - "Ephemeral watch match for {}: parent {} is watched", - event_path.display(), - parent_path.display() - ); - handled_by_ephemeral = true; - - // Process via ephemeral handler - let ctx = context.clone(); - let root = watch.path.clone(); - let toggles = watch.rule_toggles; - let event_kind = kind.clone(); - - tokio::spawn(async move { - if let Err(e) = crate::ops::indexing::ephemeral::responder::apply( - &ctx, - &root, - event_kind, - toggles, - ).await { - warn!("Failed to process ephemeral event: {}", e); - } - }); - } - } - } - - // Skip location matching if handled by ephemeral - if handled_by_ephemeral { - continue; - } - - // Find the location for this event by matching path prefix - // CRITICAL: Must match by path, not just library_id, to avoid routing - // events to the wrong location when multiple locations exist in one library - let locations = watched_locations.read().await; - let mut matched_location = None; - let mut longest_match_len = 0; - - if let Some(event_path) = event_path { - for location in locations.values() { - if location.library_id == library_id && location.enabled { - // Check if event path is under this location's root - if event_path.starts_with(&location.path) { - let match_len = location.path.as_os_str().len(); - // Use longest matching path to handle nested locations - if match_len > longest_match_len { - longest_match_len = match_len; - matched_location = Some(location.id); - } - } - } - } - } - - if let Some(location_id) = matched_location { - if let Some(worker_tx) = workers.read().await.get(&location_id) { - // Convert FsRawEventKind back to WatcherEvent for worker - let watcher_event = match kind { - FsRawEventKind::Create { path } => WatcherEvent { - kind: event_handler::WatcherEventKind::Create, - paths: vec![path], - timestamp: std::time::SystemTime::now(), - attrs: vec![], - }, - FsRawEventKind::Modify { path } => WatcherEvent { - kind: event_handler::WatcherEventKind::Modify, - paths: vec![path], - timestamp: std::time::SystemTime::now(), - attrs: vec![], - }, - FsRawEventKind::Remove { path } => WatcherEvent { - kind: event_handler::WatcherEventKind::Remove, - paths: vec![path], - timestamp: std::time::SystemTime::now(), - attrs: vec![], - }, - FsRawEventKind::Rename { from, to } => WatcherEvent { - kind: event_handler::WatcherEventKind::Rename { from, to }, - paths: vec![], - timestamp: std::time::SystemTime::now(), - attrs: vec![], - }, - }; - - debug!("Routing event to location {}: {:?}", location_id, watcher_event.kind); - if let Err(e) = worker_tx.send(watcher_event).await { - warn!("Failed to send event to worker for location {}: {}", location_id, e); - } else { - debug!("✓ Successfully sent event to worker for location {}", location_id); - } - } else { - warn!("No worker found for matched location {}", location_id); - } - } else { - warn!("No matching location found for event path: {:?}", event_path); - } - } - other => { - // Preserve emission of any other events - // Note: We need access to events bus here, but it's not available in this scope - // This will be handled by the workers when they emit final events - } - } - } - } - Err(e) => { - error!("Error processing watcher event: {}", e); - } - } - trace!("Finished processing event, continuing loop"); - } - _ = tokio::time::sleep(Duration::from_millis(100)) => { - // Periodic tick for debouncing and cleanup - if let Err(e) = platform_handler.tick().await { - error!("Error during platform handler tick: {}", e); - } - - // Handle platform-specific tick events that might generate additional events (e.g., rename matching) - #[cfg(target_os = "macos")] - { - if let Ok(tick_events) = platform_handler.inner.tick_with_locations(&watched_locations, &ephemeral_watches).await { - for tick_event in tick_events { - match tick_event { - Event::FsRawChange { library_id, kind } => { - // Emit the event to the event bus for subscribers - events.emit(Event::FsRawChange { - library_id, - kind: kind.clone(), - }); - - // Extract path from event for location matching - let event_path = match &kind { - FsRawEventKind::Create { path } => Some(path.as_path()), - FsRawEventKind::Modify { path } => Some(path.as_path()), - FsRawEventKind::Remove { path } => Some(path.as_path()), - FsRawEventKind::Rename { from, .. } => Some(from.as_path()), - }; - - // Find the location for this event by matching path prefix - let locations = watched_locations.read().await; - let mut matched_location = None; - let mut longest_match_len = 0; - - if let Some(event_path) = event_path { - for location in locations.values() { - if location.library_id == library_id && location.enabled { - if event_path.starts_with(&location.path) { - let match_len = location.path.as_os_str().len(); - if match_len > longest_match_len { - longest_match_len = match_len; - matched_location = Some(location.id); - } - } - } - } - } - - if let Some(location_id) = matched_location { - if let Some(worker_tx) = workers.read().await.get(&location_id) { - let watcher_event = match kind { - FsRawEventKind::Create { path } => WatcherEvent { - kind: event_handler::WatcherEventKind::Create, - paths: vec![path], - timestamp: std::time::SystemTime::now(), - attrs: vec![], - }, - FsRawEventKind::Modify { path } => WatcherEvent { - kind: event_handler::WatcherEventKind::Modify, - paths: vec![path], - timestamp: std::time::SystemTime::now(), - attrs: vec![], - }, - FsRawEventKind::Remove { path } => WatcherEvent { - kind: event_handler::WatcherEventKind::Remove, - paths: vec![path], - timestamp: std::time::SystemTime::now(), - attrs: vec![], - }, - FsRawEventKind::Rename { from, to } => WatcherEvent { - kind: event_handler::WatcherEventKind::Rename { from, to }, - paths: vec![], - timestamp: std::time::SystemTime::now(), - attrs: vec![], - }, - }; - - if let Err(e) = worker_tx.send(watcher_event).await { - warn!("Failed to send tick event to worker for location {}: {}", location_id, e); - } - } - } - } - _ => { - // Other event types, if any - } - } - } - } - } - - #[cfg(target_os = "windows")] - { - if let Ok(tick_events) = platform_handler.inner.tick_with_locations(&watched_locations).await { - for tick_event in tick_events { - // Similar handling for Windows if needed - match tick_event { - Event::FsRawChange { library_id, kind } => { - events.emit(Event::FsRawChange { - library_id, - kind: kind.clone(), - }); - } - _ => {} - } - } - } - } - } - } - } - - info!("Location watcher event loop stopped"); - }); - - Ok(()) - } - - /// Start listening for LocationAdded events to dynamically add new locations - async fn start_location_event_listener(&self) { - let mut event_subscriber = self.events.subscribe(); - let watched_locations = self.watched_locations.clone(); - let watcher_ref = self.watcher.clone(); - let workers = self.workers.clone(); - let is_running = self.is_running.clone(); - let context = self.context.clone(); - let events = self.events.clone(); - let config = self.config.clone(); - let worker_metrics = self.worker_metrics.clone(); - let metrics = self.metrics.clone(); - let metrics_collector = self.metrics_collector.clone(); - let platform_handler = self.platform_handler.clone(); - - tokio::spawn(async move { - info!("Location event listener started"); - - while *is_running.read().await { - match event_subscriber.recv().await { - Ok(Event::LocationAdded { - library_id, - location_id, - path, - }) => { - info!( - "Location added event received: {} at {}", - location_id, - path.display() - ); - - // Query the location to check its index_mode - let libraries = context.libraries().await; - let should_watch = if let Some(library) = libraries.get_library(library_id).await { - let db = library.db().conn(); - match crate::infra::db::entities::location::Entity::find() - .filter(crate::infra::db::entities::location::Column::Uuid.eq(location_id)) - .one(db) - .await - { - Ok(Some(location_record)) => { - if location_record.index_mode == "none" { - debug!( - "Skipping newly added location {} with IndexMode::None", - location_id - ); - false - } else { - true - } - } - Ok(None) => { - warn!("Location {} not found in database", location_id); - false - } - Err(e) => { - error!("Failed to query location {}: {}", location_id, e); - false - } - } - } else { - warn!("Library {} not found for location {}", library_id, location_id); - false - }; - - if !should_watch { - continue; - } - - // Create a temporary LocationWatcher instance for this operation - let temp_watcher = LocationWatcher { - config: config.clone(), - events: events.clone(), - context: context.clone(), - watched_locations: watched_locations.clone(), - ephemeral_watches: Arc::new(RwLock::new(HashMap::new())), - watcher: watcher_ref.clone(), - is_running: is_running.clone(), - platform_handler: platform_handler.clone(), - workers: workers.clone(), - metrics: metrics.clone(), - worker_metrics: worker_metrics.clone(), - metrics_collector: metrics_collector.clone(), - }; - - // Create WatchedLocation and add to watcher - let watched_location = WatchedLocation { - id: location_id, - library_id, - path: path.clone(), - enabled: true, - rule_toggles: Default::default(), // Use default rules for new locations - }; - - // Add location to watcher - if let Err(e) = temp_watcher.add_location(watched_location).await { - error!("Failed to add location {} to watcher: {}", location_id, e); - } else { - info!( - "Successfully added location {} to watcher: {}", - location_id, - path.display() - ); - } - } - Ok(Event::LocationRemoved { location_id, .. }) => { - info!("Location removed event received: {}", location_id); - - // Create a temporary LocationWatcher instance for this operation - let temp_watcher = LocationWatcher { - config: config.clone(), - events: events.clone(), - context: context.clone(), - watched_locations: watched_locations.clone(), - ephemeral_watches: Arc::new(RwLock::new(HashMap::new())), - watcher: watcher_ref.clone(), - is_running: is_running.clone(), - platform_handler: platform_handler.clone(), - workers: workers.clone(), - metrics: metrics.clone(), - worker_metrics: worker_metrics.clone(), - metrics_collector: metrics_collector.clone(), - }; - - // Remove location from watcher - if let Err(e) = temp_watcher.remove_location(location_id).await { - error!( - "Failed to remove location {} from watcher: {}", - location_id, e - ); - } else { - info!("Successfully removed location {} from watcher", location_id); - } - } - Ok(_) => { - // Ignore other events - } - Err(e) => { - // error!("Location event listener error: {}", e); - // Continue listening despite errors - } - } - } - - info!("Location event listener stopped"); - }); - } -} - -#[async_trait::async_trait] -impl Service for LocationWatcher { - async fn start(&self) -> Result<()> { - if *self.is_running.read().await { - warn!("Location watcher is already running"); - return Ok(()); - } - - info!("Starting location watcher service"); - - *self.is_running.write().await = true; - - // Load existing locations from database - if let Err(e) = self.load_existing_locations().await { - error!("Failed to load existing locations: {}", e); - // Continue starting the service even if loading locations fails - } - - // Start listening for LocationAdded events - self.start_location_event_listener().await; - - // Start metrics collector - self.start_metrics_collector().await?; - - self.start_event_loop().await?; - - info!("Location watcher service started"); - Ok(()) - } - - async fn stop(&self) -> Result<()> { - if !*self.is_running.read().await { - return Ok(()); - } - - info!("Stopping location watcher service"); - - *self.is_running.write().await = false; - - // Clean up watcher - *self.watcher.write().await = None; - - // Clean up all workers (dropping the senders will close the channels and stop the workers) - let worker_count = { - let mut workers = self.workers.write().await; - let count = workers.len(); - workers.clear(); - count - }; - - info!("Stopped {} location workers", worker_count); - - // Clean up worker metrics - { - let mut metrics_map = self.worker_metrics.write().await; - metrics_map.clear(); - } - - // Clean up metrics collector - { - *self.metrics_collector.write().await = None; - } - - info!("Location watcher service stopped"); - Ok(()) - } - - fn is_running(&self) -> bool { - // Use try_read to avoid blocking - self.is_running.try_read().map_or(false, |guard| *guard) - } - - fn name(&self) -> &'static str { - "location_watcher" - } -} - -#[cfg(test)] -mod tests { - use crate::ops::indexing::RuleToggles; - - use super::*; - use tempfile::TempDir; - - fn create_test_events() -> Arc { - Arc::new(EventBus::default()) - } - - fn create_mock_context() -> Arc { - // This would need to be implemented based on your CoreContext structure - // For now, we'll use a placeholder - todo!("Implement mock CoreContext for tests") - } - - #[tokio::test] - async fn test_location_watcher_creation() { - let config = LocationWatcherConfig::default(); - let events = create_test_events(); - let context = create_mock_context(); - let watcher = LocationWatcher::new(config, events, context); - - assert!(!watcher.is_running()); - assert_eq!(watcher.name(), "location_watcher"); - } - - #[tokio::test] - async fn test_add_remove_location() { - let config = LocationWatcherConfig::default(); - let events = create_test_events(); - let context = create_mock_context(); - let watcher = LocationWatcher::new(config, events, context); - - let temp_dir = TempDir::new().unwrap(); - let location = WatchedLocation { - id: Uuid::new_v4(), - library_id: Uuid::new_v4(), - path: temp_dir.path().to_path_buf(), - enabled: true, - rule_toggles: RuleToggles::default(), - }; - - let location_id = location.id; - - // Add location - watcher.add_location(location).await.unwrap(); - - let locations = watcher.get_watched_locations().await; - assert_eq!(locations.len(), 1); - assert_eq!(locations[0].id, location_id); - - // Remove location - watcher.remove_location(location_id).await.unwrap(); - - let locations = watcher.get_watched_locations().await; - assert_eq!(locations.len(), 0); - } -} +//! Filesystem Watcher Service +//! +//! Wraps `sd-fs-watcher` for platform-agnostic filesystem event detection. +//! +//! ## Architecture +//! +//! - **FsWatcherService**: Detects filesystem changes, emits events via broadcast channel +//! - **Handlers** (in `ops/indexing/handlers/`): Subscribe to events and route them +//! +//! The old monolithic `LocationWatcher` is preserved in `watcher_old/` for reference. + +mod service; + +pub use crate::ops::indexing::handlers::LocationMeta; +pub use service::{FsWatcherService, FsWatcherServiceConfig}; diff --git a/core/src/service/watcher/service.rs b/core/src/service/watcher/service.rs new file mode 100644 index 000000000..091c3c758 --- /dev/null +++ b/core/src/service/watcher/service.rs @@ -0,0 +1,399 @@ +//! FsWatcher Service - wraps the sd-fs-watcher crate for use in Spacedrive +//! +//! This service manages the lifecycle of the filesystem watcher and provides +//! the event stream that handlers subscribe to. It owns and starts the +//! `EphemeralEventHandler` and `PersistentEventHandler`. + +use crate::context::CoreContext; +use crate::library::Library; +use crate::ops::indexing::handlers::{EphemeralEventHandler, LocationMeta, PersistentEventHandler}; +use crate::ops::indexing::rules::RuleToggles; +use crate::service::Service; +use anyhow::Result; +use sd_fs_watcher::{FsEvent, FsWatcher, WatchConfig, WatcherConfig}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::broadcast; +use tracing::{debug, info, warn}; + +/// Configuration for the FsWatcher service +#[derive(Debug, Clone)] +pub struct FsWatcherServiceConfig { + /// Size of the internal event buffer + pub event_buffer_size: usize, + /// Tick interval for platform-specific event eviction + pub tick_interval: Duration, + /// Enable debug logging + pub debug_mode: bool, +} + +impl Default for FsWatcherServiceConfig { + fn default() -> Self { + Self { + event_buffer_size: 100_000, + tick_interval: Duration::from_millis(100), + debug_mode: false, + } + } +} + +impl From for WatcherConfig { + fn from(config: FsWatcherServiceConfig) -> Self { + WatcherConfig::default() + .with_buffer_size(config.event_buffer_size) + .with_tick_interval(config.tick_interval) + .with_debug(config.debug_mode) + } +} + +/// Filesystem watcher service that wraps sd-fs-watcher +/// +/// This service: +/// - Manages the lifecycle of the underlying FsWatcher +/// - Owns and starts the event handlers (PersistentEventHandler, EphemeralEventHandler) +/// - Handles watch registration for paths +/// +/// ## Usage +/// +/// ```ignore +/// let config = FsWatcherServiceConfig::default(); +/// let service = FsWatcherService::new(context, config); +/// +/// // Start the service (also starts handlers) +/// service.start().await?; +/// +/// // Watch a location (persistent, recursive) +/// service.watch_location(LocationMeta { ... }).await?; +/// +/// // Watch an ephemeral path (shallow, in-memory) +/// service.watch_ephemeral("/path/to/browse").await?; +/// ``` +pub struct FsWatcherService { + /// Core context for ephemeral cache access + context: Arc, + /// The underlying filesystem watcher + watcher: FsWatcher, + /// Handler for persistent (database) events + persistent_handler: PersistentEventHandler, + /// Handler for ephemeral (in-memory) events + ephemeral_handler: EphemeralEventHandler, + /// Whether the service is running + is_running: AtomicBool, + /// Configuration + config: FsWatcherServiceConfig, +} + +impl FsWatcherService { + /// Create a new FsWatcher service + /// + /// Note: Handlers are created but not yet connected. Call `init_handlers()` + /// after wrapping in Arc to connect them to the watcher. + pub fn new(context: Arc, config: FsWatcherServiceConfig) -> Self { + let watcher_config: WatcherConfig = config.clone().into(); + let watcher = FsWatcher::new(watcher_config); + + Self { + context: context.clone(), + watcher, + persistent_handler: PersistentEventHandler::new_unconnected(context.clone()), + ephemeral_handler: EphemeralEventHandler::new_unconnected(context), + is_running: AtomicBool::new(false), + config, + } + } + + /// Initialize handlers with a reference to self (wrapped in Arc) + /// + /// Must be called after the service is wrapped in Arc. + pub fn init_handlers(self: &Arc) { + self.persistent_handler.connect(self.clone()); + self.ephemeral_handler.connect(self.clone()); + } + + /// Subscribe to filesystem events + /// + /// Returns a broadcast receiver that will receive all filesystem events. + /// Multiple subscribers can exist simultaneously. + pub fn subscribe(&self) -> broadcast::Receiver { + self.watcher.subscribe() + } + + /// Watch a path with the given configuration + /// + /// For persistent locations, use `WatchConfig::recursive()`. + /// For ephemeral browsing, use `WatchConfig::shallow()`. + pub async fn watch_path(&self, path: impl Into, config: WatchConfig) -> Result<()> { + let path = path.into(); + debug!("Watching path: {}", path.display()); + self.watcher.watch_path(&path, config).await?; + Ok(()) + } + + /// Stop watching a path + pub async fn unwatch_path(&self, path: impl AsRef) -> Result<()> { + let path = path.as_ref(); + debug!("Unwatching path: {}", path.display()); + self.watcher.unwatch(path).await?; + Ok(()) + } + + /// Get all currently watched paths + pub async fn watched_paths(&self) -> Vec { + self.watcher.watched_paths().await + } + + /// Get the number of events received from the OS + pub fn events_received(&self) -> u64 { + self.watcher.events_received() + } + + /// Get the number of events emitted to subscribers + pub fn events_emitted(&self) -> u64 { + self.watcher.events_emitted() + } + + /// Get a reference to the underlying watcher + /// + /// Use this for advanced operations or when you need direct access + /// to the watcher's capabilities. + pub fn inner(&self) -> &FsWatcher { + &self.watcher + } + + /// Watch a location (persistent, recursive) + /// + /// The location will be watched recursively and events will be + /// batched and persisted to the database. + pub async fn watch_location(&self, meta: LocationMeta) -> Result<()> { + info!( + "Watching location {} at {}", + meta.id, + meta.root_path.display() + ); + self.persistent_handler.add_location(meta).await + } + + /// Stop watching a location + pub async fn unwatch_location(&self, location_id: uuid::Uuid) -> Result<()> { + info!("Unwatching location {}", location_id); + self.persistent_handler.remove_location(location_id).await + } + + /// Get all watched locations + pub async fn watched_locations(&self) -> Vec { + self.persistent_handler.locations().await + } + + /// Load and watch all eligible locations from a library + /// + /// Only watches locations that: + /// - Are on this device + /// - Have IndexMode != None + pub async fn load_library_locations(&self, library: &Library) -> Result { + use crate::infra::db::entities::{device, location}; + use crate::ops::indexing::path_resolver::PathResolver; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + let db = library.db().conn(); + let mut count = 0; + + // Get current device UUID and find in this library's database + let current_device_uuid = crate::device::get_current_device_id(); + let current_device = device::Entity::find() + .filter(device::Column::Uuid.eq(current_device_uuid)) + .one(db) + .await?; + + let Some(current_device) = current_device else { + warn!( + "Current device {} not found in library {} database", + current_device_uuid, + library.id() + ); + return Ok(0); + }; + + // Query locations owned by this device + let locations = location::Entity::find() + .filter(location::Column::DeviceId.eq(current_device.id)) + .all(db) + .await?; + + debug!( + "Found {} locations in library {} for this device", + locations.len(), + library.id() + ); + + for loc in locations { + // Skip locations without entry_id (not yet indexed) + let Some(entry_id) = loc.entry_id else { + debug!("Skipping location {} - no entry_id", loc.uuid); + continue; + }; + + // Skip IndexMode::None + if loc.index_mode == "none" { + debug!("Skipping location {} - IndexMode::None", loc.uuid); + continue; + } + + // Get the full filesystem path + let path = match PathResolver::get_full_path(db, entry_id).await { + Ok(path) => path, + Err(e) => { + warn!("Failed to resolve path for location {}: {}", loc.uuid, e); + continue; + } + }; + + // Skip cloud locations + let path_str = path.to_string_lossy(); + if path_str.contains("://") && !path_str.starts_with("local://") { + debug!("Skipping cloud location {}: {}", loc.uuid, path_str); + continue; + } + + // Check if path exists + if !path.exists() { + warn!( + "Location {} path does not exist: {}", + loc.uuid, + path.display() + ); + continue; + } + + let meta = LocationMeta { + id: loc.uuid, + library_id: library.id(), + root_path: path, + rule_toggles: RuleToggles::default(), + }; + + if let Err(e) = self.watch_location(meta).await { + warn!("Failed to watch location {}: {}", loc.uuid, e); + } else { + count += 1; + } + } + + info!("Loaded {} locations from library {}", count, library.id()); + Ok(count) + } + + /// Watch an ephemeral path (shallow, in-memory only) + /// + /// Used for browsing external drives, network shares, etc. + /// Registers with ephemeral cache and starts OS-level watching. + pub async fn watch_ephemeral(&self, path: impl Into) -> Result<()> { + let path = path.into(); + debug!("Watching ephemeral path: {}", path.display()); + + // Register with ephemeral cache so handler knows to process events + self.context + .ephemeral_cache() + .register_for_watching(path.clone()); + + // Start OS-level watching (shallow = immediate children only) + self.watcher + .watch_path(&path, WatchConfig::shallow()) + .await?; + + Ok(()) + } + + /// Stop watching an ephemeral path + pub async fn unwatch_ephemeral(&self, path: &Path) -> Result<()> { + debug!("Unwatching ephemeral path: {}", path.display()); + + // Unregister from ephemeral cache + self.context + .ephemeral_cache() + .unregister_from_watching(path); + + // Stop OS-level watching + self.watcher.unwatch(path).await?; + + Ok(()) + } + + // ==================== Handler Access ==================== + + /// Get reference to persistent handler + pub fn persistent_handler(&self) -> &PersistentEventHandler { + &self.persistent_handler + } + + /// Get reference to ephemeral handler + pub fn ephemeral_handler(&self) -> &EphemeralEventHandler { + &self.ephemeral_handler + } +} + +#[async_trait::async_trait] +impl Service for FsWatcherService { + async fn start(&self) -> Result<()> { + if self.is_running.swap(true, Ordering::SeqCst) { + warn!("FsWatcher service is already running"); + return Ok(()); + } + + info!("Starting FsWatcher service"); + + // Start the underlying watcher first + self.watcher.start().await?; + + // Start the event handlers + self.persistent_handler.start().await?; + self.ephemeral_handler.start().await?; + + info!("FsWatcher service started (with handlers)"); + + Ok(()) + } + + async fn stop(&self) -> Result<()> { + if !self.is_running.swap(false, Ordering::SeqCst) { + return Ok(()); + } + + info!("Stopping FsWatcher service"); + + // Stop handlers first + self.persistent_handler.stop().await; + self.ephemeral_handler.stop(); + + // Then stop the watcher + self.watcher.stop().await?; + + info!("FsWatcher service stopped"); + + Ok(()) + } + + fn is_running(&self) -> bool { + self.is_running.load(Ordering::SeqCst) + } + + fn name(&self) -> &'static str { + "fs_watcher" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_default() { + let config = FsWatcherServiceConfig::default(); + assert_eq!(config.event_buffer_size, 100_000); + assert!(!config.debug_mode); + } + + // Note: Full service tests require CoreContext which needs async runtime + // See integration tests for complete service lifecycle testing +} diff --git a/core/src/service/watcher/event_handler.rs b/core/src/service/watcher_old/event_handler.rs similarity index 100% rename from core/src/service/watcher/event_handler.rs rename to core/src/service/watcher_old/event_handler.rs diff --git a/core/src/service/watcher/example.rs b/core/src/service/watcher_old/example.rs similarity index 100% rename from core/src/service/watcher/example.rs rename to core/src/service/watcher_old/example.rs diff --git a/core/src/service/watcher/metrics.rs b/core/src/service/watcher_old/metrics.rs similarity index 100% rename from core/src/service/watcher/metrics.rs rename to core/src/service/watcher_old/metrics.rs diff --git a/core/src/service/watcher_old/mod.rs b/core/src/service/watcher_old/mod.rs new file mode 100644 index 000000000..b58ac5727 --- /dev/null +++ b/core/src/service/watcher_old/mod.rs @@ -0,0 +1,1503 @@ +//! Location Watcher Service - Monitors file system changes in indexed locations + +use crate::context::CoreContext; +use crate::infra::event::{Event, EventBus, FsRawEventKind}; +use crate::service::Service; +use anyhow::Result; +use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, RwLock}; +use tracing::{debug, error, info, trace, warn}; +use uuid::Uuid; + +mod event_handler; +mod metrics; +mod platform; +pub mod utils; +mod worker; + +#[cfg(feature = "examples")] +pub mod example; + +pub use metrics::{LocationWorkerMetrics, MetricsCollector, WatcherMetrics}; +pub use worker::LocationWorker; + +pub use event_handler::WatcherEvent; +pub use platform::PlatformHandler; + +/// Configuration for the location watcher +#[derive(Debug, Clone)] +pub struct LocationWatcherConfig { + /// Debounce duration for file system events + pub debounce_duration: Duration, + /// Maximum number of events to buffer per location (never drops events) + pub event_buffer_size: usize, + /// Whether to enable detailed debug logging + pub debug_mode: bool, + /// Debounce window for batching events (100-250ms) + pub debounce_window_ms: u64, + /// Maximum batch size for processing efficiency + pub max_batch_size: usize, + /// Metrics logging interval + pub metrics_log_interval_ms: u64, + /// Whether to enable metrics collection + pub enable_metrics: bool, + /// Maximum queue depth before triggering re-index + pub max_queue_depth_before_reindex: usize, + /// Whether to enable focused re-indexing on overflow + pub enable_focused_reindex: bool, +} + +impl Default for LocationWatcherConfig { + fn default() -> Self { + Self { + debounce_duration: Duration::from_millis(100), + event_buffer_size: 100000, // Large buffer to never drop events + debug_mode: false, + debounce_window_ms: 150, // 150ms default debounce window + max_batch_size: 10000, // Large batches for efficiency + metrics_log_interval_ms: 30000, // 30 seconds + enable_metrics: true, + max_queue_depth_before_reindex: 50000, // 50% of buffer size + enable_focused_reindex: true, + } + } +} + +impl LocationWatcherConfig { + /// Create a new configuration with custom values + pub fn new(debounce_window_ms: u64, event_buffer_size: usize, max_batch_size: usize) -> Self { + Self { + debounce_duration: Duration::from_millis(100), + event_buffer_size, + debug_mode: false, + debounce_window_ms, + max_batch_size, + metrics_log_interval_ms: 30000, + enable_metrics: true, + max_queue_depth_before_reindex: event_buffer_size / 2, + enable_focused_reindex: true, + } + } + + /// Create a configuration optimized for resource-constrained environments + /// This is for future resource manager integration + pub fn resource_optimized(memory_quota: usize, cpu_quota: usize) -> Self { + // Calculate buffer size based on available memory (1KB per event estimate) + let event_buffer_size = std::cmp::max(10000, memory_quota / 1000); + + // Calculate batch size based on CPU quota (100 events per CPU unit) + let max_batch_size = std::cmp::max(1000, cpu_quota / 100); + + Self { + debounce_duration: Duration::from_millis(100), + event_buffer_size, + debug_mode: false, + debounce_window_ms: 150, + max_batch_size, + metrics_log_interval_ms: 30000, + enable_metrics: true, + max_queue_depth_before_reindex: event_buffer_size / 2, + enable_focused_reindex: true, + } + } + + /// Validate the configuration + pub fn validate(&self) -> Result<()> { + if self.debounce_window_ms < 50 { + return Err(anyhow::anyhow!("Debounce window must be at least 50ms")); + } + if self.debounce_window_ms > 1000 { + return Err(anyhow::anyhow!("Debounce window must be at most 1000ms")); + } + if self.event_buffer_size < 100 { + return Err(anyhow::anyhow!("Event buffer size must be at least 100")); + } + if self.max_batch_size < 1 { + return Err(anyhow::anyhow!("Max batch size must be at least 1")); + } + if self.max_batch_size > self.event_buffer_size { + return Err(anyhow::anyhow!( + "Max batch size cannot exceed event buffer size" + )); + } + Ok(()) + } +} + +/// Location watcher service that monitors file system changes +pub struct LocationWatcher { + /// Watcher configuration + config: LocationWatcherConfig, + /// Event bus for emitting events + events: Arc, + /// Core context for DB and library access + context: Arc, + /// Currently watched locations + watched_locations: Arc>>, + /// Ephemeral watches (shallow, non-recursive) keyed by path + ephemeral_watches: Arc>>, + /// File system watcher + watcher: Arc>>, + /// Whether the service is running + is_running: Arc>, + /// Platform-specific event handler + platform_handler: Arc, + /// Per-location workers + workers: Arc>>>, + /// Global watcher metrics + metrics: Arc, + /// Worker metrics by location + worker_metrics: Arc>>>, + /// Metrics collector for periodic logging + metrics_collector: Arc>>>, +} + +/// Information about a watched location +#[derive(Debug, Clone)] +pub struct WatchedLocation { + /// Location UUID + pub id: Uuid, + /// Library UUID this location belongs to + pub library_id: Uuid, + /// Path being watched + pub path: PathBuf, + /// Whether watching is enabled for this location + pub enabled: bool, + /// Indexing rule toggles for filtering events + pub rule_toggles: crate::ops::indexing::rules::RuleToggles, +} + +/// Information about an ephemeral watch (shallow, non-recursive) +#[derive(Debug, Clone)] +pub struct EphemeralWatch { + /// Path being watched + pub path: PathBuf, + /// Indexing rule toggles for filtering events + pub rule_toggles: crate::ops::indexing::rules::RuleToggles, +} + +impl LocationWatcher { + /// Create a new location watcher + pub fn new( + config: LocationWatcherConfig, + events: Arc, + context: Arc, + ) -> Self { + let platform_handler = Arc::new(PlatformHandler::new()); + + Self { + config, + events, + context, + watched_locations: Arc::new(RwLock::new(HashMap::new())), + ephemeral_watches: Arc::new(RwLock::new(HashMap::new())), + watcher: Arc::new(RwLock::new(None)), + is_running: Arc::new(RwLock::new(false)), + platform_handler, + workers: Arc::new(RwLock::new(HashMap::new())), + metrics: Arc::new(WatcherMetrics::new()), + worker_metrics: Arc::new(RwLock::new(HashMap::new())), + metrics_collector: Arc::new(RwLock::new(None)), + } + } + + /// Ensure a worker exists for the given location + async fn ensure_worker_for_location( + &self, + location_id: Uuid, + library_id: Uuid, + ) -> Result> { + // Check if worker already exists + { + let workers = self.workers.read().await; + if let Some(sender) = workers.get(&location_id) { + debug!( + "Worker already exists for location {}, reusing", + location_id + ); + return Ok(sender.clone()); + } + } + + info!("Creating new worker for location {}", location_id); + + // Get rule toggles and location root from watched locations + let (rule_toggles, location_root) = { + let locations = self.watched_locations.read().await; + locations + .get(&location_id) + .map(|loc| (loc.rule_toggles, loc.path.clone())) + .ok_or_else(|| { + anyhow::anyhow!("Location {} not found in watched locations", location_id) + })? + }; + + // Create metrics for this worker + let worker_metrics = Arc::new(LocationWorkerMetrics::new()); + { + let mut metrics_map = self.worker_metrics.write().await; + metrics_map.insert(location_id, worker_metrics.clone()); + } + + // Create new worker + let (tx, rx) = mpsc::channel(self.config.event_buffer_size); + let worker = LocationWorker::new( + location_id, + library_id, + rx, + self.context.clone(), + self.events.clone(), + self.config.clone(), + worker_metrics.clone(), + rule_toggles, + location_root, + ); + + // Record worker creation + self.metrics.record_worker_created(); + + // Register worker metrics with collector + if let Some(collector) = self.metrics_collector.read().await.as_ref() { + collector.add_worker_metrics(location_id, worker_metrics.clone()); + } + + // Spawn the worker task + tokio::spawn(async move { + if let Err(e) = worker.run().await { + error!("Location worker {} failed: {}", location_id, e); + } + }); + + // Store the sender + { + let mut workers = self.workers.write().await; + workers.insert(location_id, tx.clone()); + } + + Ok(tx) + } + + /// Remove a worker for a location + async fn remove_worker_for_location(&self, location_id: Uuid) { + let mut workers = self.workers.write().await; + workers.remove(&location_id); + + // Remove metrics + let mut metrics_map = self.worker_metrics.write().await; + metrics_map.remove(&location_id); + + // Unregister from metrics collector + if let Some(collector) = self.metrics_collector.read().await.as_ref() { + collector.remove_worker_metrics(&location_id); + } + + // Record worker destruction + self.metrics.record_worker_destroyed(); + } + + /// Get metrics for a specific location + pub async fn get_location_metrics( + &self, + location_id: Uuid, + ) -> Option> { + let metrics_map = self.worker_metrics.read().await; + metrics_map.get(&location_id).cloned() + } + + /// Get global watcher metrics + pub fn get_global_metrics(&self) -> Arc { + self.metrics.clone() + } + + /// Manually trigger metrics logging (useful for testing) + pub async fn log_metrics_now(&self) { + // Log global metrics + self.metrics.log_metrics(); + + // Log worker metrics + let worker_metrics = self.worker_metrics.read().await; + for (location_id, metrics) in worker_metrics.iter() { + metrics.log_metrics(*location_id); + } + } + + /// Start the metrics collector for periodic logging + async fn start_metrics_collector(&self) -> Result<()> { + if !self.config.enable_metrics { + return Ok(()); + } + + let log_interval = Duration::from_millis(self.config.metrics_log_interval_ms); + let metrics_collector = Arc::new(MetricsCollector::new(self.metrics.clone(), log_interval)); + + // Store reference for worker registration first + *self.metrics_collector.write().await = Some(metrics_collector.clone()); + + // Start the metrics collection task + tokio::spawn(async move { + metrics_collector.start_collection().await; + }); + + info!( + "Metrics collector started with {}ms interval", + self.config.metrics_log_interval_ms + ); + Ok(()) + } + + /// Add a location to watch + pub async fn add_location(&self, location: WatchedLocation) -> Result<()> { + if !location.enabled { + debug!( + "Location {} is disabled, not adding to watcher", + location.id + ); + return Ok(()); + } + + // Skip cloud locations - they don't have filesystem paths to watch + // Cloud paths use service-native URIs like s3://, gdrive://, etc. + let path_str = location.path.to_string_lossy(); + if path_str.contains("://") && !path_str.starts_with("local://") { + debug!( + "Skipping cloud location {} from filesystem watcher: {}", + location.id, path_str + ); + return Ok(()); + } + + // Verify this device owns the location (defense in depth) + // This prevents watching locations owned by other devices + let libraries = self.context.libraries().await; + if let Some(library) = libraries.get_library(location.library_id).await { + let db = library.db().conn(); + let current_device_uuid = crate::device::get_current_device_id(); + + // Query the location to check ownership + if let Ok(Some(location_record)) = crate::infra::db::entities::location::Entity::find() + .filter(crate::infra::db::entities::location::Column::Uuid.eq(location.id)) + .one(db) + .await + { + // Get the owning device + if let Ok(Some(owning_device)) = + crate::infra::db::entities::device::Entity::find_by_id( + location_record.device_id, + ) + .one(db) + .await + { + if owning_device.uuid != current_device_uuid { + warn!( + "Refusing to watch location {} owned by device {} (current device: {})", + location.id, owning_device.uuid, current_device_uuid + ); + return Err(anyhow::anyhow!( + "Cannot watch location {} - owned by different device", + location.id + )); + } + } + } + } + + // First, add to watched_locations map + { + let mut locations = self.watched_locations.write().await; + + if locations.contains_key(&location.id) { + warn!("Location {} is already being watched", location.id); + return Ok(()); + } + + locations.insert(location.id, location.clone()); + } // Drop write lock here to avoid deadlock when ensure_worker_for_location reads it + + // Create worker for this location (after dropping write lock to avoid deadlock) + if *self.is_running.read().await { + self.ensure_worker_for_location(location.id, location.library_id) + .await?; + } + + // Register database connection for this location (needed for rename detection) + let libraries = self.context.libraries().await; + if let Some(library) = libraries.get_library(location.library_id).await { + let db = library.db().conn().clone(); + self.platform_handler + .register_location_db(location.id, db) + .await; + debug!( + "Registered database connection for location {} (rename detection)", + location.id + ); + } + + // Add to file system watcher if running + if *self.is_running.read().await { + if let Some(watcher) = self.watcher.write().await.as_mut() { + watcher.watch(&location.path, RecursiveMode::Recursive)?; + info!("Started watching location: {}", location.path.display()); + } + } + + Ok(()) + } + + /// Remove a location from watching + pub async fn remove_location(&self, location_id: Uuid) -> Result<()> { + let mut locations = self.watched_locations.write().await; + + if let Some(location) = locations.remove(&location_id) { + // Remove worker for this location + self.remove_worker_for_location(location_id).await; + + // Unregister database connection for this location + self.platform_handler + .unregister_location_db(location_id) + .await; + debug!( + "Unregistered database connection for location {}", + location_id + ); + + // Remove from file system watcher if running + if *self.is_running.read().await { + if let Some(watcher) = self.watcher.write().await.as_mut() { + watcher.unwatch(&location.path)?; + info!("Stopped watching location: {}", location.path.display()); + } + } + } + + Ok(()) + } + + /// Update a location's settings + pub async fn update_location(&self, location_id: Uuid, enabled: bool) -> Result<()> { + let mut locations = self.watched_locations.write().await; + + if let Some(location) = locations.get_mut(&location_id) { + let was_enabled = location.enabled; + location.enabled = enabled; + + if *self.is_running.read().await { + if let Some(watcher) = self.watcher.write().await.as_mut() { + match (was_enabled, enabled) { + (false, true) => { + // Enable watching + watcher.watch(&location.path, RecursiveMode::Recursive)?; + info!("Enabled watching for location: {}", location.path.display()); + } + (true, false) => { + // Disable watching + watcher.unwatch(&location.path)?; + info!( + "Disabled watching for location: {}", + location.path.display() + ); + } + _ => {} // No change needed + } + } + } + } + + Ok(()) + } + + /// Get all watched locations + pub async fn get_watched_locations(&self) -> Vec { + self.watched_locations + .read() + .await + .values() + .cloned() + .collect() + } + + // ======================================================================== + // Ephemeral Watch Support (shallow, non-recursive) + // ======================================================================== + + /// Add an ephemeral watch for a directory (shallow, immediate children only). + /// + /// Unlike location watches which are recursive, ephemeral watches only monitor + /// immediate children of the watched directory. This is appropriate for ephemeral + /// browsing where only the current directory's contents are indexed. + /// + /// The path should already be indexed in the ephemeral cache before calling this. + pub async fn add_ephemeral_watch( + &self, + path: PathBuf, + rule_toggles: crate::ops::indexing::rules::RuleToggles, + ) -> Result<()> { + // Check if path is valid + if !path.exists() { + return Err(anyhow::anyhow!( + "Cannot watch non-existent path: {}", + path.display() + )); + } + + if !path.is_dir() { + return Err(anyhow::anyhow!( + "Cannot watch non-directory path: {}", + path.display() + )); + } + + // Check if already watching + { + let watches = self.ephemeral_watches.read().await; + if watches.contains_key(&path) { + debug!("Already watching ephemeral path: {}", path.display()); + return Ok(()); + } + } + + // Register in ephemeral cache + self.context + .ephemeral_cache() + .register_for_watching(path.clone()); + + // Add to our tracking + { + let mut watches = self.ephemeral_watches.write().await; + watches.insert( + path.clone(), + EphemeralWatch { + path: path.clone(), + rule_toggles, + }, + ); + // Update metrics + self.metrics.update_ephemeral_watches(watches.len()); + } + + // Add to file system watcher with NonRecursive mode + if *self.is_running.read().await { + if let Some(watcher) = self.watcher.write().await.as_mut() { + watcher.watch(&path, RecursiveMode::NonRecursive)?; + info!("Started shallow ephemeral watch for: {}", path.display()); + } + } + + Ok(()) + } + + /// Remove an ephemeral watch + pub async fn remove_ephemeral_watch(&self, path: &Path) -> Result<()> { + let watch = { + let mut watches = self.ephemeral_watches.write().await; + let watch = watches.remove(path); + // Update metrics + self.metrics.update_ephemeral_watches(watches.len()); + watch + }; + + if let Some(watch) = watch { + // Unregister from ephemeral cache + self.context + .ephemeral_cache() + .unregister_from_watching(&watch.path); + + // Remove from file system watcher + if *self.is_running.read().await { + if let Some(watcher) = self.watcher.write().await.as_mut() { + if let Err(e) = watcher.unwatch(&watch.path) { + warn!( + "Failed to unwatch ephemeral path {}: {}", + watch.path.display(), + e + ); + } else { + info!("Stopped ephemeral watch for: {}", watch.path.display()); + } + } + } + } + + Ok(()) + } + + /// Get all ephemeral watches + pub async fn get_ephemeral_watches(&self) -> Vec { + self.ephemeral_watches + .read() + .await + .values() + .cloned() + .collect() + } + + /// Check if a path has an ephemeral watch + pub async fn has_ephemeral_watch(&self, path: &Path) -> bool { + self.ephemeral_watches.read().await.contains_key(path) + } + + /// Find the ephemeral watch that covers a given path (if any). + /// + /// For shallow watches, only returns a match if the path is an immediate + /// child of a watched directory. + pub async fn find_ephemeral_watch_for_path(&self, path: &Path) -> Option { + let watches = self.ephemeral_watches.read().await; + + // Get the parent directory of the event path + let parent = path.parent()?; + + // Check if the parent is being watched + watches.get(parent).cloned() + } + + /// Load existing locations from the database and add them to the watcher + async fn load_existing_locations(&self) -> Result<()> { + info!("Loading existing locations from database..."); + + // Get all libraries from the context + let libraries = self.context.libraries().await; + let library_list = libraries.list().await; + + let mut total_locations = 0; + + for library in library_list { + // Query locations for this library + let db = library.db().conn(); + + // Get current device UUID (this device) + let current_device_uuid = crate::device::get_current_device_id(); + + // First, get the current device's database ID by UUID + let current_device = match crate::infra::db::entities::device::Entity::find() + .filter(crate::infra::db::entities::device::Column::Uuid.eq(current_device_uuid)) + .one(db) + .await + { + Ok(Some(device)) => device, + Ok(None) => { + warn!( + "Current device {} not found in library {} database, skipping location loading", + current_device_uuid, + library.id() + ); + continue; + } + Err(e) => { + warn!( + "Failed to query device {} in library {}: {}, skipping", + current_device_uuid, + library.id(), + e + ); + continue; + } + }; + + // Add timeout to the database query + // Only watch locations owned by THIS device + let locations_result = tokio::time::timeout( + std::time::Duration::from_secs(10), + crate::infra::db::entities::location::Entity::find() + .filter( + crate::infra::db::entities::location::Column::DeviceId + .eq(current_device.id), + ) + .all(db), + ) + .await; + + match locations_result { + Ok(Ok(locations)) => { + debug!( + "Found {} locations in library {}", + locations.len(), + library.id() + ); + + for location in locations { + // Skip locations without entry_id (not yet synced) + let Some(entry_id) = location.entry_id else { + debug!("Skipping location {} without entry_id", location.uuid); + continue; + }; + + // Skip locations with IndexMode::None (not persistently indexed) + if location.index_mode == "none" { + debug!( + "Skipping location {} with IndexMode::None (ephemeral browsing only)", + location.uuid + ); + continue; + } + + // Get the full path using PathResolver with timeout + let path_result = tokio::time::timeout( + std::time::Duration::from_secs(5), + crate::ops::indexing::path_resolver::PathResolver::get_full_path( + db, entry_id, + ), + ) + .await; + + match path_result { + Ok(Ok(path)) => { + // Skip cloud locations - they don't have filesystem paths to watch + // Cloud paths use service-native URIs like s3://, gdrive://, etc. + let path_str = path.to_string_lossy(); + if path_str.contains("://") && !path_str.starts_with("local://") { + debug!( + "Skipping cloud location {} from filesystem watcher: {}", + location.uuid, path_str + ); + continue; + } + + // Register database connection for this location first + let db = library.db().conn().clone(); + self.platform_handler + .register_location_db(location.uuid, db) + .await; + + // Convert database location to WatchedLocation + let watched_location = WatchedLocation { + id: location.uuid, + library_id: library.id(), + path: path.clone(), + enabled: true, // TODO: Add enabled field to database schema + rule_toggles: Default::default(), // Use default rules for existing locations + }; + + // Add to watched locations + if let Err(e) = self.add_location(watched_location).await { + warn!( + "Failed to add location {} to watcher: {}", + location.uuid, e + ); + } else { + total_locations += 1; + debug!( + "Added location {} to watcher: {} (with DB connection)", + location.uuid, + path.display() + ); + } + } + Ok(Err(e)) => { + warn!( + "Failed to get path for location {}: {}, skipping", + location.uuid, e + ); + } + Err(_) => { + warn!( + "Timeout getting path for location {}, skipping", + location.uuid + ); + } + } + } + } + Ok(Err(e)) => { + warn!( + "Database error loading locations for library {}: {}, continuing with other libraries", + library.id(), + e + ); + } + Err(_) => { + warn!( + "Timeout loading locations for library {}, continuing with other libraries", + library.id() + ); + } + } + } + + info!("Loaded {} locations from database", total_locations); + + // Update metrics with the total count + self.metrics.update_total_locations(total_locations); + + Ok(()) + } + + /// Start the event processing loop + async fn start_event_loop(&self) -> Result<()> { + let platform_handler = self.platform_handler.clone(); + let watched_locations = self.watched_locations.clone(); + let ephemeral_watches = self.ephemeral_watches.clone(); + let workers = self.workers.clone(); + let is_running = self.is_running.clone(); + let debug_mode = self.config.debug_mode; + let metrics = self.metrics.clone(); + let events = self.events.clone(); + let context = self.context.clone(); + + let (tx, mut rx) = mpsc::channel(self.config.event_buffer_size); + let tx_clone = tx.clone(); + + // Create file system watcher + let mut watcher = + notify::recommended_watcher(move |res: Result| { + match res { + Ok(event) => { + // Always log raw events for now to debug rename issues + debug!( + "Raw notify event: kind={:?}, paths={:?}", + event.kind, event.paths + ); + + // Record event received + metrics.record_event_received(); + + // Convert notify event to our WatcherEvent + let watcher_event = WatcherEvent::from_notify_event(event); + + // Send event directly to avoid runtime context issues + // Use try_send since we're in a sync context + match tx_clone.try_send(watcher_event) { + Ok(_) => { + debug!("Successfully sent event to channel"); + } + Err(e) => { + error!("Failed to send watcher event: {}", e); + // This could happen if the channel is full or receiver is dropped + } + } + } + Err(e) => { + error!("File system watcher error: {}", e); + } + } + })?; + + // Configure watcher + watcher.configure(Config::default().with_poll_interval(Duration::from_millis(500)))?; + + // Watch all enabled locations and create workers + let locations = watched_locations.read().await; + for location in locations.values() { + if location.enabled { + watcher.watch(&location.path, RecursiveMode::Recursive)?; + info!("Started watching location: {}", location.path.display()); + } + } + drop(locations); + + // Watch all ephemeral paths (non-recursive/shallow) + let ephemeral = ephemeral_watches.read().await; + for watch in ephemeral.values() { + watcher.watch(&watch.path, RecursiveMode::NonRecursive)?; + info!( + "Started shallow ephemeral watch for: {}", + watch.path.display() + ); + } + drop(ephemeral); + + // Store watcher + *self.watcher.write().await = Some(watcher); + + // Start event processing loop + tokio::spawn(async move { + info!("Location watcher event loop task spawned"); + + while *is_running.read().await { + tokio::select! { + Some(event) = rx.recv() => { + debug!("Received event from channel: {:?}", event.kind); + // Process the event through platform handler + match platform_handler.process_event(event, &watched_locations, &ephemeral_watches).await { + Ok(processed_events) => { + for processed_event in processed_events { + match processed_event { + Event::FsRawChange { library_id, kind } => { + // Emit the event to the event bus for subscribers + events.emit(Event::FsRawChange { + library_id, + kind: kind.clone(), + }); + + // Extract path from event for location matching + let event_path = match &kind { + FsRawEventKind::Create { path } => Some(path.as_path()), + FsRawEventKind::Modify { path } => Some(path.as_path()), + FsRawEventKind::Remove { path } => Some(path.as_path()), + FsRawEventKind::Rename { from, .. } => Some(from.as_path()), + }; + + // First, check if this is an ephemeral watch event + // For shallow watches, only process if path is immediate child + let mut handled_by_ephemeral = false; + if let Some(event_path) = event_path { + let parent = event_path.parent(); + if let Some(parent_path) = parent { + let ephemeral = ephemeral_watches.read().await; + if let Some(watch) = ephemeral.get(parent_path) { + debug!( + "Ephemeral watch match for {}: parent {} is watched", + event_path.display(), + parent_path.display() + ); + handled_by_ephemeral = true; + + // Process via ephemeral handler + let ctx = context.clone(); + let root = watch.path.clone(); + let toggles = watch.rule_toggles; + let event_kind = kind.clone(); + + debug!("Spawning ephemeral responder task for: {}", event_path.display()); + tokio::spawn(async move { + debug!("Ephemeral responder task started"); + if let Err(e) = crate::ops::indexing::ephemeral::responder::apply( + &ctx, + &root, + event_kind, + toggles, + ).await { + warn!("Failed to process ephemeral event: {}", e); + } else { + debug!("Ephemeral responder task completed successfully"); + } + }); + } else { + trace!("No ephemeral watch for parent: {}", parent_path.display()); + } + } + } + + // Skip location matching if handled by ephemeral + if handled_by_ephemeral { + continue; + } + + // Find the location for this event by matching path prefix + // CRITICAL: Must match by path, not just library_id, to avoid routing + // events to the wrong location when multiple locations exist in one library + let locations = watched_locations.read().await; + let mut matched_location = None; + let mut longest_match_len = 0; + + if let Some(event_path) = event_path { + for location in locations.values() { + if location.library_id == library_id && location.enabled { + // Check if event path is under this location's root + if event_path.starts_with(&location.path) { + let match_len = location.path.as_os_str().len(); + // Use longest matching path to handle nested locations + if match_len > longest_match_len { + longest_match_len = match_len; + matched_location = Some(location.id); + } + } + } + } + } + + if let Some(location_id) = matched_location { + if let Some(worker_tx) = workers.read().await.get(&location_id) { + // Convert FsRawEventKind back to WatcherEvent for worker + let watcher_event = match kind { + FsRawEventKind::Create { path } => WatcherEvent { + kind: event_handler::WatcherEventKind::Create, + paths: vec![path], + timestamp: std::time::SystemTime::now(), + attrs: vec![], + }, + FsRawEventKind::Modify { path } => WatcherEvent { + kind: event_handler::WatcherEventKind::Modify, + paths: vec![path], + timestamp: std::time::SystemTime::now(), + attrs: vec![], + }, + FsRawEventKind::Remove { path } => WatcherEvent { + kind: event_handler::WatcherEventKind::Remove, + paths: vec![path], + timestamp: std::time::SystemTime::now(), + attrs: vec![], + }, + FsRawEventKind::Rename { from, to } => WatcherEvent { + kind: event_handler::WatcherEventKind::Rename { from, to }, + paths: vec![], + timestamp: std::time::SystemTime::now(), + attrs: vec![], + }, + }; + + debug!("Routing event to location {}: {:?}", location_id, watcher_event.kind); + if let Err(e) = worker_tx.send(watcher_event).await { + warn!("Failed to send event to worker for location {}: {}", location_id, e); + } else { + debug!("✓ Successfully sent event to worker for location {}", location_id); + } + } else { + warn!("No worker found for matched location {}", location_id); + } + } else { + warn!("No matching location found for event path: {:?}", event_path); + } + } + other => { + // Preserve emission of any other events + // Note: We need access to events bus here, but it's not available in this scope + // This will be handled by the workers when they emit final events + } + } + } + } + Err(e) => { + error!("Error processing watcher event: {}", e); + } + } + trace!("Finished processing event, continuing loop"); + } + _ = tokio::time::sleep(Duration::from_millis(100)) => { + // Periodic tick for debouncing and cleanup + if let Err(e) = platform_handler.tick().await { + error!("Error during platform handler tick: {}", e); + } + + // Handle platform-specific tick events that might generate additional events (e.g., rename matching) + #[cfg(target_os = "macos")] + { + if let Ok(tick_events) = platform_handler.inner.tick_with_locations(&watched_locations, &ephemeral_watches).await { + for tick_event in tick_events { + match tick_event { + Event::FsRawChange { library_id, kind } => { + // Emit the event to the event bus for subscribers + events.emit(Event::FsRawChange { + library_id, + kind: kind.clone(), + }); + + // Extract path from event for location matching + let event_path = match &kind { + FsRawEventKind::Create { path } => Some(path.as_path()), + FsRawEventKind::Modify { path } => Some(path.as_path()), + FsRawEventKind::Remove { path } => Some(path.as_path()), + FsRawEventKind::Rename { from, .. } => Some(from.as_path()), + }; + + // Check if this is an ephemeral event first + let mut handled_by_ephemeral = false; + if let Some(event_path) = event_path { + let parent = event_path.parent(); + if let Some(parent_path) = parent { + let ephemeral = ephemeral_watches.read().await; + if let Some(watch) = ephemeral.get(parent_path) { + debug!( + "Tick event: Ephemeral watch match for {}: parent {} is watched", + event_path.display(), + parent_path.display() + ); + handled_by_ephemeral = true; + + // Process via ephemeral handler + let ctx = context.clone(); + let root = watch.path.clone(); + let toggles = watch.rule_toggles; + let event_kind = kind.clone(); + + debug!("Tick event: Spawning ephemeral responder task for: {}", event_path.display()); + tokio::spawn(async move { + debug!("Tick event: Ephemeral responder task started"); + if let Err(e) = crate::ops::indexing::ephemeral::responder::apply( + &ctx, + &root, + event_kind, + toggles, + ).await { + warn!("Tick event: Failed to process ephemeral event: {}", e); + } else { + debug!("Tick event: Ephemeral responder task completed successfully"); + } + }); + } + } + } + + // Skip location routing if handled by ephemeral + if handled_by_ephemeral { + continue; + } + + // Find the location for this event by matching path prefix + let locations = watched_locations.read().await; + let mut matched_location = None; + let mut longest_match_len = 0; + + if let Some(event_path) = event_path { + for location in locations.values() { + if location.library_id == library_id && location.enabled { + if event_path.starts_with(&location.path) { + let match_len = location.path.as_os_str().len(); + if match_len > longest_match_len { + longest_match_len = match_len; + matched_location = Some(location.id); + } + } + } + } + } + + if let Some(location_id) = matched_location { + if let Some(worker_tx) = workers.read().await.get(&location_id) { + let watcher_event = match kind { + FsRawEventKind::Create { path } => WatcherEvent { + kind: event_handler::WatcherEventKind::Create, + paths: vec![path], + timestamp: std::time::SystemTime::now(), + attrs: vec![], + }, + FsRawEventKind::Modify { path } => WatcherEvent { + kind: event_handler::WatcherEventKind::Modify, + paths: vec![path], + timestamp: std::time::SystemTime::now(), + attrs: vec![], + }, + FsRawEventKind::Remove { path } => WatcherEvent { + kind: event_handler::WatcherEventKind::Remove, + paths: vec![path], + timestamp: std::time::SystemTime::now(), + attrs: vec![], + }, + FsRawEventKind::Rename { from, to } => WatcherEvent { + kind: event_handler::WatcherEventKind::Rename { from, to }, + paths: vec![], + timestamp: std::time::SystemTime::now(), + attrs: vec![], + }, + }; + + if let Err(e) = worker_tx.send(watcher_event).await { + warn!("Failed to send tick event to worker for location {}: {}", location_id, e); + } + } + } + } + _ => { + // Other event types, if any + } + } + } + } + } + + #[cfg(target_os = "windows")] + { + if let Ok(tick_events) = platform_handler.inner.tick_with_locations(&watched_locations).await { + for tick_event in tick_events { + // Similar handling for Windows if needed + match tick_event { + Event::FsRawChange { library_id, kind } => { + events.emit(Event::FsRawChange { + library_id, + kind: kind.clone(), + }); + } + _ => {} + } + } + } + } + } + } + } + + info!("Location watcher event loop stopped"); + }); + + Ok(()) + } + + /// Start listening for LocationAdded events to dynamically add new locations + async fn start_location_event_listener(&self) { + let mut event_subscriber = self.events.subscribe(); + let watched_locations = self.watched_locations.clone(); + let watcher_ref = self.watcher.clone(); + let workers = self.workers.clone(); + let is_running = self.is_running.clone(); + let context = self.context.clone(); + let events = self.events.clone(); + let config = self.config.clone(); + let worker_metrics = self.worker_metrics.clone(); + let metrics = self.metrics.clone(); + let metrics_collector = self.metrics_collector.clone(); + let platform_handler = self.platform_handler.clone(); + + tokio::spawn(async move { + info!("Location event listener started"); + + while *is_running.read().await { + match event_subscriber.recv().await { + Ok(Event::LocationAdded { + library_id, + location_id, + path, + }) => { + info!( + "Location added event received: {} at {}", + location_id, + path.display() + ); + + // Query the location to check its index_mode + let libraries = context.libraries().await; + let should_watch = if let Some(library) = libraries.get_library(library_id).await { + let db = library.db().conn(); + match crate::infra::db::entities::location::Entity::find() + .filter(crate::infra::db::entities::location::Column::Uuid.eq(location_id)) + .one(db) + .await + { + Ok(Some(location_record)) => { + if location_record.index_mode == "none" { + debug!( + "Skipping newly added location {} with IndexMode::None", + location_id + ); + false + } else { + true + } + } + Ok(None) => { + warn!("Location {} not found in database", location_id); + false + } + Err(e) => { + error!("Failed to query location {}: {}", location_id, e); + false + } + } + } else { + warn!("Library {} not found for location {}", library_id, location_id); + false + }; + + if !should_watch { + continue; + } + + // Create a temporary LocationWatcher instance for this operation + let temp_watcher = LocationWatcher { + config: config.clone(), + events: events.clone(), + context: context.clone(), + watched_locations: watched_locations.clone(), + ephemeral_watches: Arc::new(RwLock::new(HashMap::new())), + watcher: watcher_ref.clone(), + is_running: is_running.clone(), + platform_handler: platform_handler.clone(), + workers: workers.clone(), + metrics: metrics.clone(), + worker_metrics: worker_metrics.clone(), + metrics_collector: metrics_collector.clone(), + }; + + // Create WatchedLocation and add to watcher + let watched_location = WatchedLocation { + id: location_id, + library_id, + path: path.clone(), + enabled: true, + rule_toggles: Default::default(), // Use default rules for new locations + }; + + // Add location to watcher + if let Err(e) = temp_watcher.add_location(watched_location).await { + error!("Failed to add location {} to watcher: {}", location_id, e); + } else { + info!( + "Successfully added location {} to watcher: {}", + location_id, + path.display() + ); + } + } + Ok(Event::LocationRemoved { location_id, .. }) => { + info!("Location removed event received: {}", location_id); + + // Create a temporary LocationWatcher instance for this operation + let temp_watcher = LocationWatcher { + config: config.clone(), + events: events.clone(), + context: context.clone(), + watched_locations: watched_locations.clone(), + ephemeral_watches: Arc::new(RwLock::new(HashMap::new())), + watcher: watcher_ref.clone(), + is_running: is_running.clone(), + platform_handler: platform_handler.clone(), + workers: workers.clone(), + metrics: metrics.clone(), + worker_metrics: worker_metrics.clone(), + metrics_collector: metrics_collector.clone(), + }; + + // Remove location from watcher + if let Err(e) = temp_watcher.remove_location(location_id).await { + error!( + "Failed to remove location {} from watcher: {}", + location_id, e + ); + } else { + info!("Successfully removed location {} from watcher", location_id); + } + } + Ok(_) => { + // Ignore other events + } + Err(e) => { + // error!("Location event listener error: {}", e); + // Continue listening despite errors + } + } + } + + info!("Location event listener stopped"); + }); + } +} + +#[async_trait::async_trait] +impl Service for LocationWatcher { + async fn start(&self) -> Result<()> { + if *self.is_running.read().await { + warn!("Location watcher is already running"); + return Ok(()); + } + + info!("Starting location watcher service"); + + *self.is_running.write().await = true; + + // Load existing locations from database + if let Err(e) = self.load_existing_locations().await { + error!("Failed to load existing locations: {}", e); + // Continue starting the service even if loading locations fails + } + + // Start listening for LocationAdded events + self.start_location_event_listener().await; + + // Start metrics collector + self.start_metrics_collector().await?; + + self.start_event_loop().await?; + + info!("Location watcher service started"); + Ok(()) + } + + async fn stop(&self) -> Result<()> { + if !*self.is_running.read().await { + return Ok(()); + } + + info!("Stopping location watcher service"); + + *self.is_running.write().await = false; + + // Clean up watcher + *self.watcher.write().await = None; + + // Clean up all workers (dropping the senders will close the channels and stop the workers) + let worker_count = { + let mut workers = self.workers.write().await; + let count = workers.len(); + workers.clear(); + count + }; + + info!("Stopped {} location workers", worker_count); + + // Clean up worker metrics + { + let mut metrics_map = self.worker_metrics.write().await; + metrics_map.clear(); + } + + // Clean up metrics collector + { + *self.metrics_collector.write().await = None; + } + + info!("Location watcher service stopped"); + Ok(()) + } + + fn is_running(&self) -> bool { + // Use try_read to avoid blocking + self.is_running.try_read().map_or(false, |guard| *guard) + } + + fn name(&self) -> &'static str { + "location_watcher" + } +} + +#[cfg(test)] +mod tests { + use crate::ops::indexing::RuleToggles; + + use super::*; + use tempfile::TempDir; + + fn create_test_events() -> Arc { + Arc::new(EventBus::default()) + } + + fn create_mock_context() -> Arc { + // This would need to be implemented based on your CoreContext structure + // For now, we'll use a placeholder + todo!("Implement mock CoreContext for tests") + } + + #[tokio::test] + async fn test_location_watcher_creation() { + let config = LocationWatcherConfig::default(); + let events = create_test_events(); + let context = create_mock_context(); + let watcher = LocationWatcher::new(config, events, context); + + assert!(!watcher.is_running()); + assert_eq!(watcher.name(), "location_watcher"); + } + + #[tokio::test] + async fn test_add_remove_location() { + let config = LocationWatcherConfig::default(); + let events = create_test_events(); + let context = create_mock_context(); + let watcher = LocationWatcher::new(config, events, context); + + let temp_dir = TempDir::new().unwrap(); + let location = WatchedLocation { + id: Uuid::new_v4(), + library_id: Uuid::new_v4(), + path: temp_dir.path().to_path_buf(), + enabled: true, + rule_toggles: RuleToggles::default(), + }; + + let location_id = location.id; + + // Add location + watcher.add_location(location).await.unwrap(); + + let locations = watcher.get_watched_locations().await; + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].id, location_id); + + // Remove location + watcher.remove_location(location_id).await.unwrap(); + + let locations = watcher.get_watched_locations().await; + assert_eq!(locations.len(), 0); + } +} diff --git a/core/src/service/watcher/platform/linux.rs b/core/src/service/watcher_old/platform/linux.rs similarity index 100% rename from core/src/service/watcher/platform/linux.rs rename to core/src/service/watcher_old/platform/linux.rs diff --git a/core/src/service/watcher/platform/macos.rs b/core/src/service/watcher_old/platform/macos.rs similarity index 92% rename from core/src/service/watcher/platform/macos.rs rename to core/src/service/watcher_old/platform/macos.rs index be33d4268..4eb8a82b8 100644 --- a/core/src/service/watcher/platform/macos.rs +++ b/core/src/service/watcher_old/platform/macos.rs @@ -416,6 +416,7 @@ impl MacOSHandler { // Check locations first for location in locations.values() { if path.starts_with(&location.path) { + debug!("Eviction: matched location for {}", path.display()); events.push(Event::FsRawChange { library_id: location.library_id, kind: crate::infra::event::FsRawEventKind::Create { @@ -429,17 +430,24 @@ impl MacOSHandler { // If not matched by location, check ephemeral watches if !matched { + debug!("Eviction: checking ephemeral watches for {}", path.display()); let ephemeral = ephemeral_watches.read().await; if let Some(parent) = path.parent() { + debug!("Eviction: parent is {}, ephemeral watch count: {}", parent.display(), ephemeral.len()); if ephemeral.contains_key(parent) { + debug!("Eviction: MATCHED ephemeral watch, emitting FsRawChange for {}", path.display()); events.push(Event::FsRawChange { library_id: Uuid::nil(), // Ephemeral events use nil UUID kind: crate::infra::event::FsRawEventKind::Create { path: path.clone(), }, }); + } else { + debug!("Eviction: no ephemeral watch for parent {}", parent.display()); } } + } else { + debug!("Eviction: already matched location, skipping ephemeral check"); } } } @@ -459,6 +467,7 @@ impl MacOSHandler { // Emit create event (responder will detect if it's an update via inode) let locations = watched_locations.read().await; + let mut matched = false; for location in locations.values() { if path.starts_with(&location.path) { events.push(Event::FsRawChange { @@ -467,9 +476,25 @@ impl MacOSHandler { path: path.clone(), }, }); + matched = true; break; } } + + // Check ephemeral watches if not matched + if !matched { + let ephemeral = ephemeral_watches.read().await; + if let Some(parent) = path.parent() { + if ephemeral.contains_key(parent) { + events.push(Event::FsRawChange { + library_id: Uuid::nil(), + kind: crate::infra::event::FsRawEventKind::Create { + path: path.clone(), + }, + }); + } + } + } } } *reincident_files = reincident_to_keep; @@ -481,6 +506,7 @@ impl MacOSHandler { async fn handle_rename_create_eviction( &self, watched_locations: &Arc>>, + ephemeral_watches: &Arc>>, ) -> Result> { let mut events = Vec::new(); let mut new_paths = self.new_paths_map.write().await; @@ -494,6 +520,7 @@ impl MacOSHandler { match tokio::fs::metadata(&path).await { Ok(metadata) => { let locations = watched_locations.read().await; + let mut matched = false; for location in locations.values() { if path.starts_with(&location.path) { events.push(Event::FsRawChange { @@ -507,9 +534,25 @@ impl MacOSHandler { let mut to_recalc = self.to_recalculate_size.write().await; to_recalc.insert(parent.to_path_buf(), Instant::now()); } + matched = true; break; } } + + // Check ephemeral watches if not matched + if !matched { + let ephemeral = ephemeral_watches.read().await; + if let Some(parent) = path.parent() { + if ephemeral.contains_key(parent) { + events.push(Event::FsRawChange { + library_id: Uuid::nil(), + kind: crate::infra::event::FsRawEventKind::Create { + path: path.clone(), + }, + }); + } + } + } } Err(_) => { // File no longer exists, ignore @@ -529,6 +572,7 @@ impl MacOSHandler { async fn handle_rename_remove_eviction( &self, watched_locations: &Arc>>, + ephemeral_watches: &Arc>>, ) -> Result> { let mut events = Vec::new(); let mut old_paths = self.old_paths_map.write().await; @@ -538,6 +582,7 @@ impl MacOSHandler { if instant.elapsed() > HUNDRED_MILLIS { // Path has timed out, treat as removal let locations = watched_locations.read().await; + let mut matched = false; for location in locations.values() { if path.starts_with(&location.path) { events.push(Event::FsRawChange { @@ -551,9 +596,25 @@ impl MacOSHandler { let mut to_recalc = self.to_recalculate_size.write().await; to_recalc.insert(parent.to_path_buf(), Instant::now()); } + matched = true; break; } } + + // Check ephemeral watches if not matched + if !matched { + let ephemeral = ephemeral_watches.read().await; + if let Some(parent) = path.parent() { + if ephemeral.contains_key(parent) { + events.push(Event::FsRawChange { + library_id: Uuid::nil(), + kind: crate::infra::event::FsRawEventKind::Remove { + path: path.clone(), + }, + }); + } + } + } } else { paths_to_keep.insert(inode, (instant, path)); } @@ -763,13 +824,13 @@ impl MacOSHandler { // Handle rename create evictions let create_events = self - .handle_rename_create_eviction(watched_locations) + .handle_rename_create_eviction(watched_locations, ephemeral_watches) .await?; all_events.extend(create_events); // Handle rename remove evictions let remove_events = self - .handle_rename_remove_eviction(watched_locations) + .handle_rename_remove_eviction(watched_locations, ephemeral_watches) .await?; all_events.extend(remove_events); diff --git a/core/src/service/watcher/platform/mod.rs b/core/src/service/watcher_old/platform/mod.rs similarity index 100% rename from core/src/service/watcher/platform/mod.rs rename to core/src/service/watcher_old/platform/mod.rs diff --git a/core/src/service/watcher/platform/windows.rs b/core/src/service/watcher_old/platform/windows.rs similarity index 100% rename from core/src/service/watcher/platform/windows.rs rename to core/src/service/watcher_old/platform/windows.rs diff --git a/core/src/service/watcher/tests.rs b/core/src/service/watcher_old/tests.rs similarity index 100% rename from core/src/service/watcher/tests.rs rename to core/src/service/watcher_old/tests.rs diff --git a/core/src/service/watcher/utils.rs b/core/src/service/watcher_old/utils.rs similarity index 100% rename from core/src/service/watcher/utils.rs rename to core/src/service/watcher_old/utils.rs diff --git a/core/src/service/watcher/worker.rs b/core/src/service/watcher_old/worker.rs similarity index 100% rename from core/src/service/watcher/worker.rs rename to core/src/service/watcher_old/worker.rs diff --git a/core/src/testing/integration_utils.rs b/core/src/testing/integration_utils.rs index 38a5327e0..570ff4508 100644 --- a/core/src/testing/integration_utils.rs +++ b/core/src/testing/integration_utils.rs @@ -135,7 +135,7 @@ pub struct TestConfigBuilder { log_level: String, networking_enabled: bool, volume_monitoring_enabled: bool, - location_watcher_enabled: bool, + fs_watcher_enabled: bool, job_logging_enabled: bool, telemetry_enabled: bool, } @@ -148,7 +148,7 @@ impl TestConfigBuilder { log_level: "warn".to_string(), // Reduce log noise by default networking_enabled: false, // Disable for faster tests volume_monitoring_enabled: false, // Disable for faster tests - location_watcher_enabled: true, // Usually needed for indexing tests + fs_watcher_enabled: true, // Usually needed for indexing tests job_logging_enabled: true, // Usually needed for job tests telemetry_enabled: false, // Disable for tests } @@ -172,9 +172,9 @@ impl TestConfigBuilder { self } - /// Enable/disable location watcher (default: true) - pub fn location_watcher_enabled(mut self, enabled: bool) -> Self { - self.location_watcher_enabled = enabled; + /// Enable/disable filesystem watcher (default: true) + pub fn fs_watcher_enabled(mut self, enabled: bool) -> Self { + self.fs_watcher_enabled = enabled; self } @@ -207,7 +207,7 @@ impl TestConfigBuilder { services: ServiceConfig { networking_enabled: self.networking_enabled, volume_monitoring_enabled: self.volume_monitoring_enabled, - location_watcher_enabled: self.location_watcher_enabled, + fs_watcher_enabled: self.fs_watcher_enabled, }, logging: crate::config::app_config::LoggingConfig::default(), } @@ -236,8 +236,8 @@ impl TestConfigBuilder { config.services.volume_monitoring_enabled ); info!( - " - Location watcher enabled: {}", - config.services.location_watcher_enabled + " - Filesystem watcher enabled: {}", + config.services.fs_watcher_enabled ); info!(" - Job logging enabled: {}", config.job_logging.enabled); @@ -424,8 +424,8 @@ impl IntegrationTestSetup { loaded_config.services.volume_monitoring_enabled ); info!( - " - Location watcher enabled: {}", - loaded_config.services.location_watcher_enabled + " - Filesystem watcher enabled: {}", + loaded_config.services.fs_watcher_enabled ); info!( " - Job logging enabled: {}", diff --git a/crates/fs-watcher/Cargo.toml b/crates/fs-watcher/Cargo.toml new file mode 100644 index 000000000..823588493 --- /dev/null +++ b/crates/fs-watcher/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "sd-fs-watcher" +version = "0.1.0" + +authors = ["Spacedrive Technology Inc. "] +description = """ +Platform-agnostic filesystem watcher that emits normalized events. +Handles platform-specific quirks like macOS rename detection and event buffering. +""" + +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[features] +default = [] +# Enable detailed debug logging +debug = [] + +[dependencies] +# Workspace dependencies +async-trait = { workspace = true } +futures = { workspace = true } +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["sync", "time", "rt", "macros", "fs"] } +tracing = { workspace = true } + +# External dependencies +notify = "8.0.0" + +# Platform-specific dependencies +[target.'cfg(target_os = "macos")'.dependencies] +libc = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tracing-subscriber = { workspace = true } +tracing-test = { workspace = true } diff --git a/crates/fs-watcher/README.md b/crates/fs-watcher/README.md new file mode 100644 index 000000000..bb19df427 --- /dev/null +++ b/crates/fs-watcher/README.md @@ -0,0 +1,203 @@ +# sd-fs-watcher + +Platform-agnostic filesystem watcher for Spacedrive. + +## Overview + +`sd-fs-watcher` provides a clean, storage-agnostic interface for watching filesystem changes. It handles platform-specific quirks (like macOS rename detection) internally and emits normalized events. + +This crate is designed to be the foundation of Spacedrive's filesystem event system, but it has no knowledge of: + +- Databases or ORM entities +- Libraries or locations +- UUIDs or entry IDs + +It just watches paths and emits events. + +## Usage + +```rust +use sd_fs_watcher::{FsWatcher, WatchConfig, WatcherConfig}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Create watcher with default config + let watcher = FsWatcher::new(WatcherConfig::default()); + watcher.start().await?; + + // Subscribe to events + let mut rx = watcher.subscribe(); + + // Watch a directory recursively + let _handle = watcher.watch("/path/to/watch", WatchConfig::recursive()).await?; + + // Process events + while let Ok(event) = rx.recv().await { + match event.kind { + sd_fs_watcher::FsEventKind::Create => { + println!("Created: {}", event.path.display()); + } + sd_fs_watcher::FsEventKind::Modify => { + println!("Modified: {}", event.path.display()); + } + sd_fs_watcher::FsEventKind::Remove => { + println!("Removed: {}", event.path.display()); + } + sd_fs_watcher::FsEventKind::Rename { from, to } => { + println!("Renamed: {} -> {}", from.display(), to.display()); + } + } + } + + Ok(()) +} +``` + +## Watch Modes + +### Recursive (default) + +Watch a directory and all its subdirectories: + +```rust +let _handle = watcher.watch("/path", WatchConfig::recursive()).await?; +``` + +### Shallow + +Watch only immediate children of a directory (for ephemeral browsing): + +```rust +let _handle = watcher.watch("/path", WatchConfig::shallow()).await?; +``` + +## Event Filtering + +By default, the watcher filters out: + +- Temporary files (`.tmp`, `.temp`, `~`, `.swp`) +- System files (`.DS_Store`, `Thumbs.db`) +- Hidden files (starting with `.`) + +Important dotfiles like `.gitignore`, `.env`, etc. are preserved. + +```rust +// Custom filtering +let config = WatchConfig::recursive() + .with_filters(EventFilters { + skip_hidden: false, // Include hidden files + skip_system_files: true, + skip_temp_files: true, + skip_patterns: vec!["node_modules".to_string()], + important_dotfiles: vec![".env".to_string()], + }); +``` + +## Platform-Specific Behavior + +### macOS + +macOS FSEvents doesn't provide native rename tracking. When a file is renamed, we receive separate create and delete events. This crate implements rename detection via inode tracking: + +1. When a file is created, we record its inode +2. When a file is removed, we buffer it briefly +3. If a create with the same inode arrives within 500ms, we emit a rename event +4. Otherwise, we emit separate create/remove events + +### Linux + +Linux inotify provides better rename tracking. We handle rename events directly when both paths are provided, with a small stabilization buffer for modify events. + +### Windows + +Windows ReadDirectoryChangesW provides reasonable tracking. We implement rename detection by buffering remove events and matching with subsequent creates. + +## Reference Counting + +Multiple calls to `watch()` on the same path share resources: + +```rust +let handle1 = watcher.watch("/path", WatchConfig::recursive()).await?; +let handle2 = watcher.watch("/path", WatchConfig::recursive()).await?; + +// Only one actual watch is registered with the OS +// Dropping both handles will unwatch +drop(handle1); +// Still watching (handle2 exists) + +drop(handle2); +// Now actually unwatched +``` + +## Metrics + +```rust +let received = watcher.events_received(); // Raw events from notify +let emitted = watcher.events_emitted(); // Processed events broadcast +``` + +## Event Metadata + +Each `FsEvent` includes an optional `is_directory` flag: + +```rust +pub struct FsEvent { + pub path: PathBuf, + pub kind: FsEventKind, + pub timestamp: SystemTime, + pub is_directory: Option, // Avoids extra fs::metadata calls downstream +} +``` + +Check directory status without filesystem calls: + +```rust +if let Some(true) = event.is_dir() { + // Handle directory event +} else if let Some(false) = event.is_file() { + // Handle file event +} else { + // Unknown - check filesystem if needed (e.g., for Remove events) +} +``` + +## Integration with Spacedrive + +This crate is designed to be consumed by higher-level services: + +- **PersistentIndexService**: Subscribes to events, filters by location scope, writes to database +- **EphemeralIndexService**: Subscribes to events, filters by session scope, writes to memory + +These services are not part of this crate - they live in `sd-core` and consume events from `FsWatcher`. + +### Backpressure Management + +The `FsWatcher` uses a broadcast channel for event distribution. To avoid backpressure issues: + +1. **Don't block in the receiver loop**: Avoid synchronous database writes directly in the broadcast receiver +2. **Use internal batching queues**: The `PersistentIndexService` should receive events and immediately push them to its own internal batching queue (like the existing `LocationWorker` logic) +3. **Keep the broadcast clear**: This ensures the `EphemeralIndexService` (UI updates) receives events promptly + +```rust +// Good pattern for PersistentIndexService +let mut rx = watcher.subscribe(); +let (batch_tx, batch_rx) = mpsc::channel(100_000); + +// Receiver task - fast, non-blocking +tokio::spawn(async move { + while let Ok(event) = rx.recv().await { + if is_in_my_scope(&event) { + let _ = batch_tx.send(event).await; // Push to internal queue + } + } +}); + +// Worker task - handles batching and DB writes +tokio::spawn(async move { + // Batch events, coalesce, write to DB... +}); +``` + +### Database-Backed Inode Lookup + +For enhanced rename detection on macOS, the `PersistentIndexService` can maintain an inode cache. When a Remove event is received, check if the inode exists in your database to detect if it's actually a rename where the "new path" hasn't arrived yet. diff --git a/crates/fs-watcher/src/config.rs b/crates/fs-watcher/src/config.rs new file mode 100644 index 000000000..513a175a6 --- /dev/null +++ b/crates/fs-watcher/src/config.rs @@ -0,0 +1,259 @@ +//! Watch configuration types +//! +//! Configuration for how paths should be watched and events filtered. + +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// Configuration for watching a path +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WatchConfig { + /// Whether to watch recursively (all subdirectories) or shallow (immediate children only) + pub recursive: bool, + /// Rules for filtering events + pub filters: EventFilters, +} + +impl Default for WatchConfig { + fn default() -> Self { + Self { + recursive: true, + filters: EventFilters::default(), + } + } +} + +impl WatchConfig { + /// Create a recursive watch configuration with default filters + pub fn recursive() -> Self { + Self { + recursive: true, + filters: EventFilters::default(), + } + } + + /// Create a shallow (non-recursive) watch configuration with default filters + pub fn shallow() -> Self { + Self { + recursive: false, + filters: EventFilters::default(), + } + } + + /// Set whether to watch recursively + pub fn with_recursive(mut self, recursive: bool) -> Self { + self.recursive = recursive; + self + } + + /// Set the event filters + pub fn with_filters(mut self, filters: EventFilters) -> Self { + self.filters = filters; + self + } +} + +/// Filters for which events to emit +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventFilters { + /// Skip hidden files (starting with .) + pub skip_hidden: bool, + /// Skip system files (.DS_Store, Thumbs.db, etc.) + pub skip_system_files: bool, + /// Skip temporary files (.tmp, .temp, ~, .swp) + pub skip_temp_files: bool, + /// Custom patterns to skip (glob patterns) + pub skip_patterns: Vec, + /// Keep these dotfiles even if skip_hidden is true + pub important_dotfiles: Vec, +} + +impl Default for EventFilters { + fn default() -> Self { + Self { + skip_hidden: true, + skip_system_files: true, + skip_temp_files: true, + skip_patterns: Vec::new(), + important_dotfiles: vec![ + ".gitignore".to_string(), + ".gitkeep".to_string(), + ".gitattributes".to_string(), + ".editorconfig".to_string(), + ".env".to_string(), + ".env.local".to_string(), + ".nvmrc".to_string(), + ".node-version".to_string(), + ".python-version".to_string(), + ".dockerignore".to_string(), + ".eslintrc".to_string(), + ".prettierrc".to_string(), + ], + } + } +} + +impl EventFilters { + /// Create filters that allow all events (no filtering) + pub fn allow_all() -> Self { + Self { + skip_hidden: false, + skip_system_files: false, + skip_temp_files: false, + skip_patterns: Vec::new(), + important_dotfiles: Vec::new(), + } + } + + /// Check if a path should be filtered out + pub fn should_skip(&self, path: &std::path::Path) -> bool { + let path_str = path.to_string_lossy(); + + // Check temp files + if self.skip_temp_files { + if path_str.contains(".tmp") + || path_str.contains(".temp") + || path_str.ends_with("~") + || path_str.ends_with(".swp") + { + return true; + } + } + + // Check system files + if self.skip_system_files { + if path_str.contains(".DS_Store") || path_str.contains("Thumbs.db") { + return true; + } + } + + // Check hidden files + if self.skip_hidden { + if let Some(file_name) = path.file_name() { + let name = file_name.to_string_lossy(); + if name.starts_with('.') { + // Check if it's an important dotfile + let is_important = self + .important_dotfiles + .iter() + .any(|d| d.as_str() == name.as_ref()); + if !is_important { + return true; + } + } + } + } + + // Check custom skip patterns + for pattern in &self.skip_patterns { + if path_str.contains(pattern) { + return true; + } + } + + false + } +} + +/// Global watcher configuration +#[derive(Debug, Clone)] +pub struct WatcherConfig { + /// Size of the event channel buffer + pub event_buffer_size: usize, + /// Platform-specific tick interval for buffered event eviction + pub tick_interval: Duration, + /// Debounce duration for rapid events + pub debounce_duration: Duration, + /// Enable detailed debug logging + pub debug_mode: bool, +} + +impl Default for WatcherConfig { + fn default() -> Self { + Self { + event_buffer_size: 100_000, + tick_interval: Duration::from_millis(100), + debounce_duration: Duration::from_millis(100), + debug_mode: false, + } + } +} + +impl WatcherConfig { + /// Create a new watcher configuration + pub fn new() -> Self { + Self::default() + } + + /// Set the event buffer size + pub fn with_buffer_size(mut self, size: usize) -> Self { + self.event_buffer_size = size; + self + } + + /// Set the tick interval + pub fn with_tick_interval(mut self, interval: Duration) -> Self { + self.tick_interval = interval; + self + } + + /// Set the debounce duration + pub fn with_debounce(mut self, duration: Duration) -> Self { + self.debounce_duration = duration; + self + } + + /// Enable debug mode + pub fn with_debug(mut self, debug: bool) -> Self { + self.debug_mode = debug; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_default_filters() { + let filters = EventFilters::default(); + + // Should skip system files + assert!(filters.should_skip(&PathBuf::from("/test/.DS_Store"))); + assert!(filters.should_skip(&PathBuf::from("/test/Thumbs.db"))); + + // Should skip temp files + assert!(filters.should_skip(&PathBuf::from("/test/file.tmp"))); + assert!(filters.should_skip(&PathBuf::from("/test/file~"))); + + // Should skip hidden files + assert!(filters.should_skip(&PathBuf::from("/test/.hidden"))); + + // Should NOT skip important dotfiles + assert!(!filters.should_skip(&PathBuf::from("/test/.gitignore"))); + assert!(!filters.should_skip(&PathBuf::from("/test/.env"))); + + // Should NOT skip normal files + assert!(!filters.should_skip(&PathBuf::from("/test/file.txt"))); + } + + #[test] + fn test_allow_all_filters() { + let filters = EventFilters::allow_all(); + + // Should not skip anything + assert!(!filters.should_skip(&PathBuf::from("/test/.DS_Store"))); + assert!(!filters.should_skip(&PathBuf::from("/test/.hidden"))); + assert!(!filters.should_skip(&PathBuf::from("/test/file.tmp"))); + } + + #[test] + fn test_watch_config() { + let config = WatchConfig::recursive(); + assert!(config.recursive); + + let config = WatchConfig::shallow(); + assert!(!config.recursive); + } +} diff --git a/crates/fs-watcher/src/error.rs b/crates/fs-watcher/src/error.rs new file mode 100644 index 000000000..c95b7860c --- /dev/null +++ b/crates/fs-watcher/src/error.rs @@ -0,0 +1,61 @@ +//! Error types for the filesystem watcher + +use std::path::PathBuf; +use thiserror::Error; + +/// Result type alias for watcher operations +pub type Result = std::result::Result; + +/// Errors that can occur during filesystem watching +#[derive(Debug, Error)] +pub enum WatcherError { + /// Failed to start the watcher + #[error("Failed to start watcher: {0}")] + StartFailed(String), + + /// Failed to watch a path + #[error("Failed to watch path {path}: {reason}")] + WatchFailed { + path: PathBuf, + reason: String, + }, + + /// Failed to unwatch a path + #[error("Failed to unwatch path {path}: {reason}")] + UnwatchFailed { + path: PathBuf, + reason: String, + }, + + /// Path does not exist + #[error("Path does not exist: {0}")] + PathNotFound(PathBuf), + + /// Path is not a directory + #[error("Path is not a directory: {0}")] + NotADirectory(PathBuf), + + /// Watcher is already running + #[error("Watcher is already running")] + AlreadyRunning, + + /// Watcher is not running + #[error("Watcher is not running")] + NotRunning, + + /// Event channel closed + #[error("Event channel closed")] + ChannelClosed, + + /// Internal notify error + #[error("Notify error: {0}")] + NotifyError(#[from] notify::Error), + + /// IO error + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + /// Configuration error + #[error("Configuration error: {0}")] + ConfigError(String), +} diff --git a/crates/fs-watcher/src/event.rs b/crates/fs-watcher/src/event.rs new file mode 100644 index 000000000..df28a8bf6 --- /dev/null +++ b/crates/fs-watcher/src/event.rs @@ -0,0 +1,264 @@ +//! Filesystem event types +//! +//! Storage-agnostic event types that represent raw filesystem changes. +//! These events contain only paths and change kinds - no library IDs, +//! no database references, no routing decisions. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::time::SystemTime; + +/// A filesystem change event +/// +/// This is a normalized, platform-agnostic representation of a filesystem change. +/// Platform-specific handlers translate OS events into these normalized events. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FsEvent { + /// The path affected by this event + pub path: PathBuf, + /// The kind of change + pub kind: FsEventKind, + /// When the event was detected + pub timestamp: SystemTime, + /// Whether the path is a directory (avoids extra fs::metadata calls downstream) + /// Note: For Remove events, this may be None if the path no longer exists + pub is_directory: Option, +} + +impl FsEvent { + /// Create a new filesystem event + pub fn new(path: PathBuf, kind: FsEventKind) -> Self { + Self { + path, + kind, + timestamp: SystemTime::now(), + is_directory: None, + } + } + + /// Create a new filesystem event with directory flag + pub fn new_with_dir_flag(path: PathBuf, kind: FsEventKind, is_directory: bool) -> Self { + Self { + path, + kind, + timestamp: SystemTime::now(), + is_directory: Some(is_directory), + } + } + + /// Create a create event + pub fn create(path: PathBuf) -> Self { + Self::new(path, FsEventKind::Create) + } + + /// Create a create event for a directory + pub fn create_dir(path: PathBuf) -> Self { + Self::new_with_dir_flag(path, FsEventKind::Create, true) + } + + /// Create a create event for a file + pub fn create_file(path: PathBuf) -> Self { + Self::new_with_dir_flag(path, FsEventKind::Create, false) + } + + /// Create a modify event + pub fn modify(path: PathBuf) -> Self { + Self::new(path, FsEventKind::Modify) + } + + /// Create a modify event for a file (directories typically don't get modify events) + pub fn modify_file(path: PathBuf) -> Self { + Self::new_with_dir_flag(path, FsEventKind::Modify, false) + } + + /// Create a remove event + pub fn remove(path: PathBuf) -> Self { + Self::new(path, FsEventKind::Remove) + } + + /// Create a rename event + pub fn rename(from: PathBuf, to: PathBuf) -> Self { + Self { + path: to.clone(), + kind: FsEventKind::Rename { from, to }, + timestamp: SystemTime::now(), + is_directory: None, + } + } + + /// Create a rename event with directory flag + pub fn rename_with_dir_flag(from: PathBuf, to: PathBuf, is_directory: bool) -> Self { + Self { + path: to.clone(), + kind: FsEventKind::Rename { from, to }, + timestamp: SystemTime::now(), + is_directory: Some(is_directory), + } + } + + /// Check if this event is for a directory + pub fn is_dir(&self) -> Option { + self.is_directory + } + + /// Check if this event is for a file + pub fn is_file(&self) -> Option { + self.is_directory.map(|d| !d) + } +} + +/// The kind of filesystem change +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum FsEventKind { + /// A file or directory was created + Create, + /// A file or directory was modified + Modify, + /// A file or directory was removed + Remove, + /// A file or directory was renamed/moved + Rename { + /// The original path + from: PathBuf, + /// The new path + to: PathBuf, + }, +} + +impl FsEventKind { + /// Check if this is a create event + pub fn is_create(&self) -> bool { + matches!(self, Self::Create) + } + + /// Check if this is a modify event + pub fn is_modify(&self) -> bool { + matches!(self, Self::Modify) + } + + /// Check if this is a remove event + pub fn is_remove(&self) -> bool { + matches!(self, Self::Remove) + } + + /// Check if this is a rename event + pub fn is_rename(&self) -> bool { + matches!(self, Self::Rename { .. }) + } +} + +/// Raw event from notify crate before platform processing +#[derive(Debug, Clone)] +pub struct RawNotifyEvent { + /// The kind of event from notify + pub kind: RawEventKind, + /// Paths affected by the event + pub paths: Vec, + /// Timestamp when received + pub timestamp: SystemTime, +} + +/// Raw event kinds from notify +#[derive(Debug, Clone)] +pub enum RawEventKind { + /// Create event + Create, + /// Modify event + Modify, + /// Remove event + Remove, + /// Rename event (platform-specific semantics) + Rename, + /// Other/unknown event type + Other(String), +} + +impl RawNotifyEvent { + /// Create from a notify event + pub fn from_notify(event: notify::Event) -> Self { + use notify::event::{ModifyKind, RenameMode}; + use notify::EventKind; + + let kind = match event.kind { + EventKind::Create(_) => RawEventKind::Create, + EventKind::Modify(ModifyKind::Name(RenameMode::Any)) => RawEventKind::Rename, + EventKind::Modify(ModifyKind::Name(RenameMode::From)) => RawEventKind::Rename, + EventKind::Modify(ModifyKind::Name(RenameMode::To)) => RawEventKind::Rename, + EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => RawEventKind::Rename, + EventKind::Modify(_) => RawEventKind::Modify, + EventKind::Remove(_) => RawEventKind::Remove, + other => RawEventKind::Other(format!("{:?}", other)), + }; + + Self { + kind, + paths: event.paths, + timestamp: SystemTime::now(), + } + } + + /// Get the primary path for this event + pub fn primary_path(&self) -> Option<&PathBuf> { + self.paths.first() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_event_creation() { + let path = PathBuf::from("/test/file.txt"); + + let event = FsEvent::create(path.clone()); + assert!(event.kind.is_create()); + assert_eq!(event.path, path); + assert!(event.is_directory.is_none()); + + let event = FsEvent::modify(path.clone()); + assert!(event.kind.is_modify()); + + let event = FsEvent::remove(path.clone()); + assert!(event.kind.is_remove()); + } + + #[test] + fn test_directory_flag() { + let path = PathBuf::from("/test/dir"); + + let event = FsEvent::create_dir(path.clone()); + assert!(event.kind.is_create()); + assert_eq!(event.is_dir(), Some(true)); + assert_eq!(event.is_file(), Some(false)); + + let event = FsEvent::create_file(path.clone()); + assert!(event.kind.is_create()); + assert_eq!(event.is_dir(), Some(false)); + assert_eq!(event.is_file(), Some(true)); + + // Generic create has no flag + let event = FsEvent::create(path.clone()); + assert!(event.is_dir().is_none()); + } + + #[test] + fn test_rename_event() { + let from = PathBuf::from("/test/old.txt"); + let to = PathBuf::from("/test/new.txt"); + + let event = FsEvent::rename(from.clone(), to.clone()); + assert!(event.kind.is_rename()); + + if let FsEventKind::Rename { from: f, to: t } = &event.kind { + assert_eq!(f, &from); + assert_eq!(t, &to); + } else { + panic!("Expected rename event"); + } + + // Test rename with directory flag + let event = FsEvent::rename_with_dir_flag(from.clone(), to.clone(), true); + assert_eq!(event.is_dir(), Some(true)); + } +} diff --git a/crates/fs-watcher/src/lib.rs b/crates/fs-watcher/src/lib.rs new file mode 100644 index 000000000..c6bebda5b --- /dev/null +++ b/crates/fs-watcher/src/lib.rs @@ -0,0 +1,69 @@ +//! Platform-agnostic filesystem watcher +//! +//! `sd-fs-watcher` provides a clean, storage-agnostic interface for watching +//! filesystem changes. It handles platform-specific quirks (like macOS rename +//! detection) internally and emits normalized events. +//! +//! # Architecture +//! +//! The crate is organized into layers: +//! +//! - **FsWatcher**: Main interface for watching paths and receiving events +//! - **PlatformHandler**: Platform-specific event processing (rename detection, buffering) +//! - **FsEvent/FsEventKind**: Normalized, storage-agnostic event types +//! +//! # Key Features +//! +//! - **Storage Agnostic**: No knowledge of databases, libraries, or UUIDs +//! - **Rename Detection**: Handles macOS FSEvents rename quirks via inode tracking +//! - **Event Filtering**: Built-in filtering for temp files, hidden files, etc. +//! - **Reference Counting**: Multiple watchers on the same path share resources +//! - **Broadcast Events**: Multiple subscribers can receive events concurrently +//! +//! # Example +//! +//! ```ignore +//! use sd_fs_watcher::{FsWatcher, WatchConfig, WatcherConfig}; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! // Create watcher with default config +//! let watcher = FsWatcher::new(WatcherConfig::default()); +//! watcher.start().await?; +//! +//! // Subscribe to events +//! let mut rx = watcher.subscribe(); +//! +//! // Watch a directory recursively +//! let _handle = watcher.watch("/path/to/watch", WatchConfig::recursive()).await?; +//! +//! // Process events +//! while let Ok(event) = rx.recv().await { +//! match event.kind { +//! sd_fs_watcher::FsEventKind::Create => println!("Created: {}", event.path.display()), +//! sd_fs_watcher::FsEventKind::Modify => println!("Modified: {}", event.path.display()), +//! sd_fs_watcher::FsEventKind::Remove => println!("Removed: {}", event.path.display()), +//! sd_fs_watcher::FsEventKind::Rename { from, to } => { +//! println!("Renamed: {} -> {}", from.display(), to.display()) +//! } +//! } +//! } +//! +//! Ok(()) +//! } +//! ``` + +mod config; +mod error; +mod event; +mod platform; +mod watcher; + +pub use config::{EventFilters, WatchConfig, WatcherConfig}; +pub use error::{Result, WatcherError}; +pub use event::{FsEvent, FsEventKind, RawEventKind, RawNotifyEvent}; +pub use platform::{EventHandler, PlatformHandler}; +pub use watcher::{FsWatcher, WatchHandle}; + +// Re-export notify types that users might need +pub use notify::RecursiveMode; diff --git a/crates/fs-watcher/src/platform/linux.rs b/crates/fs-watcher/src/platform/linux.rs new file mode 100644 index 000000000..cd071d601 --- /dev/null +++ b/crates/fs-watcher/src/platform/linux.rs @@ -0,0 +1,160 @@ +//! Linux-specific event handler +//! +//! Linux inotify provides better rename tracking than macOS FSEvents, +//! but still requires some buffering for reliable handling. + +use crate::event::{FsEvent, RawEventKind, RawNotifyEvent}; +use crate::platform::EventHandler; +use crate::Result; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; +use tracing::trace; + +/// Timeout for event stabilization +const STABILIZATION_TIMEOUT_MS: u64 = 100; + +/// Linux event handler +pub struct LinuxHandler { + /// Files pending stabilization + pending_updates: RwLock>, +} + +impl LinuxHandler { + /// Create a new Linux handler + pub fn new() -> Self { + Self { + pending_updates: RwLock::new(HashMap::new()), + } + } + + /// Evict pending updates that have stabilized + async fn evict_updates(&self, timeout: Duration) -> Vec { + let mut events = Vec::new(); + let mut updates = self.pending_updates.write().await; + let mut to_remove = Vec::new(); + + for (path, timestamp) in updates.iter() { + if timestamp.elapsed() > timeout { + to_remove.push(path.clone()); + events.push(FsEvent::modify(path.clone())); + trace!("Evicting update (stabilized): {}", path.display()); + } + } + + for path in to_remove { + updates.remove(&path); + } + + events + } +} + +impl Default for LinuxHandler { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl EventHandler for LinuxHandler { + async fn process(&self, event: RawNotifyEvent) -> Result> { + let Some(path) = event.primary_path().cloned() else { + return Ok(vec![]); + }; + + match event.kind { + RawEventKind::Create => Ok(vec![FsEvent::create(path)]), + RawEventKind::Remove => Ok(vec![FsEvent::remove(path)]), + RawEventKind::Modify => { + // Buffer modifications for stabilization + let mut updates = self.pending_updates.write().await; + updates.insert(path, Instant::now()); + Ok(vec![]) + } + RawEventKind::Rename => { + // inotify provides rename events with both paths + if event.paths.len() >= 2 { + let from = event.paths[0].clone(); + let to = event.paths[1].clone(); + Ok(vec![FsEvent::rename(from, to)]) + } else { + // Incomplete rename, treat as modify + let mut updates = self.pending_updates.write().await; + updates.insert(path, Instant::now()); + Ok(vec![]) + } + } + RawEventKind::Other(ref kind) => { + trace!("Ignoring unknown event kind: {}", kind); + Ok(vec![]) + } + } + } + + async fn tick(&self) -> Result> { + let timeout = Duration::from_millis(STABILIZATION_TIMEOUT_MS); + Ok(self.evict_updates(timeout).await) + } + + async fn reset(&self) { + self.pending_updates.write().await.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_handler_creation() { + let handler = LinuxHandler::new(); + assert!(handler.pending_updates.read().await.is_empty()); + } + + #[tokio::test] + async fn test_create_event() { + let handler = LinuxHandler::new(); + let event = RawNotifyEvent { + kind: RawEventKind::Create, + paths: vec![PathBuf::from("/test/file.txt")], + timestamp: std::time::SystemTime::now(), + }; + + let events = handler.process(event).await.unwrap(); + assert_eq!(events.len(), 1); + assert!(events[0].kind.is_create()); + } + + #[tokio::test] + async fn test_remove_event() { + let handler = LinuxHandler::new(); + let event = RawNotifyEvent { + kind: RawEventKind::Remove, + paths: vec![PathBuf::from("/test/file.txt")], + timestamp: std::time::SystemTime::now(), + }; + + let events = handler.process(event).await.unwrap(); + assert_eq!(events.len(), 1); + assert!(events[0].kind.is_remove()); + } + + #[tokio::test] + async fn test_rename_event() { + let handler = LinuxHandler::new(); + let event = RawNotifyEvent { + kind: RawEventKind::Rename, + paths: vec![ + PathBuf::from("/test/old.txt"), + PathBuf::from("/test/new.txt"), + ], + timestamp: std::time::SystemTime::now(), + }; + + let events = handler.process(event).await.unwrap(); + assert_eq!(events.len(), 1); + assert!(events[0].kind.is_rename()); + } +} diff --git a/crates/fs-watcher/src/platform/macos.rs b/crates/fs-watcher/src/platform/macos.rs new file mode 100644 index 000000000..cc810e17f --- /dev/null +++ b/crates/fs-watcher/src/platform/macos.rs @@ -0,0 +1,420 @@ +//! macOS-specific event handler +//! +//! macOS FSEvents doesn't provide native rename tracking. When a file is renamed, +//! we receive separate create and delete events. This handler implements rename +//! detection by tracking inodes and buffering events. +//! +//! Key features: +//! - Inode-based rename detection +//! - Three-phase event buffering (creates, updates, removes) +//! - Timeout-based eviction for unmatched events +//! - Finder duplicate directory event deduplication +//! - Reincident file tracking for files with rapid successive changes +//! - Immediate emission for directories, buffered emission for files + +use crate::event::{FsEvent, RawEventKind, RawNotifyEvent}; +use crate::platform::EventHandler; +use crate::Result; +use std::collections::HashMap; +use std::os::unix::fs::MetadataExt; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; +use tracing::{debug, trace}; + +/// Timeout for rename detection buffering (matching old path with new path) +const RENAME_TIMEOUT_MS: u64 = 500; + +/// Timeout for file stabilization (avoid processing mid-write files) +const STABILIZATION_TIMEOUT_MS: u64 = 500; + +/// Longer timeout for files with rapid successive changes +const REINCIDENT_TIMEOUT_MS: u64 = 10_000; + +/// macOS event handler with rename detection +pub struct MacOsHandler { + /// Files pending potential rename (by inode) - the "old path" side + /// Key: inode, Value: (path, timestamp) + pending_removes: RwLock>, + + /// Recently created files (by inode) for rename matching - the "new path" side + /// Key: inode, Value: (path, timestamp) + pending_creates: RwLock>, + + /// Files to update after stabilization + /// Key: path, Value: timestamp + pending_updates: RwLock>, + + /// Files with multiple rapid changes - use longer timeout + /// Key: path, Value: first change timestamp + reincident_updates: RwLock>, + + /// Last created directory path - for Finder duplicate event deduplication + last_created_dir: RwLock>, +} + +#[derive(Debug, Clone)] +struct PendingRemove { + path: PathBuf, + #[allow(dead_code)] // Used for debugging, will be useful for enhanced inode tracking + inode: u64, + timestamp: Instant, +} + +#[derive(Debug, Clone)] +struct PendingCreate { + path: PathBuf, + inode: u64, + timestamp: Instant, +} + +impl MacOsHandler { + /// Create a new macOS handler + pub fn new() -> Self { + Self { + pending_removes: RwLock::new(HashMap::new()), + pending_creates: RwLock::new(HashMap::new()), + pending_updates: RwLock::new(HashMap::new()), + reincident_updates: RwLock::new(HashMap::new()), + last_created_dir: RwLock::new(None), + } + } + + /// Get the inode for a path + async fn get_inode(path: &PathBuf) -> Option { + match tokio::fs::metadata(path).await { + Ok(metadata) => Some(metadata.ino()), + Err(_) => None, + } + } + + /// Check if path is a directory + async fn is_directory(path: &PathBuf) -> bool { + tokio::fs::metadata(path) + .await + .map(|m| m.is_dir()) + .unwrap_or(false) + } + + /// Try to match a create event with a pending remove (rename detection) + async fn try_match_rename(&self, path: &PathBuf, inode: u64) -> Option { + let mut removes = self.pending_removes.write().await; + if let Some(pending) = removes.remove(&inode) { + debug!( + "Rename detected: {} -> {} (inode {})", + pending.path.display(), + path.display(), + inode + ); + Some(pending.path) + } else { + None + } + } + + /// Process create events, attempting rename matching + async fn process_create(&self, path: PathBuf) -> Result> { + // Check if this is a directory + if Self::is_directory(&path).await { + // Dedupe Finder's duplicate directory creation events + { + let mut last_dir = self.last_created_dir.write().await; + if let Some(ref last) = *last_dir { + if *last == path { + trace!( + "Ignoring duplicate directory create event: {}", + path.display() + ); + return Ok(vec![]); + } + } + *last_dir = Some(path.clone()); + } + + // Directories emit immediately (no rename detection needed) + debug!( + "Directory created, emitting immediately: {}", + path.display() + ); + return Ok(vec![FsEvent::create_dir(path)]); + } + + // For files, get inode for rename detection + let Some(inode) = Self::get_inode(&path).await else { + // File might have been deleted already + debug!("Could not get inode for created file: {}", path.display()); + return Ok(vec![FsEvent::create(path)]); + }; + + // Check if this matches a pending remove (rename) + if let Some(from_path) = self.try_match_rename(&path, inode).await { + return Ok(vec![FsEvent::rename(from_path, path)]); + } + + // Buffer the create for potential later rename matching + { + let mut creates = self.pending_creates.write().await; + creates.insert( + inode, + PendingCreate { + path: path.clone(), + inode, + timestamp: Instant::now(), + }, + ); + } + + // Don't emit yet - will be emitted on tick if no matching remove comes + Ok(vec![]) + } + + /// Process remove events, buffering for rename detection + async fn process_remove(&self, path: PathBuf) -> Result> { + // Try to get the inode from our pending creates (the file might already be gone) + // If we can't get the inode, emit immediately as a remove + let inode = { + let creates = self.pending_creates.read().await; + creates.values().find(|c| c.path == path).map(|c| c.inode) + }; + + // If we have a matching pending create, this is a rapid create+delete + if let Some(inode) = inode { + let mut creates = self.pending_creates.write().await; + if creates.remove(&inode).is_some() { + debug!( + "Rapid create+delete detected, neutralizing: {}", + path.display() + ); + return Ok(vec![]); + } + } + + // Try to get inode from the filesystem (file might still exist briefly) + if let Some(inode) = Self::get_inode(&path).await { + // Buffer for potential rename matching + let mut removes = self.pending_removes.write().await; + removes.insert( + inode, + PendingRemove { + path: path.clone(), + inode, + timestamp: Instant::now(), + }, + ); + trace!("Buffered remove for rename detection: {}", path.display()); + return Ok(vec![]); + } + + // File is gone and we couldn't get inode - emit remove + Ok(vec![FsEvent::remove(path)]) + } + + /// Process modify events with stabilization buffering + async fn process_modify(&self, path: PathBuf) -> Result> { + let mut updates = self.pending_updates.write().await; + let mut reincident = self.reincident_updates.write().await; + + // Check if this file is already pending - track as reincident + if let Some(old_instant) = updates.insert(path.clone(), Instant::now()) { + // File had a previous pending update - mark as reincident for longer timeout + reincident.entry(path).or_insert(old_instant); + } + + Ok(vec![]) + } + + /// Evict pending creates that have timed out + async fn evict_creates(&self, timeout: Duration) -> Vec { + let mut events = Vec::new(); + let mut creates = self.pending_creates.write().await; + let mut to_remove = Vec::new(); + + for (inode, pending) in creates.iter() { + if pending.timestamp.elapsed() > timeout { + to_remove.push(*inode); + // Files only - directories are emitted immediately in process_create + events.push(FsEvent::create_file(pending.path.clone())); + trace!( + "Evicting create (no matching remove): {}", + pending.path.display() + ); + } + } + + for inode in to_remove { + creates.remove(&inode); + } + + events + } + + /// Evict pending removes that have timed out + async fn evict_removes(&self, timeout: Duration) -> Vec { + let mut events = Vec::new(); + let mut removes = self.pending_removes.write().await; + let mut to_remove = Vec::new(); + + for (inode, pending) in removes.iter() { + if pending.timestamp.elapsed() > timeout { + to_remove.push(*inode); + events.push(FsEvent::remove(pending.path.clone())); + trace!( + "Evicting remove (no matching create): {}", + pending.path.display() + ); + } + } + + for inode in to_remove { + removes.remove(&inode); + } + + events + } + + /// Evict pending updates that have stabilized + async fn evict_updates(&self, timeout: Duration) -> Vec { + let mut events = Vec::new(); + let mut updates = self.pending_updates.write().await; + let mut reincident = self.reincident_updates.write().await; + let reincident_timeout = Duration::from_millis(REINCIDENT_TIMEOUT_MS); + + let mut to_remove = Vec::new(); + + for (path, timestamp) in updates.iter() { + // Check if this is a reincident file (use longer timeout) + let effective_timeout = if reincident.contains_key(path) { + reincident_timeout + } else { + timeout + }; + + if timestamp.elapsed() > effective_timeout { + to_remove.push(path.clone()); + // Emit as Create for files - the responder will detect if it's an update via inode + events.push(FsEvent::create_file(path.clone())); + trace!( + "Evicting update (stabilized after {}ms): {}", + timestamp.elapsed().as_millis(), + path.display() + ); + } + } + + for path in &to_remove { + updates.remove(path); + reincident.remove(path); + } + + events + } +} + +impl Default for MacOsHandler { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl EventHandler for MacOsHandler { + async fn process(&self, event: RawNotifyEvent) -> Result> { + let Some(path) = event.primary_path().cloned() else { + return Ok(vec![]); + }; + + match event.kind { + RawEventKind::Create => self.process_create(path).await, + RawEventKind::Remove => self.process_remove(path).await, + RawEventKind::Modify => self.process_modify(path).await, + RawEventKind::Rename => { + // On macOS, rename events from notify are unreliable + // We handle renames via inode tracking instead + // Treat as modify and let inode tracking handle it + self.process_modify(path).await + } + RawEventKind::Other(ref kind) => { + trace!("Ignoring unknown event kind: {}", kind); + Ok(vec![]) + } + } + } + + async fn tick(&self) -> Result> { + let rename_timeout = Duration::from_millis(RENAME_TIMEOUT_MS); + let stabilization_timeout = Duration::from_millis(STABILIZATION_TIMEOUT_MS); + + let mut events = Vec::new(); + + // Evict in order: updates first, then creates, then removes + // This ensures proper ordering for related events + events.extend(self.evict_updates(stabilization_timeout).await); + events.extend(self.evict_creates(rename_timeout).await); + events.extend(self.evict_removes(rename_timeout).await); + + Ok(events) + } + + async fn reset(&self) { + self.pending_removes.write().await.clear(); + self.pending_creates.write().await.clear(); + self.pending_updates.write().await.clear(); + self.reincident_updates.write().await.clear(); + *self.last_created_dir.write().await = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[tokio::test] + async fn test_handler_creation() { + let handler = MacOsHandler::new(); + // Should start with empty buffers + assert!(handler.pending_removes.read().await.is_empty()); + assert!(handler.pending_creates.read().await.is_empty()); + assert!(handler.pending_updates.read().await.is_empty()); + assert!(handler.reincident_updates.read().await.is_empty()); + assert!(handler.last_created_dir.read().await.is_none()); + } + + #[tokio::test] + async fn test_reset() { + let handler = MacOsHandler::new(); + + // Add some pending data + { + let mut updates = handler.pending_updates.write().await; + updates.insert(PathBuf::from("/test"), Instant::now()); + } + { + let mut last_dir = handler.last_created_dir.write().await; + *last_dir = Some(PathBuf::from("/test/dir")); + } + + // Reset should clear everything + handler.reset().await; + + assert!(handler.pending_updates.read().await.is_empty()); + assert!(handler.last_created_dir.read().await.is_none()); + } + + #[tokio::test] + async fn test_reincident_tracking() { + let handler = MacOsHandler::new(); + let path = PathBuf::from("/test/file.txt"); + + // First modify - should not be reincident + { + let mut updates = handler.pending_updates.write().await; + updates.insert(path.clone(), Instant::now()); + } + assert!(handler.reincident_updates.read().await.is_empty()); + + // Second modify - should mark as reincident + handler.process_modify(path.clone()).await.unwrap(); + assert!(handler.reincident_updates.read().await.contains_key(&path)); + } +} diff --git a/crates/fs-watcher/src/platform/mod.rs b/crates/fs-watcher/src/platform/mod.rs new file mode 100644 index 000000000..6934b6d9a --- /dev/null +++ b/crates/fs-watcher/src/platform/mod.rs @@ -0,0 +1,144 @@ +//! Platform-specific event handlers +//! +//! Each platform has different filesystem event semantics. Platform handlers +//! translate raw OS events into normalized `FsEvent` types. +//! +//! Key responsibilities: +//! - Rename detection (especially on macOS where renames come as separate create/delete events) +//! - Event buffering and debouncing +//! - Platform-specific quirk handling +//! +//! Platform handlers are storage-agnostic - they return raw events without +//! any knowledge of locations, libraries, or databases. + +use crate::event::{FsEvent, RawNotifyEvent}; +use crate::Result; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(target_os = "linux")] +pub use linux::LinuxHandler; +#[cfg(target_os = "macos")] +pub use macos::MacOsHandler; +#[cfg(target_os = "windows")] +pub use windows::WindowsHandler; + +/// Trait for platform-specific event processing +/// +/// Platform handlers receive raw notify events and return normalized `FsEvent` values. +/// They may buffer events internally for rename detection or debouncing. +#[async_trait::async_trait] +pub trait EventHandler: Send + Sync { + /// Process a raw event and return normalized events + /// + /// Platform handlers may: + /// - Buffer events internally (e.g., for rename detection) + /// - Return empty vec if event is still being processed + /// - Return multiple events if buffered events are ready + async fn process(&self, event: RawNotifyEvent) -> Result>; + + /// Periodic tick for evicting buffered events + /// + /// Returns events that have been buffered and are now ready to emit. + /// For example, files that didn't match rename patterns after a timeout. + async fn tick(&self) -> Result>; + + /// Reset internal state (e.g., clear buffers) + async fn reset(&self); +} + +/// Platform handler wrapper that selects the appropriate implementation +pub struct PlatformHandler { + #[cfg(target_os = "macos")] + inner: MacOsHandler, + #[cfg(target_os = "linux")] + inner: LinuxHandler, + #[cfg(target_os = "windows")] + inner: WindowsHandler, + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + inner: DefaultHandler, +} + +impl PlatformHandler { + /// Create a new platform handler for the current platform + pub fn new() -> Self { + Self { + #[cfg(target_os = "macos")] + inner: MacOsHandler::new(), + #[cfg(target_os = "linux")] + inner: LinuxHandler::new(), + #[cfg(target_os = "windows")] + inner: WindowsHandler::new(), + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + inner: DefaultHandler::new(), + } + } + + /// Process a raw event + pub async fn process(&self, event: RawNotifyEvent) -> Result> { + self.inner.process(event).await + } + + /// Periodic tick for buffered event eviction + pub async fn tick(&self) -> Result> { + self.inner.tick().await + } + + /// Reset internal state + pub async fn reset(&self) { + self.inner.reset().await + } +} + +impl Default for PlatformHandler { + fn default() -> Self { + Self::new() + } +} + +/// Default handler for unsupported platforms +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +pub struct DefaultHandler; + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +impl DefaultHandler { + pub fn new() -> Self { + Self + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +#[async_trait::async_trait] +impl EventHandler for DefaultHandler { + async fn process(&self, event: RawNotifyEvent) -> Result> { + use crate::event::RawEventKind; + + let Some(path) = event.primary_path().cloned() else { + return Ok(vec![]); + }; + + let fs_event = match event.kind { + RawEventKind::Create => FsEvent::create(path), + RawEventKind::Modify => FsEvent::modify(path), + RawEventKind::Remove => FsEvent::remove(path), + RawEventKind::Rename => { + // Without platform-specific handling, treat rename as modify + FsEvent::modify(path) + } + RawEventKind::Other(_) => return Ok(vec![]), + }; + + Ok(vec![fs_event]) + } + + async fn tick(&self) -> Result> { + Ok(vec![]) + } + + async fn reset(&self) {} +} diff --git a/crates/fs-watcher/src/platform/windows.rs b/crates/fs-watcher/src/platform/windows.rs new file mode 100644 index 000000000..755b7111b --- /dev/null +++ b/crates/fs-watcher/src/platform/windows.rs @@ -0,0 +1,194 @@ +//! Windows-specific event handler +//! +//! Windows ReadDirectoryChangesW provides reasonable rename tracking, +//! but still benefits from event buffering for stability. + +use crate::event::{FsEvent, RawEventKind, RawNotifyEvent}; +use crate::platform::EventHandler; +use crate::Result; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; +use tracing::trace; + +/// Timeout for event stabilization +const STABILIZATION_TIMEOUT_MS: u64 = 100; + +/// Windows event handler +pub struct WindowsHandler { + /// Files pending stabilization + pending_updates: RwLock>, + + /// Pending rename sources (waiting for target) + pending_rename_from: RwLock>, +} + +impl WindowsHandler { + /// Create a new Windows handler + pub fn new() -> Self { + Self { + pending_updates: RwLock::new(HashMap::new()), + pending_rename_from: RwLock::new(None), + } + } + + /// Evict pending updates that have stabilized + async fn evict_updates(&self, timeout: Duration) -> Vec { + let mut events = Vec::new(); + let mut updates = self.pending_updates.write().await; + let mut to_remove = Vec::new(); + + for (path, timestamp) in updates.iter() { + if timestamp.elapsed() > timeout { + to_remove.push(path.clone()); + events.push(FsEvent::modify(path.clone())); + trace!("Evicting update (stabilized): {}", path.display()); + } + } + + for path in to_remove { + updates.remove(&path); + } + + events + } + + /// Evict pending rename source if timed out + async fn evict_pending_rename(&self, timeout: Duration) -> Vec { + let mut events = Vec::new(); + let mut pending = self.pending_rename_from.write().await; + + if let Some((path, timestamp)) = pending.take() { + if timestamp.elapsed() > timeout { + // Rename source without target - treat as remove + events.push(FsEvent::remove(path)); + } else { + // Put it back + *pending = Some((path, timestamp)); + } + } + + events + } +} + +impl Default for WindowsHandler { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl EventHandler for WindowsHandler { + async fn process(&self, event: RawNotifyEvent) -> Result> { + let Some(path) = event.primary_path().cloned() else { + return Ok(vec![]); + }; + + match event.kind { + RawEventKind::Create => { + // Check if this matches a pending rename + let pending = self.pending_rename_from.write().await.take(); + if let Some((from_path, _)) = pending { + return Ok(vec![FsEvent::rename(from_path, path)]); + } + Ok(vec![FsEvent::create(path)]) + } + RawEventKind::Remove => { + // Buffer as potential rename source + let mut pending = self.pending_rename_from.write().await; + *pending = Some((path, Instant::now())); + Ok(vec![]) + } + RawEventKind::Modify => { + // Buffer modifications for stabilization + let mut updates = self.pending_updates.write().await; + updates.insert(path, Instant::now()); + Ok(vec![]) + } + RawEventKind::Rename => { + // Windows sometimes provides proper rename events + if event.paths.len() >= 2 { + let from = event.paths[0].clone(); + let to = event.paths[1].clone(); + Ok(vec![FsEvent::rename(from, to)]) + } else { + // Incomplete rename, buffer it + let mut pending = self.pending_rename_from.write().await; + *pending = Some((path, Instant::now())); + Ok(vec![]) + } + } + RawEventKind::Other(ref kind) => { + trace!("Ignoring unknown event kind: {}", kind); + Ok(vec![]) + } + } + } + + async fn tick(&self) -> Result> { + let timeout = Duration::from_millis(STABILIZATION_TIMEOUT_MS); + let mut events = Vec::new(); + + events.extend(self.evict_updates(timeout).await); + events.extend(self.evict_pending_rename(timeout).await); + + Ok(events) + } + + async fn reset(&self) { + self.pending_updates.write().await.clear(); + *self.pending_rename_from.write().await = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_handler_creation() { + let handler = WindowsHandler::new(); + assert!(handler.pending_updates.read().await.is_empty()); + assert!(handler.pending_rename_from.read().await.is_none()); + } + + #[tokio::test] + async fn test_create_event() { + let handler = WindowsHandler::new(); + let event = RawNotifyEvent { + kind: RawEventKind::Create, + paths: vec![PathBuf::from("C:\\test\\file.txt")], + timestamp: std::time::SystemTime::now(), + }; + + let events = handler.process(event).await.unwrap(); + assert_eq!(events.len(), 1); + assert!(events[0].kind.is_create()); + } + + #[tokio::test] + async fn test_rename_detection() { + let handler = WindowsHandler::new(); + + // First, a remove event (potential rename source) + let remove_event = RawNotifyEvent { + kind: RawEventKind::Remove, + paths: vec![PathBuf::from("C:\\test\\old.txt")], + timestamp: std::time::SystemTime::now(), + }; + let events = handler.process(remove_event).await.unwrap(); + assert!(events.is_empty()); // Buffered + + // Then, a create event (rename target) + let create_event = RawNotifyEvent { + kind: RawEventKind::Create, + paths: vec![PathBuf::from("C:\\test\\new.txt")], + timestamp: std::time::SystemTime::now(), + }; + let events = handler.process(create_event).await.unwrap(); + assert_eq!(events.len(), 1); + assert!(events[0].kind.is_rename()); + } +} diff --git a/crates/fs-watcher/src/watcher.rs b/crates/fs-watcher/src/watcher.rs new file mode 100644 index 000000000..7815abd4e --- /dev/null +++ b/crates/fs-watcher/src/watcher.rs @@ -0,0 +1,535 @@ +//! Main filesystem watcher implementation +//! +//! `FsWatcher` is the primary interface for watching filesystem changes. +//! It's storage-agnostic - it only knows about paths and events, not +//! about locations, libraries, or databases. + +use crate::config::{WatchConfig, WatcherConfig}; +use crate::error::{Result, WatcherError}; +use crate::event::{FsEvent, RawNotifyEvent}; +use crate::platform::PlatformHandler; +use notify::{RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use tokio::sync::{broadcast, mpsc, RwLock}; +use tracing::{debug, error, info, trace, warn}; + +/// Handle returned when watching a path +/// +/// When dropped, the path is automatically unwatched (if no other handles exist). +pub struct WatchHandle { + path: PathBuf, + watcher: Arc, +} + +impl std::fmt::Debug for WatchHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WatchHandle") + .field("path", &self.path) + .finish() + } +} + +impl Drop for WatchHandle { + fn drop(&mut self) { + // Decrement reference count and unwatch if zero + let path = self.path.clone(); + let inner = self.watcher.clone(); + + // Spawn a task to handle the async unwatch + tokio::spawn(async move { + if let Err(e) = inner.release_watch(&path).await { + warn!("Failed to release watch for {}: {}", path.display(), e); + } + }); + } +} + +/// Watch state for a path +struct WatchState { + config: WatchConfig, + ref_count: usize, +} + +/// Internal watcher state +struct FsWatcherInner { + /// Configuration + config: WatcherConfig, + /// Watched paths with reference counting + watched_paths: RwLock>, + /// The notify watcher instance + notify_watcher: RwLock>, + /// Platform-specific event handler + platform_handler: PlatformHandler, + /// Whether the watcher is running + is_running: AtomicBool, + /// Event sender for broadcasts + event_tx: broadcast::Sender, + /// Metrics + events_received: AtomicU64, + events_emitted: AtomicU64, +} + +impl FsWatcherInner { + /// Add a watch with reference counting + async fn add_watch(&self, path: PathBuf, config: WatchConfig) -> Result<()> { + let mut watched = self.watched_paths.write().await; + + if let Some(state) = watched.get_mut(&path) { + // Path already watched - increment ref count + state.ref_count += 1; + debug!( + "Incremented ref count for {}: {}", + path.display(), + state.ref_count + ); + return Ok(()); + } + + // Validate path exists + if !path.exists() { + return Err(WatcherError::PathNotFound(path)); + } + + // Register with notify if we're running + if self.is_running.load(Ordering::SeqCst) { + if let Some(watcher) = self.notify_watcher.write().await.as_mut() { + let mode = if config.recursive { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }; + + watcher.watch(&path, mode).map_err(|e| WatcherError::WatchFailed { + path: path.clone(), + reason: e.to_string(), + })?; + } + } + + watched.insert( + path.clone(), + WatchState { + config, + ref_count: 1, + }, + ); + + debug!("Started watching: {}", path.display()); + Ok(()) + } + + /// Release a watch (decrement ref count, unwatch if zero) + async fn release_watch(&self, path: &Path) -> Result<()> { + let mut watched = self.watched_paths.write().await; + + let should_unwatch = if let Some(state) = watched.get_mut(path) { + state.ref_count -= 1; + debug!( + "Decremented ref count for {}: {}", + path.display(), + state.ref_count + ); + state.ref_count == 0 + } else { + return Ok(()); // Not watched + }; + + if should_unwatch { + watched.remove(path); + + // Unregister from notify if we're running + if self.is_running.load(Ordering::SeqCst) { + if let Some(watcher) = self.notify_watcher.write().await.as_mut() { + if let Err(e) = watcher.unwatch(path) { + warn!("Failed to unwatch {}: {}", path.display(), e); + } + } + } + + debug!("Stopped watching: {}", path.display()); + } + + Ok(()) + } +} + +/// Platform-agnostic filesystem watcher +/// +/// Watches filesystem paths and emits normalized events. Handles platform-specific +/// quirks like macOS rename detection internally. +/// +/// # Example +/// +/// ```ignore +/// use sd_fs_watcher::{FsWatcher, WatchConfig}; +/// +/// let watcher = FsWatcher::new(Default::default()); +/// watcher.start().await?; +/// +/// // Subscribe to events +/// let mut rx = watcher.subscribe(); +/// +/// // Watch a path +/// let handle = watcher.watch("/path/to/watch", WatchConfig::recursive()).await?; +/// +/// // Receive events +/// while let Ok(event) = rx.recv().await { +/// println!("Event: {:?}", event); +/// } +/// ``` +pub struct FsWatcher { + inner: Arc, +} + +impl FsWatcher { + /// Create a new filesystem watcher + pub fn new(config: WatcherConfig) -> Self { + let (event_tx, _) = broadcast::channel(config.event_buffer_size); + + Self { + inner: Arc::new(FsWatcherInner { + config, + watched_paths: RwLock::new(HashMap::new()), + notify_watcher: RwLock::new(None), + platform_handler: PlatformHandler::new(), + is_running: AtomicBool::new(false), + event_tx, + events_received: AtomicU64::new(0), + events_emitted: AtomicU64::new(0), + }), + } + } + + /// Start the watcher + pub async fn start(&self) -> Result<()> { + if self.inner.is_running.swap(true, Ordering::SeqCst) { + return Err(WatcherError::AlreadyRunning); + } + + info!("Starting filesystem watcher"); + + // Create channel for raw events from notify + let (raw_tx, raw_rx) = mpsc::channel(self.inner.config.event_buffer_size); + + // Create the notify watcher + let raw_tx_clone = raw_tx.clone(); + let inner_clone = self.inner.clone(); + + let watcher = notify::recommended_watcher(move |res: std::result::Result| { + match res { + Ok(event) => { + inner_clone.events_received.fetch_add(1, Ordering::Relaxed); + let raw_event = RawNotifyEvent::from_notify(event); + + if let Err(e) = raw_tx_clone.try_send(raw_event) { + error!("Failed to send raw event: {}", e); + } + } + Err(e) => { + error!("Notify watcher error: {}", e); + } + } + }) + .map_err(|e| WatcherError::StartFailed(e.to_string()))?; + + *self.inner.notify_watcher.write().await = Some(watcher); + + // Register all existing watched paths + self.register_existing_watches().await?; + + // Start the event processing loop + self.start_event_loop(raw_rx).await; + + info!("Filesystem watcher started"); + Ok(()) + } + + /// Stop the watcher + pub async fn stop(&self) -> Result<()> { + if !self.inner.is_running.swap(false, Ordering::SeqCst) { + return Ok(()); // Already stopped + } + + info!("Stopping filesystem watcher"); + + // Clear the notify watcher + *self.inner.notify_watcher.write().await = None; + + // Reset platform handler state + self.inner.platform_handler.reset().await; + + info!("Filesystem watcher stopped"); + Ok(()) + } + + /// Check if the watcher is running + pub fn is_running(&self) -> bool { + self.inner.is_running.load(Ordering::SeqCst) + } + + /// Watch a path + /// + /// Returns a handle that automatically unwatches when dropped. + pub async fn watch(&self, path: impl AsRef, config: WatchConfig) -> Result { + let path = path.as_ref().to_path_buf(); + self.inner.add_watch(path.clone(), config).await?; + + Ok(WatchHandle { + path, + watcher: self.inner.clone(), + }) + } + + /// Watch a path without returning a handle + /// + /// Use this when you want to manually manage watch lifecycle via `unwatch()`. + pub async fn watch_path(&self, path: impl AsRef, config: WatchConfig) -> Result<()> { + let path = path.as_ref().to_path_buf(); + self.inner.add_watch(path, config).await + } + + /// Unwatch a path + pub async fn unwatch(&self, path: impl AsRef) -> Result<()> { + self.inner.release_watch(path.as_ref()).await + } + + /// Get all watched paths + pub async fn watched_paths(&self) -> Vec { + self.inner + .watched_paths + .read() + .await + .keys() + .cloned() + .collect() + } + + /// Subscribe to filesystem events + pub fn subscribe(&self) -> broadcast::Receiver { + self.inner.event_tx.subscribe() + } + + /// Get the number of events received + pub fn events_received(&self) -> u64 { + self.inner.events_received.load(Ordering::Relaxed) + } + + /// Get the number of events emitted + pub fn events_emitted(&self) -> u64 { + self.inner.events_emitted.load(Ordering::Relaxed) + } + + /// Register existing watches with notify + async fn register_existing_watches(&self) -> Result<()> { + let watched = self.inner.watched_paths.read().await; + let mut watcher_guard = self.inner.notify_watcher.write().await; + + if let Some(watcher) = watcher_guard.as_mut() { + for (path, state) in watched.iter() { + let mode = if state.config.recursive { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }; + + if let Err(e) = watcher.watch(path, mode) { + warn!("Failed to register watch for {}: {}", path.display(), e); + } else { + debug!("Registered watch for: {}", path.display()); + } + } + } + + Ok(()) + } + + /// Start the event processing loop + async fn start_event_loop(&self, mut raw_rx: mpsc::Receiver) { + let inner = self.inner.clone(); + let tick_interval = self.inner.config.tick_interval; + + tokio::spawn(async move { + info!("Event processing loop started"); + + loop { + if !inner.is_running.load(Ordering::SeqCst) { + break; + } + + tokio::select! { + // Process incoming raw events + Some(raw_event) = raw_rx.recv() => { + // Check if path should be filtered + let should_process = if let Some(path) = raw_event.primary_path() { + let watched = inner.watched_paths.read().await; + // Find the watch config for this path + let config = watched.iter().find(|(watched_path, _)| { + path.starts_with(watched_path) + }).map(|(_, state)| &state.config); + + if let Some(config) = config { + !config.filters.should_skip(path) + } else { + true // No filter config, process anyway + } + } else { + false + }; + + if should_process { + // Process through platform handler + match inner.platform_handler.process(raw_event).await { + Ok(events) => { + for event in events { + inner.events_emitted.fetch_add(1, Ordering::Relaxed); + if let Err(e) = inner.event_tx.send(event) { + trace!("No event subscribers: {}", e); + } + } + } + Err(e) => { + error!("Error processing event: {}", e); + } + } + } + } + + // Periodic tick for buffered event eviction + _ = tokio::time::sleep(tick_interval) => { + match inner.platform_handler.tick().await { + Ok(events) => { + for event in events { + inner.events_emitted.fetch_add(1, Ordering::Relaxed); + if let Err(e) = inner.event_tx.send(event) { + trace!("No event subscribers: {}", e); + } + } + } + Err(e) => { + error!("Error during tick: {}", e); + } + } + } + } + } + + info!("Event processing loop stopped"); + }); + } +} + +impl Default for FsWatcher { + fn default() -> Self { + Self::new(WatcherConfig::default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use tempfile::TempDir; + + #[tokio::test] + async fn test_watcher_creation() { + let watcher = FsWatcher::new(WatcherConfig::default()); + assert!(!watcher.is_running()); + } + + #[tokio::test] + async fn test_watcher_start_stop() { + let watcher = FsWatcher::new(WatcherConfig::default()); + + watcher.start().await.unwrap(); + assert!(watcher.is_running()); + + watcher.stop().await.unwrap(); + assert!(!watcher.is_running()); + } + + #[tokio::test] + async fn test_watch_path() { + let watcher = FsWatcher::new(WatcherConfig::default()); + watcher.start().await.unwrap(); + + let temp_dir = TempDir::new().unwrap(); + + watcher + .watch_path(temp_dir.path(), WatchConfig::recursive()) + .await + .unwrap(); + + let paths = watcher.watched_paths().await; + assert_eq!(paths.len(), 1); + assert_eq!(paths[0], temp_dir.path()); + + watcher.stop().await.unwrap(); + } + + #[tokio::test] + async fn test_watch_handle_drops() { + let watcher = FsWatcher::new(WatcherConfig::default()); + watcher.start().await.unwrap(); + + let temp_dir = TempDir::new().unwrap(); + + { + let _handle = watcher + .watch(temp_dir.path(), WatchConfig::recursive()) + .await + .unwrap(); + + let paths = watcher.watched_paths().await; + assert_eq!(paths.len(), 1); + } + + // Give time for the async drop to complete + tokio::time::sleep(Duration::from_millis(100)).await; + + let paths = watcher.watched_paths().await; + assert_eq!(paths.len(), 0); + + watcher.stop().await.unwrap(); + } + + #[tokio::test] + async fn test_reference_counting() { + let watcher = FsWatcher::new(WatcherConfig::default()); + watcher.start().await.unwrap(); + + let temp_dir = TempDir::new().unwrap(); + + // Watch the same path twice + let _handle1 = watcher + .watch(temp_dir.path(), WatchConfig::recursive()) + .await + .unwrap(); + + let _handle2 = watcher + .watch(temp_dir.path(), WatchConfig::recursive()) + .await + .unwrap(); + + let paths = watcher.watched_paths().await; + assert_eq!(paths.len(), 1); // Only one path in the map + + drop(_handle1); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Should still be watched (handle2 exists) + let paths = watcher.watched_paths().await; + assert_eq!(paths.len(), 1); + + drop(_handle2); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Now should be unwatched + let paths = watcher.watched_paths().await; + assert_eq!(paths.len(), 0); + + watcher.stop().await.unwrap(); + } +} diff --git a/packages/assets/sounds/index.ts b/packages/assets/sounds/index.ts index 010d94f08..0037663c8 100644 --- a/packages/assets/sounds/index.ts +++ b/packages/assets/sounds/index.ts @@ -2,6 +2,8 @@ import copyOgg from "./copy.ogg"; import copyMp3 from "./copy.mp3"; import startupOgg from "./startup.ogg"; import startupMp3 from "./startup.mp3"; +import pairingOgg from "./pairing.ogg"; +import pairingMp3 from "./pairing.mp3"; /** * Play a sound effect @@ -26,4 +28,5 @@ function playSound(oggSrc: string, mp3Src: string, volume = 0.5) { export const sounds = { copy: () => playSound(copyOgg, copyMp3, 0.3), startup: () => playSound(startupOgg, startupMp3, 0.5), + pairing: () => playSound(pairingOgg, pairingMp3, 0.5), }; diff --git a/packages/assets/sounds/pairing.mp3 b/packages/assets/sounds/pairing.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..c208b2728122f123a54c77f6743e1f699ab01c19 GIT binary patch literal 18875 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 o|KdwQI@Iqg<({ type: "form" }); + const [operation, setOperation] = useState<"copy" | "move">(props.operation); const [conflictResolution, setConflictResolution] = useState("Skip"); const copyFiles = useLibraryMutation("files.copy"); @@ -51,14 +52,14 @@ function FileOperationDialog(props: FileOperationDialogProps) { try { setPhase({ type: "executing" }); - // Execute with the user's chosen conflict resolution + // Execute with the user's chosen operation and conflict resolution await copyFiles.mutateAsync({ sources: { paths: props.sources }, destination: props.destination, overwrite: conflictResolution === "Overwrite", verify_checksum: false, preserve_timestamps: true, - move_files: props.operation === "move", + move_files: operation === "move", copy_method: "Auto", on_conflict: conflictResolution, }); @@ -87,7 +88,7 @@ function FileOperationDialog(props: FileOperationDialogProps) { } hideButtons > @@ -95,7 +96,7 @@ function FileOperationDialog(props: FileOperationDialogProps) {
- {props.operation === "copy" ? "Copying files..." : "Moving files..."} + {operation === "copy" ? "Copying files..." : "Moving files..."}
@@ -127,14 +128,14 @@ function FileOperationDialog(props: FileOperationDialogProps) { ); } - // Form state - let user choose conflict resolution + // Form state - let user choose operation and conflict resolution return ( } - ctaLabel={props.operation === "copy" ? "Copy" : "Move"} + ctaLabel={operation === "copy" ? "Copy" : "Move"} onSubmit={handleSubmit} onCancelled={handleCancel} > @@ -143,9 +144,7 @@ function FileOperationDialog(props: FileOperationDialogProps) {
-
- {props.operation === "copy" ? "Copying to:" : "Moving to:"} -
+
Destination:
{formatDestination(props.destination)}
@@ -155,6 +154,35 @@ function FileOperationDialog(props: FileOperationDialogProps) {
+ {/* Operation type selection */} +
+
Operation:
+
+ + +
+
+ {/* Conflict resolution options */}
diff --git a/packages/interface/src/components/PairingModal.tsx b/packages/interface/src/components/PairingModal.tsx index 6b16099bb..f985998c5 100644 --- a/packages/interface/src/components/PairingModal.tsx +++ b/packages/interface/src/components/PairingModal.tsx @@ -13,6 +13,7 @@ import { motion, AnimatePresence } from "framer-motion"; import clsx from "clsx"; import QRCode from "qrcode"; import { useCoreMutation, useCoreQuery } from "../context"; +import { sounds } from "@sd/assets/sounds"; interface PairingModalProps { isOpen: boolean; @@ -86,6 +87,7 @@ export function PairingModal({ isOpen, onClose, mode: initialMode = "generate" } useEffect(() => { if (isCompleted) { + sounds.pairing(); const timer = setTimeout(() => { handleClose(); }, 2000);