Files
Starlight_Lancher/packages/app-lib/src/state/instances/watcher.rs
T

745 lines
26 KiB
Rust

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<Debouncer<RecommendedWatcher>>,
instance_ids: Arc<RwLock<HashMap<String, String>>>,
content_changes: Arc<RwLock<HashMap<String, InstanceContentChangeState>>>,
manual_import_directory: Arc<RwLock<Option<PathBuf>>>,
manual_import_generation: Arc<AtomicU64>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct InstanceContentWatchSnapshot {
pub epoch: u64,
pub generation: u64,
pub dirty_paths: HashSet<String>,
pub directory_dirty: bool,
}
#[derive(Default)]
struct InstanceContentChangeState {
epoch: u64,
generation: u64,
dirty_paths: HashSet<String>,
directory_dirty: bool,
tracked_paths: HashSet<String>,
}
static NEXT_CONTENT_EPOCH: AtomicU64 = AtomicU64::new(1);
pub async fn init_watcher() -> crate::Result<FileWatcher> {
let (tx, mut rx) = channel(1);
let instance_ids = Arc::new(RwLock::new(HashMap::<String, String>::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::<PathBuf>));
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::<Vec<_>>()
.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<Item = String>,
) -> Option<InstanceContentWatchSnapshot> {
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<InstanceContentWatchSnapshot> {
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<PathBuf>,
) -> 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<String>,
) -> 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<HashMap<String, InstanceContentChangeState>>,
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<PathBuf> {
// `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
);
}
}