use crate::State; use crate::event::InstancePayloadType; use crate::event::emit::{emit_instance, emit_minecraft_crash_warning}; use crate::state::{ DirectoryInfo, InstanceInstallStage, ProjectType, attached_world_data, }; use crate::worlds::WorldType; use notify::{RecommendedWatcher, RecursiveMode}; use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer}; use std::{ collections::{HashMap, HashSet}, path::{Path, PathBuf}, sync::{ Arc, atomic::{AtomicU64, Ordering}, }, time::Duration, }; use tokio::sync::{RwLock, mpsc::channel}; use super::adapters::sqlite::instance_rows; use super::config_sync::{CONFIG_FILE_NAME, CONFIG_FILE_TEMP_NAME}; pub struct FileWatcher { watcher: RwLock>, instance_ids: Arc>>, content_changes: Arc>>, manual_import_directory: Arc>>, manual_import_generation: Arc, } #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct InstanceContentWatchSnapshot { pub epoch: u64, pub generation: u64, pub dirty_paths: HashSet, pub directory_dirty: bool, } #[derive(Default)] struct InstanceContentChangeState { epoch: u64, generation: u64, dirty_paths: HashSet, directory_dirty: bool, tracked_paths: HashSet, } static NEXT_CONTENT_EPOCH: AtomicU64 = AtomicU64::new(1); pub async fn init_watcher() -> crate::Result { let (tx, mut rx) = channel(1); let instance_ids = Arc::new(RwLock::new(HashMap::::new())); let event_instance_ids = instance_ids.clone(); let content_changes = Arc::new(RwLock::new(HashMap::< String, InstanceContentChangeState, >::new())); let event_content_changes = content_changes.clone(); let manual_import_directory = Arc::new(RwLock::new(None::)); let event_manual_import_directory = manual_import_directory.clone(); let manual_import_generation = Arc::new(AtomicU64::new(0)); let file_watcher = new_debouncer( Duration::from_secs_f32(1.0), move |res: DebounceEventResult| { tx.blocking_send(res).ok(); }, )?; tokio::task::spawn(async move { let span = tracing::span!(tracing::Level::INFO, "init_watcher"); tracing::info!(parent: &span, "Initing watcher"); while let Some(res) = rx.recv().await { let _span = span.enter(); match res { Ok(events) => { let instance_ids = event_instance_ids.read().await; let manual_import_directory = event_manual_import_directory.read().await.clone(); let mut visited_instances = Vec::new(); let mut scan_manual_downloads = false; for e in &events { let mut instance_path = None; let mut found = false; for component in e.path.components() { if found { instance_path = Some(component.as_os_str()); break; } if component.as_os_str() == crate::state::dirs::INSTANCES_FOLDER_NAME { found = true; } } if let Some(instance_path) = instance_path { let instance_path_str = instance_path.to_string_lossy().to_string(); let Some(instance_id) = instance_ids.get(&instance_path_str).cloned() else { continue; }; let first_file_name = e .path .components() .skip_while(|x| x.as_os_str() != instance_path) .nth(1) .map(|x| x.as_os_str()); let relative_path = e .path .components() .skip_while(|x| x.as_os_str() != instance_path) .skip(1) .map(|component| { component.as_os_str().to_string_lossy() }) .collect::>() .join("/"); if !relative_path.is_empty() { record_upgrade_content_change( &event_content_changes, &instance_id, &relative_path, ) .await; } if first_file_name .is_some_and(is_config_sync_file_name) { continue; } let is_crash_report = first_file_name .as_ref() .is_some_and(|x| *x == "crash-reports") && e.path .extension() .as_ref() .is_some_and(|x| *x == "txt"); let is_jvm_crash = first_file_name.as_ref().is_some_and(|x| { x.to_string_lossy() .starts_with("hs_err_pid") }) && e .path .extension() .as_ref() .is_some_and(|x| *x == "log"); if is_crash_report || is_jvm_crash { crash_task(instance_id); } else if !visited_instances.contains(&instance_id) { let event = if first_file_name .as_ref() .is_some_and(|x| *x == "servers.dat") { Some(InstancePayloadType::ServersUpdated) } else if first_file_name.as_ref().is_some_and( |x| { *x == "saves" && e.path .file_name() .as_ref() .is_some_and(|x| { *x == "level.dat" }) }, ) { tracing::info!( "World updated: {}", e.path.display() ); let world = e .path .parent() .unwrap() .file_name() .unwrap() .to_string_lossy() .to_string(); if !e.path.is_file() { let instance_id = instance_id.clone(); let world = world.clone(); tokio::spawn(async move { if let Ok(state) = State::get().await && let Err(e) = attached_world_data::AttachedWorldData::remove_for_world( &instance_id, WorldType::Singleplayer, &world, &state.pool ).await { tracing::warn!("Failed to remove AttachedWorldData for '{world}': {e}") } }); } Some(InstancePayloadType::WorldUpdated { world, }) } else if first_file_name .as_ref() .is_none_or(|x| *x != "saves") { Some(InstancePayloadType::Synced) } else { None }; if let Some(event) = event { let emit_instance_id = instance_id.clone(); tokio::spawn(async move { let _ = emit_instance( &emit_instance_id, event, ) .await; }); visited_instances.push(instance_id); } } } else if manual_import_directory.as_ref().is_some_and( |directory| e.path.starts_with(directory), ) { scan_manual_downloads = true; } } if scan_manual_downloads && let Some(directory) = manual_import_directory { tokio::spawn(async move { if let Err(error) = crate::api::curseforge::scan_pending_manual_downloads_in( &directory, ) .await { tracing::warn!( "Unable to scan pending manual downloads: {error}" ); } }); } } Err(error) => tracing::warn!("Unable to watch file: {error}"), } } }); Ok(FileWatcher { watcher: RwLock::new(file_watcher), instance_ids, content_changes, manual_import_directory, manual_import_generation, }) } impl FileWatcher { pub(crate) async fn track_upgrade_source( &self, instance_id: &str, paths: impl IntoIterator, ) -> Option { let mut changes = self.content_changes.write().await; let change = changes.get_mut(instance_id)?; change.tracked_paths.extend(paths); Some(change.snapshot()) } pub(crate) async fn content_watch_snapshot( &self, instance_id: &str, ) -> Option { self.content_changes .read() .await .get(instance_id) .map(InstanceContentChangeState::snapshot) } #[cfg(test)] pub(crate) async fn record_upgrade_content_change( &self, instance_id: &str, relative_path: &str, ) { record_upgrade_content_change( &self.content_changes, instance_id, relative_path, ) .await; } pub(crate) async fn configure_manual_import_directory( &self, directory: Option, ) -> crate::Result<()> { let current = self.manual_import_directory.read().await.clone(); if current == directory { return Ok(()); } let mut debouncer = self.watcher.write().await; if let Some(directory) = directory.as_ref() { debouncer .watcher() .watch(directory, RecursiveMode::NonRecursive)?; } if let Some(current) = current.as_ref() { let _ = debouncer.watcher().unwatch(current); } *self.manual_import_directory.write().await = directory; let generation = self .manual_import_generation .fetch_add(1, Ordering::Relaxed) + 1; let Some(directory) = self.manual_import_directory.read().await.clone() else { return Ok(()); }; let active_generation = self.manual_import_generation.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(3)); interval.set_missed_tick_behavior( tokio::time::MissedTickBehavior::Skip, ); loop { interval.tick().await; if active_generation.load(Ordering::Relaxed) != generation { break; } if let Err(error) = crate::api::curseforge::scan_pending_manual_downloads_in( &directory, ) .await { tracing::warn!( "Unable to poll pending manual downloads: {error}" ); } } }); Ok(()) } } pub(crate) async fn watch_instances_init( watcher: &FileWatcher, dirs: &DirectoryInfo, pool: &sqlx::SqlitePool, ) { let Ok(instances) = instance_rows::list_instances(pool).await else { return; }; for instance in instances { watch_instance_folder( &instance.id, &instance.path, &dirs.instance_game_dir(&instance), watcher, ) .await; } } pub(crate) async fn watch_instance_folder( instance_id: &str, instance_path: &str, full_instance_path: &Path, watcher: &FileWatcher, ) { let Ok(metadata) = tokio::fs::metadata(full_instance_path).await else { return; }; if !metadata.is_dir() { return; } let mut to_watch = Vec::new(); for full_path in instance_watch_paths(full_instance_path) { if &full_path == full_instance_path { // The root is watched non-recursively after the subfolders. continue; } let meta = tokio::fs::symlink_metadata(&full_path).await; let exists = meta.is_ok(); let is_symlink = meta.ok().is_some_and(|m| m.file_type().is_symlink()); let sub_path = full_path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); if !exists && !is_symlink && !sub_path.contains('.') && let Err(e) = crate::util::io::create_dir_all(&full_path).await { tracing::error!( "Failed to create directory for watcher {full_path:?}: {e}" ); return; } to_watch.push(full_path); } let mut debouncer = watcher.watcher.write().await; for full_path in &to_watch { if let Err(e) = debouncer .watcher() .watch(full_path, RecursiveMode::Recursive) { tracing::error!( "Failed to watch directory for watcher {full_path:?}: {e}" ); return; } } if let Err(e) = debouncer .watcher() .watch(full_instance_path, RecursiveMode::NonRecursive) { tracing::error!( "Failed to watch root instance directory for watcher {full_instance_path:?}: {e}" ); } watcher .instance_ids .write() .await .insert(instance_path.to_string(), instance_id.to_string()); watcher .content_changes .write() .await .insert(instance_id.to_string(), new_instance_content_change_state()); } /// Stops watching an instance folder and forgets its instance-id mapping. /// /// Used when the instance folder is about to be renamed or replaced. On /// Windows an active watch keeps an open directory handle, which blocks /// renaming the folder with `ERROR_ACCESS_DENIED`; the folder must be /// unwatched first and re-registered afterwards. pub(crate) async fn unwatch_instance_folder( instance_path: &str, full_instance_path: &Path, watcher: &FileWatcher, ) { let mut debouncer = watcher.watcher.write().await; for full_path in instance_watch_paths(full_instance_path) { let _ = debouncer.watcher().unwatch(&full_path); } let instance_id = watcher.instance_ids.write().await.remove(instance_path); if let Some(instance_id) = instance_id { watcher.content_changes.write().await.remove(&instance_id); } } impl InstanceContentChangeState { fn snapshot(&self) -> InstanceContentWatchSnapshot { InstanceContentWatchSnapshot { epoch: self.epoch, generation: self.generation, dirty_paths: self.dirty_paths.clone(), directory_dirty: self.directory_dirty, } } } fn new_instance_content_change_state() -> InstanceContentChangeState { InstanceContentChangeState { epoch: NEXT_CONTENT_EPOCH.fetch_add(1, Ordering::Relaxed), ..InstanceContentChangeState::default() } } fn is_upgrade_content_change( relative_path: &str, tracked_paths: &HashSet, ) -> bool { let normalized = relative_path.replace('\\', "/"); let top_level = normalized.split('/').next().unwrap_or_default(); matches!( top_level, "mods" | "resourcepacks" | "shaderpacks" | "datapacks" ) || tracked_paths.contains(&normalized) } async fn record_upgrade_content_change( content_changes: &RwLock>, instance_id: &str, relative_path: &str, ) { let mut content_changes = content_changes.write().await; if let Some(change) = content_changes.get_mut(instance_id) && is_upgrade_content_change(relative_path, &change.tracked_paths) { change.generation = change.generation.wrapping_add(1); change.dirty_paths.insert(relative_path.replace('\\', "/")); change.directory_dirty = true; } } /// All paths `watch_instance_folder` registers for a single instance, /// including the root, so `unwatch_instance_folder` can release them again. fn instance_watch_paths(full_instance_path: &Path) -> Vec { // `saves` is both a ProjectType folder and part of the crash-report // extras; deduplicate so watch/unwatch stay symmetric (a leftover watch // handle on a subfolder keeps Windows from renaming the instance root). let mut seen = HashSet::new(); let mut paths = Vec::new(); for sub in ProjectType::iterator() .map(|x| x.get_folder()) .chain(["crash-reports", "saves"]) { let full_path = full_instance_path.join(sub); if seen.insert(full_path.clone()) { paths.push(full_path); } } paths.push(full_instance_path.to_path_buf()); paths } fn crash_task(instance_id: String) { tokio::task::spawn(async move { let res = async { let state = State::get().await?; let Some(instance) = instance_rows::get_instance_by_id(&instance_id, &state.pool) .await? else { return Ok(()); }; if instance.install_stage == InstanceInstallStage::Installed { emit_minecraft_crash_warning(&instance_id, &instance.name) .await?; } Ok::<(), crate::Error>(()) } .await; match res { Ok(()) => {} Err(err) => { tracing::warn!("Unable to send crash report to frontend: {err}") } }; }); } fn is_config_sync_file_name(name: &std::ffi::OsStr) -> bool { let name = name.to_string_lossy(); name == CONFIG_FILE_NAME || name == CONFIG_FILE_TEMP_NAME } #[cfg(all(test, windows))] mod tests { use super::*; #[tokio::test] async fn watched_instance_folder_cannot_be_renamed_on_windows() { let temp = tempfile::tempdir().unwrap(); let dirs = DirectoryInfo { settings_dir: temp.path().to_path_buf(), config_dir: temp.path().to_path_buf(), app_identifier: "test".to_string(), }; let watcher = init_watcher().await.unwrap(); let instance_path = "watched-instance"; let full_path = dirs.instances_dir().join(instance_path); std::fs::create_dir_all(&full_path).unwrap(); watch_instance_folder( "instance-1", instance_path, &full_path, &watcher, ) .await; // On Windows, an active watch keeps a directory handle open and blocks // renaming the instance folder (ERROR_ACCESS_DENIED). This is the // failure the symlink import used to hit. let rename_result = std::fs::rename( &full_path, temp.path().join("watched-instance.bak"), ); assert!( rename_result.is_err(), "a watched folder must not be renameable on Windows" ); // The import flow unwatches the folder first; after that the rename // must succeed (the watcher closes its handles asynchronously, so a // short retry window is needed). unwatch_instance_folder(instance_path, &full_path, &watcher).await; let mut renamed = false; for _ in 0..20 { match std::fs::rename( &full_path, temp.path().join("watched-instance.bak"), ) { Ok(()) => { renamed = true; break; } Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { tokio::time::sleep(Duration::from_millis(100)).await; } Err(error) => panic!("unexpected rename error: {error:?}"), } } assert!( renamed, "rename should succeed after the folder is unwatched" ); drop(watcher); } } #[cfg(test)] mod config_file_name_tests { use super::*; use std::ffi::OsStr; #[test] fn recognizes_sync_config_files_but_not_other_instance_events() { assert!(is_config_sync_file_name(OsStr::new("axolotl_config.json"))); assert!(is_config_sync_file_name(OsStr::new( "axolotl_config.json.tmp" ))); assert!(!is_config_sync_file_name(OsStr::new("mods"))); assert!(!is_config_sync_file_name(OsStr::new("servers.dat"))); } #[tokio::test] async fn content_change_tracking_ignores_unrelated_paths() { let watcher = init_watcher().await.unwrap(); watcher.content_changes.write().await.insert( "instance".to_string(), new_instance_content_change_state(), ); watcher .track_upgrade_source( "instance", ["schematics/existing.schem".to_string()], ) .await; watcher .record_upgrade_content_change("instance", "config/options.txt") .await; watcher .record_upgrade_content_change( "instance", "schematics/existing.schem", ) .await; watcher .record_upgrade_content_change("instance", "mods/new.jar") .await; let snapshot = watcher.content_watch_snapshot("instance").await.unwrap(); assert_eq!(snapshot.generation, 2); assert!(snapshot.dirty_paths.contains("mods/new.jar")); assert!(snapshot.dirty_paths.contains("schematics/existing.schem")); assert!(!snapshot.dirty_paths.contains("config/options.txt")); } #[tokio::test] async fn concurrent_content_notifications_do_not_lose_generation() { let watcher = Arc::new(init_watcher().await.unwrap()); watcher.content_changes.write().await.insert( "instance".to_string(), new_instance_content_change_state(), ); let mut tasks = Vec::new(); for index in 0..32 { let watcher = Arc::clone(&watcher); tasks.push(tokio::spawn(async move { watcher .record_upgrade_content_change( "instance", &format!("mods/{index}.jar"), ) .await; })); } for task in tasks { task.await.unwrap(); } let snapshot = watcher.content_watch_snapshot("instance").await.unwrap(); assert_eq!(snapshot.generation, 32); assert_eq!(snapshot.dirty_paths.len(), 32); } #[tokio::test] async fn watcher_reinitialization_changes_content_epoch() { let first = init_watcher().await.unwrap(); let second = init_watcher().await.unwrap(); first.content_changes.write().await.insert( "instance".to_string(), new_instance_content_change_state(), ); second.content_changes.write().await.insert( "instance".to_string(), new_instance_content_change_state(), ); assert_ne!( first .content_watch_snapshot("instance") .await .unwrap() .epoch, second .content_watch_snapshot("instance") .await .unwrap() .epoch ); } }