Skip to main content

max / audiofiles

5.8 KB · 160 lines History Blame Raw
1 //! Background worker for loose-files maintenance: integrity check, relocate, and
2 //! purge. These walk the filesystem and hash candidate files, so running them on
3 //! the egui thread froze the UI (ultra-fuzz UX finding). Mirrors `cleanup.rs`:
4 //! a dedicated thread with its own Database, polled each frame via channels.
5
6 use std::path::PathBuf;
7
8 use tracing::instrument;
9
10 use audiofiles_core::db::Database;
11 use audiofiles_core::store::SampleStore;
12 use audiofiles_core::worker_runtime::{WorkerCtx, WorkerHandle, spawn_worker};
13
14 /// Command from the GUI thread to the loose-files worker.
15 pub enum LooseFilesCommand {
16 /// Count loose-files samples whose source file is missing.
17 CheckIntegrity,
18 /// Walk `search_root` and repoint missing samples to matching files.
19 Relocate(PathBuf),
20 /// Remove loose-files samples whose source file is missing.
21 Purge,
22 /// Re-hash every managed blob and report any whose bytes no longer match
23 /// their content address (the CAS scrub). Managed-mode counterpart to
24 /// `CheckIntegrity`, which only checks loose-files source presence.
25 VerifyStore,
26 }
27
28 /// Event from the loose-files worker back to the GUI thread.
29 pub enum LooseFilesEvent {
30 /// Integrity check finished: `missing` source files absent.
31 IntegrityResult { missing: usize },
32 /// Relocate finished.
33 RelocateResult {
34 relocated: usize,
35 still_missing: usize,
36 },
37 /// Purge finished.
38 PurgeResult { purged: usize },
39 /// Store scrub finished: `checked` managed blobs re-hashed, `corrupt` of
40 /// them failed to match their content address.
41 VerifyResult { checked: usize, corrupt: usize },
42 /// An operation failed.
43 Failed { message: String },
44 }
45
46 /// Handle for communicating with the loose-files worker.
47 ///
48 /// Handle for the loose-files worker. No Cancel command, so the runtime handle
49 /// is used directly.
50 ///
51 /// Previously this worker hand-rolled its own recv loop and was the one worker
52 /// that omitted `catch_unwind`, a panic in relocate/purge silently killed the
53 /// thread and dropped all future commands. Routing through [`spawn_worker`]
54 /// makes that drift unrepresentable: the runtime owns the loop and the guard.
55 pub type LooseFilesHandle = WorkerHandle<LooseFilesCommand, LooseFilesEvent>;
56
57 /// Spawn the loose-files worker. It opens its own `Database` to avoid contending
58 /// with the GUI connection.
59 #[instrument(skip_all)]
60 pub fn spawn_loose_files_worker(
61 db_path: PathBuf,
62 store_root: PathBuf,
63 ) -> std::io::Result<LooseFilesHandle> {
64 spawn_worker(
65 "loose-files-worker",
66 move || Database::open(&db_path),
67 |e| LooseFilesEvent::Failed {
68 message: format!("could not open the library database: {e}"),
69 },
70 |_state| LooseFilesEvent::Failed {
71 message: "loose-files maintenance panicked (internal error)".to_string(),
72 },
73 move |db, cmd, ctx| loose_files_step(db, &store_root, cmd, ctx),
74 )
75 }
76
77 fn loose_files_step(
78 db: &mut Database,
79 store_root: &std::path::Path,
80 cmd: LooseFilesCommand,
81 ctx: &WorkerCtx<LooseFilesEvent>,
82 ) {
83 let event = match cmd {
84 LooseFilesCommand::CheckIntegrity => {
85 match audiofiles_core::store::check_loose_files_integrity(db) {
86 Ok((_valid, missing)) => LooseFilesEvent::IntegrityResult { missing },
87 Err(e) => LooseFilesEvent::Failed {
88 message: e.to_string(),
89 },
90 }
91 }
92 LooseFilesCommand::Relocate(root) => {
93 match audiofiles_core::store::relocate_missing_loose_files(db, &root) {
94 Ok((relocated, still_missing)) => LooseFilesEvent::RelocateResult {
95 relocated,
96 still_missing,
97 },
98 Err(e) => LooseFilesEvent::Failed {
99 message: e.to_string(),
100 },
101 }
102 }
103 LooseFilesCommand::Purge => match audiofiles_core::store::purge_missing_loose_files(db) {
104 Ok(purged) => LooseFilesEvent::PurgeResult { purged },
105 Err(e) => LooseFilesEvent::Failed {
106 message: e.to_string(),
107 },
108 },
109 LooseFilesCommand::VerifyStore => {
110 match SampleStore::new(store_root).and_then(|store| store.scrub(db)) {
111 Ok((checked, corrupt)) => LooseFilesEvent::VerifyResult {
112 checked,
113 corrupt: corrupt.len(),
114 },
115 Err(e) => LooseFilesEvent::Failed {
116 message: e.to_string(),
117 },
118 }
119 }
120 };
121 ctx.emit(event);
122 }
123
124 #[cfg(test)]
125 mod tests {
126 use super::*;
127
128 #[test]
129 fn spawn_and_drop_does_not_hang() {
130 let dir = tempfile::TempDir::new().unwrap();
131 let db_path = dir.path().join("audiofiles.db");
132 let _db = Database::open(&db_path).unwrap();
133
134 let handle = spawn_loose_files_worker(db_path, dir.path().join("samples")).unwrap();
135 assert!(handle.try_recv().is_none());
136 drop(handle);
137 }
138
139 #[test]
140 fn integrity_check_reports_result() {
141 let dir = tempfile::TempDir::new().unwrap();
142 let db_path = dir.path().join("audiofiles.db");
143 let _db = Database::open(&db_path).unwrap();
144
145 let handle = spawn_loose_files_worker(db_path, dir.path().join("samples")).unwrap();
146 assert!(handle.send(LooseFilesCommand::CheckIntegrity));
147
148 let mut got = false;
149 for _ in 0..50 {
150 if let Some(LooseFilesEvent::IntegrityResult { missing }) = handle.try_recv() {
151 assert_eq!(missing, 0);
152 got = true;
153 break;
154 }
155 std::thread::sleep(std::time::Duration::from_millis(20));
156 }
157 assert!(got, "expected an IntegrityResult event");
158 }
159 }
160