//! Background worker for loose-files maintenance: integrity check, relocate, and //! purge. These walk the filesystem and hash candidate files, so running them on //! the egui thread froze the UI (ultra-fuzz UX finding). Mirrors `cleanup.rs`: //! a dedicated thread with its own Database, polled each frame via channels. use std::path::PathBuf; use tracing::instrument; use audiofiles_core::db::Database; use audiofiles_core::store::SampleStore; use audiofiles_core::worker_runtime::{WorkerCtx, WorkerHandle, spawn_worker}; /// Command from the GUI thread to the loose-files worker. pub enum LooseFilesCommand { /// Count loose-files samples whose source file is missing. CheckIntegrity, /// Walk `search_root` and repoint missing samples to matching files. Relocate(PathBuf), /// Remove loose-files samples whose source file is missing. Purge, /// Re-hash every managed blob and report any whose bytes no longer match /// their content address (the CAS scrub). Managed-mode counterpart to /// `CheckIntegrity`, which only checks loose-files source presence. VerifyStore, } /// Event from the loose-files worker back to the GUI thread. pub enum LooseFilesEvent { /// Integrity check finished: `missing` source files absent. IntegrityResult { missing: usize }, /// Relocate finished. RelocateResult { relocated: usize, still_missing: usize, }, /// Purge finished. PurgeResult { purged: usize }, /// Store scrub finished: `checked` managed blobs re-hashed, `corrupt` of /// them failed to match their content address. VerifyResult { checked: usize, corrupt: usize }, /// An operation failed. Failed { message: String }, } /// Handle for communicating with the loose-files worker. /// /// Handle for the loose-files worker. No Cancel command, so the runtime handle /// is used directly. /// /// Previously this worker hand-rolled its own recv loop and was the one worker /// that omitted `catch_unwind`, a panic in relocate/purge silently killed the /// thread and dropped all future commands. Routing through [`spawn_worker`] /// makes that drift unrepresentable: the runtime owns the loop and the guard. pub type LooseFilesHandle = WorkerHandle; /// Spawn the loose-files worker. It opens its own `Database` to avoid contending /// with the GUI connection. #[instrument(skip_all)] pub fn spawn_loose_files_worker( db_path: PathBuf, store_root: PathBuf, ) -> std::io::Result { spawn_worker( "loose-files-worker", move || Database::open(&db_path), |e| LooseFilesEvent::Failed { message: format!("could not open the library database: {e}"), }, |_state| LooseFilesEvent::Failed { message: "loose-files maintenance panicked (internal error)".to_string(), }, move |db, cmd, ctx| loose_files_step(db, &store_root, cmd, ctx), ) } fn loose_files_step( db: &mut Database, store_root: &std::path::Path, cmd: LooseFilesCommand, ctx: &WorkerCtx, ) { let event = match cmd { LooseFilesCommand::CheckIntegrity => { match audiofiles_core::store::check_loose_files_integrity(db) { Ok((_valid, missing)) => LooseFilesEvent::IntegrityResult { missing }, Err(e) => LooseFilesEvent::Failed { message: e.to_string(), }, } } LooseFilesCommand::Relocate(root) => { match audiofiles_core::store::relocate_missing_loose_files(db, &root) { Ok((relocated, still_missing)) => LooseFilesEvent::RelocateResult { relocated, still_missing, }, Err(e) => LooseFilesEvent::Failed { message: e.to_string(), }, } } LooseFilesCommand::Purge => match audiofiles_core::store::purge_missing_loose_files(db) { Ok(purged) => LooseFilesEvent::PurgeResult { purged }, Err(e) => LooseFilesEvent::Failed { message: e.to_string(), }, }, LooseFilesCommand::VerifyStore => { match SampleStore::new(store_root).and_then(|store| store.scrub(db)) { Ok((checked, corrupt)) => LooseFilesEvent::VerifyResult { checked, corrupt: corrupt.len(), }, Err(e) => LooseFilesEvent::Failed { message: e.to_string(), }, } } }; ctx.emit(event); } #[cfg(test)] mod tests { use super::*; #[test] fn spawn_and_drop_does_not_hang() { let dir = tempfile::TempDir::new().unwrap(); let db_path = dir.path().join("audiofiles.db"); let _db = Database::open(&db_path).unwrap(); let handle = spawn_loose_files_worker(db_path, dir.path().join("samples")).unwrap(); assert!(handle.try_recv().is_none()); drop(handle); } #[test] fn integrity_check_reports_result() { let dir = tempfile::TempDir::new().unwrap(); let db_path = dir.path().join("audiofiles.db"); let _db = Database::open(&db_path).unwrap(); let handle = spawn_loose_files_worker(db_path, dir.path().join("samples")).unwrap(); assert!(handle.send(LooseFilesCommand::CheckIntegrity)); let mut got = false; for _ in 0..50 { if let Some(LooseFilesEvent::IntegrityResult { missing }) = handle.try_recv() { assert_eq!(missing, 0); got = true; break; } std::thread::sleep(std::time::Duration::from_millis(20)); } assert!(got, "expected an IntegrityResult event"); } }