max / audiofiles
9 files changed,
+870 insertions,
-896 deletions
| @@ -11,11 +11,10 @@ | |||
| 11 | 11 | //! The indexes are cached across queries and dropped on [`SearchCommand::Invalidate`] | |
| 12 | 12 | //! (sent when new analysis lands), so they rebuild from fresh data on the next query. | |
| 13 | 13 | ||
| 14 | - | use std::path::{Path, PathBuf}; | |
| 15 | - | use std::sync::{mpsc, Mutex}; | |
| 16 | - | use std::thread; | |
| 14 | + | use std::path::PathBuf; | |
| 17 | 15 | ||
| 18 | 16 | use audiofiles_core::db::Database; | |
| 17 | + | use audiofiles_core::worker_runtime::{spawn_worker, WorkerCtx, WorkerHandle}; | |
| 19 | 18 | use audiofiles_core::{fingerprint, similarity}; | |
| 20 | 19 | ||
| 21 | 20 | /// Command sent from the GUI thread to the search worker. | |
| @@ -26,8 +25,6 @@ pub enum SearchCommand { | |||
| 26 | 25 | FindNearDuplicates { hash: String, limit: usize }, | |
| 27 | 26 | /// Drop both cached indexes so the next query rebuilds from fresh data. | |
| 28 | 27 | Invalidate, | |
| 29 | - | /// Shut the worker thread down. | |
| 30 | - | Shutdown, | |
| 31 | 28 | } | |
| 32 | 29 | ||
| 33 | 30 | /// Event sent from the search worker back to the GUI thread. | |
| @@ -40,151 +37,87 @@ pub enum SearchEvent { | |||
| 40 | 37 | Error { error: String }, | |
| 41 | 38 | } | |
| 42 | 39 | ||
| 43 | - | /// Handle for communicating with the background search worker. | |
| 44 | - | pub struct SearchWorkerHandle { | |
| 45 | - | cmd_tx: mpsc::Sender<SearchCommand>, | |
| 46 | - | event_rx: Mutex<mpsc::Receiver<SearchEvent>>, | |
| 47 | - | thread: Option<thread::JoinHandle<()>>, | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | impl SearchWorkerHandle { | |
| 51 | - | /// Poll for the next event without blocking. | |
| 52 | - | pub fn try_recv(&self) -> Option<SearchEvent> { | |
| 53 | - | self.event_rx.lock().ok()?.try_recv().ok() | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | /// Send a command to the worker. | |
| 57 | - | pub fn send(&self, cmd: SearchCommand) { | |
| 58 | - | let _ = self.cmd_tx.send(cmd); | |
| 59 | - | } | |
| 60 | - | } | |
| 40 | + | /// Handle for communicating with the background search worker. Search has no | |
| 41 | + | /// Cancel command, so the runtime handle is used directly. | |
| 42 | + | pub type SearchWorkerHandle = WorkerHandle<SearchCommand, SearchEvent>; | |
| 61 | 43 | ||
| 62 | - | impl Drop for SearchWorkerHandle { | |
| 63 | - | fn drop(&mut self) { | |
| 64 | - | let _ = self.cmd_tx.send(SearchCommand::Shutdown); | |
| 65 | - | if let Some(handle) = self.thread.take() { | |
| 66 | - | let _ = handle.join(); | |
| 67 | - | } | |
| 68 | - | } | |
| 44 | + | /// Persistent worker state: the read-only DB connection and the two cached | |
| 45 | + | /// VP-tree indexes, reused across queries and dropped on `Invalidate`. | |
| 46 | + | struct SearchState { | |
| 47 | + | db: Database, | |
| 48 | + | similarity_index: Option<similarity::SimilarityIndex>, | |
| 49 | + | fingerprint_index: Option<fingerprint::FingerprintIndex>, | |
| 69 | 50 | } | |
| 70 | 51 | ||
| 71 | - | /// Spawn the background search worker. It opens its own connection to the | |
| 72 | - | /// database at `db_path` on first use. | |
| 52 | + | /// Spawn the background search worker. It opens its own read-only connection to | |
| 53 | + | /// the database at `db_path` on the worker thread. | |
| 73 | 54 | pub fn spawn_search_worker(db_path: PathBuf) -> std::io::Result<SearchWorkerHandle> { | |
| 74 | - | let (cmd_tx, cmd_rx) = mpsc::channel::<SearchCommand>(); | |
| 75 | - | let (event_tx, event_rx) = mpsc::channel::<SearchEvent>(); | |
| 76 | - | ||
| 77 | - | let thread = thread::Builder::new() | |
| 78 | - | .name("search-worker".to_string()) | |
| 79 | - | .spawn(move || search_worker_loop(db_path, cmd_rx, event_tx))?; | |
| 80 | - | ||
| 81 | - | Ok(SearchWorkerHandle { | |
| 82 | - | cmd_tx, | |
| 83 | - | event_rx: Mutex::new(event_rx), | |
| 84 | - | thread: Some(thread), | |
| 85 | - | }) | |
| 55 | + | spawn_worker( | |
| 56 | + | "search-worker", | |
| 57 | + | move || Database::open(&db_path).map(|db| SearchState { | |
| 58 | + | db, | |
| 59 | + | similarity_index: None, | |
| 60 | + | fingerprint_index: None, | |
| 61 | + | }), | |
| 62 | + | |e| SearchEvent::Error { error: e.to_string() }, | |
| 63 | + | |_state| SearchEvent::Error { | |
| 64 | + | error: "search worker panicked (internal error)".to_string(), | |
| 65 | + | }, | |
| 66 | + | search_step, | |
| 67 | + | ) | |
| 86 | 68 | } | |
| 87 | 69 | ||
| 88 | - | fn search_worker_loop( | |
| 89 | - | db_path: PathBuf, | |
| 90 | - | cmd_rx: mpsc::Receiver<SearchCommand>, | |
| 91 | - | event_tx: mpsc::Sender<SearchEvent>, | |
| 92 | - | ) { | |
| 93 | - | let mut similarity_index: Option<similarity::SimilarityIndex> = None; | |
| 94 | - | let mut fingerprint_index: Option<fingerprint::FingerprintIndex> = None; | |
| 95 | - | // Opened lazily on the first query and reused. | |
| 96 | - | let mut db: Option<Database> = None; | |
| 97 | - | ||
| 98 | - | while let Ok(cmd) = cmd_rx.recv() { | |
| 99 | - | match cmd { | |
| 100 | - | SearchCommand::Shutdown => break, | |
| 101 | - | SearchCommand::Invalidate => { | |
| 102 | - | similarity_index = None; | |
| 103 | - | fingerprint_index = None; | |
| 104 | - | } | |
| 105 | - | SearchCommand::FindSimilar { hash, limit } => { | |
| 106 | - | let database = match ensure_db(&mut db, &db_path) { | |
| 107 | - | Ok(d) => d, | |
| 108 | - | Err(e) => { | |
| 109 | - | let _ = event_tx.send(SearchEvent::Error { error: e }); | |
| 110 | - | continue; | |
| 111 | - | } | |
| 112 | - | }; | |
| 113 | - | // Isolate panics: a malformed feature row must report an error, | |
| 114 | - | // not unwind the worker and silently stop all future searches. | |
| 115 | - | let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 116 | - | if similarity_index.is_none() { | |
| 117 | - | let data = similarity::SimilarityIndex::load_data(database)?; | |
| 118 | - | similarity_index = Some(similarity::SimilarityIndex::build_from_data(data)); | |
| 119 | - | } | |
| 120 | - | let built = similarity_index.as_ref().expect("set above"); | |
| 121 | - | let features = similarity::load_features(database, &hash)?; | |
| 122 | - | let results = built.find_similar(&hash, &features, limit); | |
| 123 | - | Ok::<_, audiofiles_core::error::CoreError>( | |
| 124 | - | results.into_iter().map(|r| r.hash).collect::<Vec<String>>(), | |
| 125 | - | ) | |
| 126 | - | })); | |
| 127 | - | let event = match outcome { | |
| 128 | - | Ok(Ok(hashes)) => SearchEvent::SimilarResults { | |
| 129 | - | source: hash, | |
| 130 | - | hashes, | |
| 131 | - | }, | |
| 132 | - | Ok(Err(e)) => SearchEvent::Error { | |
| 133 | - | error: e.to_string(), | |
| 134 | - | }, | |
| 135 | - | Err(_) => SearchEvent::Error { | |
| 136 | - | error: "similarity search panicked (internal error)".to_string(), | |
| 137 | - | }, | |
| 138 | - | }; | |
| 139 | - | let _ = event_tx.send(event); | |
| 140 | - | } | |
| 141 | - | SearchCommand::FindNearDuplicates { hash, limit } => { | |
| 142 | - | let database = match ensure_db(&mut db, &db_path) { | |
| 143 | - | Ok(d) => d, | |
| 144 | - | Err(e) => { | |
| 145 | - | let _ = event_tx.send(SearchEvent::Error { error: e }); | |
| 146 | - | continue; | |
| 147 | - | } | |
| 148 | - | }; | |
| 149 | - | let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 150 | - | if fingerprint_index.is_none() { | |
| 151 | - | let entries = fingerprint::FingerprintIndex::load_data(database)?; | |
| 152 | - | fingerprint_index = | |
| 153 | - | Some(fingerprint::FingerprintIndex::build_from_data(entries)); | |
| 154 | - | } | |
| 155 | - | let built = fingerprint_index.as_ref().expect("set above"); | |
| 156 | - | let reference = fingerprint::load_fingerprint(database, &hash)?; | |
| 157 | - | let results = built.find_near_duplicates(&hash, &reference.envelope, limit); | |
| 158 | - | Ok::<_, audiofiles_core::error::CoreError>( | |
| 159 | - | results.into_iter().map(|r| r.hash).collect::<Vec<String>>(), | |
| 160 | - | ) | |
| 161 | - | })); | |
| 162 | - | let event = match outcome { | |
| 163 | - | Ok(Ok(hashes)) => SearchEvent::NearDuplicateResults { | |
| 164 | - | source: hash, | |
| 165 | - | hashes, | |
| 166 | - | }, | |
| 167 | - | Ok(Err(e)) => SearchEvent::Error { | |
| 168 | - | error: e.to_string(), | |
| 169 | - | }, | |
| 170 | - | Err(_) => SearchEvent::Error { | |
| 171 | - | error: "duplicate search panicked (internal error)".to_string(), | |
| 172 | - | }, | |
| 173 | - | }; | |
| 174 | - | let _ = event_tx.send(event); | |
| 175 | - | } | |
| 70 | + | fn search_step(state: &mut SearchState, cmd: SearchCommand, ctx: &WorkerCtx<SearchEvent>) { | |
| 71 | + | match cmd { | |
| 72 | + | SearchCommand::Invalidate => { | |
| 73 | + | state.similarity_index = None; | |
| 74 | + | state.fingerprint_index = None; | |
| 75 | + | } | |
| 76 | + | SearchCommand::FindSimilar { hash, limit } => { | |
| 77 | + | // A malformed feature row that panics is caught by the runtime and | |
| 78 | + | // reported as Error; the worker survives for the next query. | |
| 79 | + | let result: Result<Vec<String>, audiofiles_core::error::CoreError> = (|| { | |
| 80 | + | if state.similarity_index.is_none() { | |
| 81 | + | let data = similarity::SimilarityIndex::load_data(&state.db)?; | |
| 82 | + | state.similarity_index = | |
| 83 | + | Some(similarity::SimilarityIndex::build_from_data(data)); | |
| 84 | + | } | |
| 85 | + | let built = state.similarity_index.as_ref().expect("set above"); | |
| 86 | + | let features = similarity::load_features(&state.db, &hash)?; | |
| 87 | + | Ok(built | |
| 88 | + | .find_similar(&hash, &features, limit) | |
| 89 | + | .into_iter() | |
| 90 | + | .map(|r| r.hash) | |
| 91 | + | .collect()) | |
| 92 | + | })(); | |
| 93 | + | ctx.emit(match result { | |
| 94 | + | Ok(hashes) => SearchEvent::SimilarResults { source: hash, hashes }, | |
| 95 | + | Err(e) => SearchEvent::Error { error: e.to_string() }, | |
| 96 | + | }); | |
| 97 | + | } | |
| 98 | + | SearchCommand::FindNearDuplicates { hash, limit } => { | |
| 99 | + | let result: Result<Vec<String>, audiofiles_core::error::CoreError> = (|| { | |
| 100 | + | if state.fingerprint_index.is_none() { | |
| 101 | + | let entries = fingerprint::FingerprintIndex::load_data(&state.db)?; | |
| 102 | + | state.fingerprint_index = | |
| 103 | + | Some(fingerprint::FingerprintIndex::build_from_data(entries)); | |
| 104 | + | } | |
| 105 | + | let built = state.fingerprint_index.as_ref().expect("set above"); | |
| 106 | + | let reference = fingerprint::load_fingerprint(&state.db, &hash)?; | |
| 107 | + | Ok(built | |
| 108 | + | .find_near_duplicates(&hash, &reference.envelope, limit) | |
| 109 | + | .into_iter() | |
| 110 | + | .map(|r| r.hash) | |
| 111 | + | .collect()) | |
| 112 | + | })(); | |
| 113 | + | ctx.emit(match result { | |
| 114 | + | Ok(hashes) => SearchEvent::NearDuplicateResults { source: hash, hashes }, | |
| 115 | + | Err(e) => SearchEvent::Error { error: e.to_string() }, | |
| 116 | + | }); | |
| 176 | 117 | } | |
| 177 | 118 | } | |
| 178 | 119 | } | |
| 179 | 120 | ||
| 180 | - | /// Open the worker's own database connection on first use, then reuse it. | |
| 181 | - | fn ensure_db<'a>(db: &'a mut Option<Database>, db_path: &Path) -> Result<&'a Database, String> { | |
| 182 | - | if db.is_none() { | |
| 183 | - | *db = Some(Database::open(db_path).map_err(|e| e.to_string())?); | |
| 184 | - | } | |
| 185 | - | Ok(db.as_ref().expect("opened above")) | |
| 186 | - | } | |
| 187 | - | ||
| 188 | 121 | #[cfg(test)] | |
| 189 | 122 | mod tests { | |
| 190 | 123 | use super::*; |
| @@ -4,13 +4,12 @@ | |||
| 4 | 4 | //! communicating via channels. The GUI thread polls events each frame. | |
| 5 | 5 | ||
| 6 | 6 | use std::path::PathBuf; | |
| 7 | - | use std::sync::{mpsc, Mutex}; | |
| 8 | - | use std::thread; | |
| 9 | 7 | ||
| 10 | - | use tracing::{error, instrument}; | |
| 8 | + | use tracing::instrument; | |
| 11 | 9 | ||
| 12 | 10 | use audiofiles_core::export::{ExportConfig, ExportItem, ExportSummary}; | |
| 13 | 11 | use audiofiles_core::store::SampleStore; | |
| 12 | + | use audiofiles_core::worker_runtime::{spawn_worker, WorkerCtx, WorkerHandle}; | |
| 14 | 13 | ||
| 15 | 14 | /// Command sent from the GUI thread to the export worker. | |
| 16 | 15 | pub enum ExportCommand { | |
| @@ -19,10 +18,8 @@ pub enum ExportCommand { | |||
| 19 | 18 | items: Vec<ExportItem>, | |
| 20 | 19 | config: ExportConfig, | |
| 21 | 20 | }, | |
| 22 | - | /// Cancel the current export. | |
| 21 | + | /// Cancel the current export (sets the worker's cancel flag synchronously). | |
| 23 | 22 | Cancel, | |
| 24 | - | /// Shut down the worker thread. | |
| 25 | - | Shutdown, | |
| 26 | 23 | } | |
| 27 | 24 | ||
| 28 | 25 | /// Event sent from the export worker back to the GUI thread. | |
| @@ -42,32 +39,22 @@ pub enum ExportEvent { | |||
| 42 | 39 | ||
| 43 | 40 | /// Handle for communicating with the background export worker. | |
| 44 | 41 | /// | |
| 45 | - | /// The receiver is wrapped in a `Mutex` so `BrowserState` remains `Sync` (required by nih-plug). | |
| 46 | - | /// Only the GUI thread actually calls `try_recv`, so contention is zero. | |
| 47 | - | pub struct ExportHandle { | |
| 48 | - | cmd_tx: mpsc::Sender<ExportCommand>, | |
| 49 | - | event_rx: Mutex<mpsc::Receiver<ExportEvent>>, | |
| 50 | - | _thread: Option<thread::JoinHandle<()>>, | |
| 51 | - | } | |
| 42 | + | /// Thin newtype over the generic [`WorkerHandle`]: `send` maps a `Cancel` | |
| 43 | + | /// command to a synchronous cancel-flag set, checked between files. | |
| 44 | + | pub struct ExportHandle(WorkerHandle<ExportCommand, ExportEvent>); | |
| 52 | 45 | ||
| 53 | 46 | impl ExportHandle { | |
| 54 | 47 | /// Poll for the next event without blocking. | |
| 55 | 48 | pub fn try_recv(&self) -> Option<ExportEvent> { | |
| 56 | - | self.event_rx.lock().ok()?.try_recv().ok() | |
| 49 | + | self.0.try_recv() | |
| 57 | 50 | } | |
| 58 | 51 | ||
| 59 | 52 | /// Send a command to the worker. | |
| 60 | 53 | pub fn send(&self, cmd: ExportCommand) { | |
| 61 | - | let _ = self.cmd_tx.send(cmd); | |
| 62 | - | } | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | impl Drop for ExportHandle { | |
| 66 | - | fn drop(&mut self) { | |
| 67 | - | let _ = self.cmd_tx.send(ExportCommand::Shutdown); | |
| 68 | - | if let Some(handle) = self._thread.take() { | |
| 69 | - | let _ = handle.join(); | |
| 54 | + | if matches!(cmd, ExportCommand::Cancel) { | |
| 55 | + | self.0.request_cancel(); | |
| 70 | 56 | } | |
| 57 | + | self.0.send(cmd); | |
| 71 | 58 | } | |
| 72 | 59 | } | |
| 73 | 60 | ||
| @@ -76,94 +63,59 @@ impl Drop for ExportHandle { | |||
| 76 | 63 | /// The worker opens its own `SampleStore` to avoid Mutex contention with the GUI thread. | |
| 77 | 64 | #[instrument(skip_all)] | |
| 78 | 65 | pub fn spawn_export_worker(store_root: PathBuf) -> std::io::Result<ExportHandle> { | |
| 79 | - | let (cmd_tx, cmd_rx) = mpsc::channel::<ExportCommand>(); | |
| 80 | - | let (event_tx, event_rx) = mpsc::channel::<ExportEvent>(); | |
| 81 | - | ||
| 82 | - | let thread = thread::Builder::new() | |
| 83 | - | .name("export-worker".to_string()) | |
| 84 | - | .spawn(move || { | |
| 85 | - | worker_loop(cmd_rx, event_tx, &store_root); | |
| 86 | - | })?; | |
| 87 | - | ||
| 88 | - | Ok(ExportHandle { | |
| 89 | - | cmd_tx, | |
| 90 | - | event_rx: Mutex::new(event_rx), | |
| 91 | - | _thread: Some(thread), | |
| 92 | - | }) | |
| 66 | + | let handle = spawn_worker( | |
| 67 | + | "export-worker", | |
| 68 | + | move || SampleStore::new(&store_root), | |
| 69 | + | |e| ExportEvent::Complete { | |
| 70 | + | total: 0, | |
| 71 | + | errors: vec![("init".to_string(), e.to_string())], | |
| 72 | + | }, | |
| 73 | + | |_state| ExportEvent::Complete { | |
| 74 | + | total: 0, | |
| 75 | + | errors: vec![( | |
| 76 | + | "export".to_string(), | |
| 77 | + | "export panicked (internal error)".to_string(), | |
| 78 | + | )], | |
| 79 | + | }, | |
| 80 | + | export_step, | |
| 81 | + | )?; | |
| 82 | + | Ok(ExportHandle(handle)) | |
| 93 | 83 | } | |
| 94 | 84 | ||
| 95 | - | #[instrument(skip_all)] | |
| 96 | - | fn worker_loop( | |
| 97 | - | cmd_rx: mpsc::Receiver<ExportCommand>, | |
| 98 | - | event_tx: mpsc::Sender<ExportEvent>, | |
| 99 | - | store_root: &std::path::Path, | |
| 100 | - | ) { | |
| 101 | - | let store = match SampleStore::new(store_root) { | |
| 102 | - | Ok(s) => s, | |
| 103 | - | Err(e) => { | |
| 104 | - | let _ = event_tx.send(ExportEvent::Complete { | |
| 105 | - | total: 0, | |
| 106 | - | errors: vec![("init".to_string(), e.to_string())], | |
| 107 | - | }); | |
| 108 | - | error!("Export worker failed to open store: {e}"); | |
| 109 | - | return; | |
| 110 | - | } | |
| 85 | + | fn export_step(store: &mut SampleStore, cmd: ExportCommand, ctx: &WorkerCtx<ExportEvent>) { | |
| 86 | + | let ExportCommand::Export { items, config } = cmd else { | |
| 87 | + | // Cancel: the flag was already set synchronously by the handle. | |
| 88 | + | return; | |
| 111 | 89 | }; | |
| 112 | 90 | ||
| 113 | - | while let Ok(cmd) = cmd_rx.recv() { | |
| 114 | - | match cmd { | |
| 115 | - | ExportCommand::Shutdown => break, | |
| 116 | - | ExportCommand::Cancel => continue, | |
| 117 | - | ExportCommand::Export { items, config } => { | |
| 118 | - | let cancelled = std::sync::atomic::AtomicBool::new(false); | |
| 119 | - | ||
| 120 | - | // Catch panics so a bad file reports an error instead of unwinding | |
| 121 | - | // the worker thread — that would wedge the UI in the exporting | |
| 122 | - | // state, since ExportEvent::Complete is the only exit. | |
| 123 | - | let summary = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 124 | - | audiofiles_core::export::run_export( | |
| 125 | - | &items, | |
| 126 | - | &config, | |
| 127 | - | &store, | |
| 128 | - | |completed, total, current_name| { | |
| 129 | - | // Check for cancel between files | |
| 130 | - | if let Ok(ExportCommand::Cancel) | Ok(ExportCommand::Shutdown) = | |
| 131 | - | cmd_rx.try_recv() | |
| 132 | - | { | |
| 133 | - | cancelled.store(true, std::sync::atomic::Ordering::Relaxed); | |
| 134 | - | return false; | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | let _ = event_tx.send(ExportEvent::Progress { | |
| 138 | - | completed, | |
| 139 | - | total, | |
| 140 | - | current_name: current_name.to_string(), | |
| 141 | - | }); | |
| 142 | - | ||
| 143 | - | true | |
| 144 | - | }, | |
| 145 | - | ) | |
| 146 | - | })); | |
| 147 | - | ||
| 148 | - | match summary { | |
| 149 | - | Ok(Ok(ExportSummary { total, errors })) => { | |
| 150 | - | let _ = event_tx.send(ExportEvent::Complete { total, errors }); | |
| 151 | - | } | |
| 152 | - | Ok(Err(e)) => { | |
| 153 | - | let _ = event_tx.send(ExportEvent::Complete { | |
| 154 | - | total: 0, | |
| 155 | - | errors: vec![("export".to_string(), e.to_string())], | |
| 156 | - | }); | |
| 157 | - | } | |
| 158 | - | Err(_panic) => { | |
| 159 | - | let _ = event_tx.send(ExportEvent::Complete { | |
| 160 | - | total: 0, | |
| 161 | - | errors: vec![("export".to_string(), "export panicked (internal error)".to_string())], | |
| 162 | - | }); | |
| 163 | - | } | |
| 164 | - | } | |
| 91 | + | ctx.reset_cancel(); | |
| 92 | + | // A panic in run_export is caught by the runtime and reported as a | |
| 93 | + | // Complete-with-error, so the UI never wedges in the exporting state. | |
| 94 | + | let summary = audiofiles_core::export::run_export( | |
| 95 | + | &items, | |
| 96 | + | &config, | |
| 97 | + | store, | |
| 98 | + | |completed, total, current_name| { | |
| 99 | + | // Cooperative cancel between files (flag set by the handle on Cancel | |
| 100 | + | // or by the runtime on Drop). | |
| 101 | + | if ctx.is_cancelled() { | |
| 102 | + | return false; | |
| 165 | 103 | } | |
| 166 | - | } | |
| 104 | + | ctx.emit(ExportEvent::Progress { | |
| 105 | + | completed, | |
| 106 | + | total, | |
| 107 | + | current_name: current_name.to_string(), | |
| 108 | + | }); | |
| 109 | + | true | |
| 110 | + | }, | |
| 111 | + | ); | |
| 112 | + | ||
| 113 | + | match summary { | |
| 114 | + | Ok(ExportSummary { total, errors }) => ctx.emit(ExportEvent::Complete { total, errors }), | |
| 115 | + | Err(e) => ctx.emit(ExportEvent::Complete { | |
| 116 | + | total: 0, | |
| 117 | + | errors: vec![("export".to_string(), e.to_string())], | |
| 118 | + | }), | |
| 167 | 119 | } | |
| 168 | 120 | } | |
| 169 | 121 | ||
| @@ -191,7 +143,6 @@ mod tests { | |||
| 191 | 143 | }, | |
| 192 | 144 | }; | |
| 193 | 145 | let _cancel = ExportCommand::Cancel; | |
| 194 | - | let _shutdown = ExportCommand::Shutdown; | |
| 195 | 146 | } | |
| 196 | 147 | ||
| 197 | 148 | #[test] |
| @@ -7,16 +7,16 @@ | |||
| 7 | 7 | use std::collections::HashMap; | |
| 8 | 8 | use std::fs; | |
| 9 | 9 | use std::path::{Path, PathBuf}; | |
| 10 | - | use std::sync::{mpsc, Mutex}; | |
| 11 | - | use std::thread; | |
| 10 | + | use std::sync::atomic::{AtomicBool, Ordering}; | |
| 12 | 11 | use std::time::{Duration, Instant}; | |
| 13 | 12 | ||
| 14 | - | use tracing::{error, instrument, warn}; | |
| 13 | + | use tracing::{instrument, warn}; | |
| 15 | 14 | ||
| 16 | 15 | use audiofiles_core::db::Database; | |
| 17 | 16 | use audiofiles_core::error::CoreError; | |
| 18 | 17 | use audiofiles_core::store::SampleStore; | |
| 19 | 18 | use audiofiles_core::vfs::{self, NodeType}; | |
| 19 | + | use audiofiles_core::worker_runtime::{spawn_worker, EventSink, WorkerCtx, WorkerHandle}; | |
| 20 | 20 | use audiofiles_core::{NodeId, VfsId}; | |
| 21 | 21 | ||
| 22 | 22 | /// Check whether a path has an audio file extension. | |
| @@ -68,10 +68,8 @@ pub enum ImportCommand { | |||
| 68 | 68 | source: PathBuf, | |
| 69 | 69 | strategy: ImportStrategy, | |
| 70 | 70 | }, | |
| 71 | - | /// Cancel the current import. | |
| 71 | + | /// Cancel the current import (sets the worker's cancel flag synchronously). | |
| 72 | 72 | Cancel, | |
| 73 | - | /// Shut down the worker thread. | |
| 74 | - | Shutdown, | |
| 75 | 73 | } | |
| 76 | 74 | ||
| 77 | 75 | /// Event sent from the import worker back to the GUI thread. | |
| @@ -104,33 +102,30 @@ pub enum ImportEvent { | |||
| 104 | 102 | ||
| 105 | 103 | /// Handle for communicating with the background import worker. | |
| 106 | 104 | /// | |
| 107 | - | /// The receiver is wrapped in a `Mutex` so `BrowserState` remains `Sync` (required by nih-plug). | |
| 108 | - | /// Only the GUI thread actually calls `try_recv`, so contention is zero. | |
| 109 | - | pub struct ImportHandle { | |
| 110 | - | cmd_tx: mpsc::Sender<ImportCommand>, | |
| 111 | - | event_rx: Mutex<mpsc::Receiver<ImportEvent>>, | |
| 112 | - | _thread: Option<thread::JoinHandle<()>>, | |
| 113 | - | } | |
| 105 | + | /// Thin newtype over the generic [`WorkerHandle`]: `send` maps a `Cancel` | |
| 106 | + | /// command to a synchronous cancel-flag set, checked between files and during | |
| 107 | + | /// the pre-walk. | |
| 108 | + | pub struct ImportHandle(WorkerHandle<ImportCommand, ImportEvent>); | |
| 114 | 109 | ||
| 115 | 110 | impl ImportHandle { | |
| 116 | 111 | /// Poll for the next event without blocking. | |
| 117 | 112 | pub fn try_recv(&self) -> Option<ImportEvent> { | |
| 118 | - | self.event_rx.lock().ok()?.try_recv().ok() | |
| 113 | + | self.0.try_recv() | |
| 119 | 114 | } | |
| 120 | 115 | ||
| 121 | 116 | /// Send a command to the worker. | |
| 122 | 117 | pub fn send(&self, cmd: ImportCommand) { | |
| 123 | - | let _ = self.cmd_tx.send(cmd); | |
| 118 | + | if matches!(cmd, ImportCommand::Cancel) { | |
| 119 | + | self.0.request_cancel(); | |
| 120 | + | } | |
| 121 | + | self.0.send(cmd); | |
| 124 | 122 | } | |
| 125 | 123 | } | |
| 126 | 124 | ||
| 127 | - | impl Drop for ImportHandle { | |
| 128 | - | fn drop(&mut self) { | |
| 129 | - | let _ = self.cmd_tx.send(ImportCommand::Shutdown); | |
| 130 | - | if let Some(handle) = self._thread.take() { | |
| 131 | - | let _ = handle.join(); | |
| 132 | - | } | |
| 133 | - | } | |
| 125 | + | /// Per-worker state: the worker's own DB connection and store. | |
| 126 | + | struct ImportWorker { | |
| 127 | + | db: Database, | |
| 128 | + | store: SampleStore, | |
| 134 | 129 | } | |
| 135 | 130 | ||
| 136 | 131 | /// Spawn the background import worker thread. | |
| @@ -139,20 +134,33 @@ impl Drop for ImportHandle { | |||
| 139 | 134 | /// with the GUI thread. Returns a handle for sending commands and polling events. | |
| 140 | 135 | #[instrument(skip_all)] | |
| 141 | 136 | pub fn spawn_import_worker(db_path: PathBuf, store_root: PathBuf) -> std::io::Result<ImportHandle> { | |
| 142 | - | let (cmd_tx, cmd_rx) = mpsc::channel::<ImportCommand>(); | |
| 143 | - | let (event_tx, event_rx) = mpsc::channel::<ImportEvent>(); | |
| 144 | - | ||
| 145 | - | let thread = thread::Builder::new() | |
| 146 | - | .name("import-worker".to_string()) | |
| 147 | - | .spawn(move || { | |
| 148 | - | worker_loop(cmd_rx, event_tx, &db_path, &store_root); | |
| 149 | - | })?; | |
| 150 | - | ||
| 151 | - | Ok(ImportHandle { | |
| 152 | - | cmd_tx, | |
| 153 | - | event_rx: Mutex::new(event_rx), | |
| 154 | - | _thread: Some(thread), | |
| 155 | - | }) | |
| 137 | + | let handle = spawn_worker( | |
| 138 | + | "import-worker", | |
| 139 | + | move || -> Result<ImportWorker, CoreError> { | |
| 140 | + | let db = Database::open(&db_path)?; | |
| 141 | + | let store = SampleStore::new(&store_root)?; | |
| 142 | + | Ok(ImportWorker { db, store }) | |
| 143 | + | }, | |
| 144 | + | |e| { | |
| 145 | + | tracing::error!("Import worker failed to open DB/store: {e}"); | |
| 146 | + | ImportEvent::Complete { | |
| 147 | + | imported: Vec::new(), | |
| 148 | + | total_files: 0, | |
| 149 | + | errors: 1, | |
| 150 | + | duplicates: 0, | |
| 151 | + | folders: Vec::new(), | |
| 152 | + | } | |
| 153 | + | }, | |
| 154 | + | |_state| ImportEvent::Complete { | |
| 155 | + | imported: Vec::new(), | |
| 156 | + | total_files: 0, | |
| 157 | + | errors: 1, | |
| 158 | + | duplicates: 0, | |
| 159 | + | folders: Vec::new(), | |
| 160 | + | }, | |
| 161 | + | import_step, | |
| 162 | + | )?; | |
| 163 | + | Ok(ImportHandle(handle)) | |
| 156 | 164 | } | |
| 157 | 165 | ||
| 158 | 166 | /// Recursively count audio files and sum their sizes under `dir`. | |
| @@ -160,8 +168,8 @@ pub fn spawn_import_worker(db_path: PathBuf, store_root: PathBuf) -> std::io::Re | |||
| 160 | 168 | #[instrument(skip_all)] | |
| 161 | 169 | fn count_audio_files( | |
| 162 | 170 | dir: &Path, | |
| 163 | - | cmd_rx: &mpsc::Receiver<ImportCommand>, | |
| 164 | - | event_tx: &mpsc::Sender<ImportEvent>, | |
| 171 | + | cancel: &AtomicBool, | |
| 172 | + | sink: &EventSink<ImportEvent>, | |
| 165 | 173 | ) -> Option<(usize, u64)> { | |
| 166 | 174 | let mut count = 0; | |
| 167 | 175 | let mut total_bytes = 0u64; | |
| @@ -171,7 +179,7 @@ fn count_audio_files( | |||
| 171 | 179 | ||
| 172 | 180 | while let Some(current) = stack.pop() { | |
| 173 | 181 | // Check for cancel | |
| 174 | - | if let Ok(ImportCommand::Cancel) | Ok(ImportCommand::Shutdown) = cmd_rx.try_recv() { | |
| 182 | + | if cancel.load(Ordering::Acquire) { | |
| 175 | 183 | return None; | |
| 176 | 184 | } | |
| 177 | 185 | ||
| @@ -198,7 +206,7 @@ fn count_audio_files( | |||
| 198 | 206 | total_bytes += meta.len(); | |
| 199 | 207 | } | |
| 200 | 208 | if last_emit.elapsed() >= emit_interval { | |
| 201 | - | let _ = event_tx.send(ImportEvent::WalkProgress { count, total_bytes }); | |
| 209 | + | sink.emit(ImportEvent::WalkProgress { count, total_bytes }); | |
| 202 | 210 | last_emit = Instant::now(); | |
| 203 | 211 | } | |
| 204 | 212 | } | |
| @@ -279,8 +287,8 @@ fn import_single_file( | |||
| 279 | 287 | struct ImportContext<'a> { | |
| 280 | 288 | store: &'a SampleStore, | |
| 281 | 289 | db: &'a Database, | |
| 282 | - | event_tx: &'a mpsc::Sender<ImportEvent>, | |
| 283 | - | cmd_rx: &'a mpsc::Receiver<ImportCommand>, | |
| 290 | + | sink: &'a EventSink<ImportEvent>, | |
| 291 | + | cancel: &'a AtomicBool, | |
| 284 | 292 | completed: &'a mut usize, | |
| 285 | 293 | total: usize, | |
| 286 | 294 | errors: &'a mut usize, | |
| @@ -290,17 +298,15 @@ struct ImportContext<'a> { | |||
| 290 | 298 | } | |
| 291 | 299 | ||
| 292 | 300 | impl ImportContext<'_> { | |
| 293 | - | /// Check for a cancellation command without blocking. | |
| 301 | + | /// Whether cancellation has been requested (flag set by the handle on Cancel | |
| 302 | + | /// or by the runtime on Drop). | |
| 294 | 303 | fn is_cancelled(&self) -> bool { | |
| 295 | - | matches!( | |
| 296 | - | self.cmd_rx.try_recv(), | |
| 297 | - | Ok(ImportCommand::Cancel) | Ok(ImportCommand::Shutdown) | |
| 298 | - | ) | |
| 304 | + | self.cancel.load(Ordering::Acquire) | |
| 299 | 305 | } | |
| 300 | 306 | ||
| 301 | 307 | /// Send a progress event for the current file. | |
| 302 | 308 | fn send_progress(&self, name: String) { | |
| 303 | - | let _ = self.event_tx.send(ImportEvent::Progress { | |
| 309 | + | self.sink.emit(ImportEvent::Progress { | |
| 304 | 310 | completed: *self.completed, | |
| 305 | 311 | total: self.total, | |
| 306 | 312 | current_name: name, | |
| @@ -346,14 +352,17 @@ impl ImportContext<'_> { | |||
| 346 | 352 | } | |
| 347 | 353 | Ok(Err(e)) => { | |
| 348 | 354 | *self.errors += 1; | |
| 349 | - | let _ = self.event_tx.send(ImportEvent::FileError { | |
| 355 | + | self.sink.emit(ImportEvent::FileError { | |
| 350 | 356 | path: path.display().to_string(), | |
| 351 | 357 | error: e.to_string(), | |
| 352 | 358 | }); | |
| 353 | 359 | } | |
| 354 | 360 | Err(_panic) => { | |
| 361 | + | // Per-file panic isolation: a single bad file must not abort the | |
| 362 | + | // whole import. (The runtime's outer guard backstops anything | |
| 363 | + | // outside this per-file loop.) | |
| 355 | 364 | *self.errors += 1; | |
| 356 | - | let _ = self.event_tx.send(ImportEvent::FileError { | |
| 365 | + | self.sink.emit(ImportEvent::FileError { | |
| 357 | 366 | path: path.display().to_string(), | |
| 358 | 367 | error: "import panicked (internal error)".to_string(), | |
| 359 | 368 | }); | |
| @@ -563,153 +572,112 @@ fn import_structured( | |||
| 563 | 572 | (false, folders) | |
| 564 | 573 | } | |
| 565 | 574 | ||
| 566 | - | fn worker_loop( | |
| 567 | - | cmd_rx: mpsc::Receiver<ImportCommand>, | |
| 568 | - | event_tx: mpsc::Sender<ImportEvent>, | |
| 569 | - | db_path: &Path, | |
| 570 | - | store_root: &Path, | |
| 571 | - | ) { | |
| 572 | - | // Open our own DB connection and store | |
| 573 | - | let db = match Database::open(db_path) { | |
| 574 | - | Ok(db) => db, | |
| 575 | - | Err(e) => { | |
| 576 | - | let _ = event_tx.send(ImportEvent::Complete { | |
| 577 | - | imported: Vec::new(), | |
| 578 | - | total_files: 0, | |
| 579 | - | errors: 1, | |
| 580 | - | duplicates: 0, | |
| 581 | - | folders: Vec::new(), | |
| 582 | - | }); | |
| 583 | - | error!("Import worker failed to open DB: {e}"); | |
| 584 | - | return; | |
| 585 | - | } | |
| 575 | + | fn import_step(worker: &mut ImportWorker, cmd: ImportCommand, ctx: &WorkerCtx<ImportEvent>) { | |
| 576 | + | let ImportCommand::ImportDirectory { source, strategy } = cmd else { | |
| 577 | + | // Cancel: the flag was already set synchronously by the handle. | |
| 578 | + | return; | |
| 586 | 579 | }; | |
| 587 | 580 | ||
| 588 | - | let store = match SampleStore::new(store_root) { | |
| 589 | - | Ok(s) => s, | |
| 590 | - | Err(e) => { | |
| 591 | - | let _ = event_tx.send(ImportEvent::Complete { | |
| 581 | + | ctx.reset_cancel(); | |
| 582 | + | let db = &worker.db; | |
| 583 | + | let store = &worker.store; | |
| 584 | + | let sink = ctx.sink(); | |
| 585 | + | let cancel = ctx.cancel_flag(); | |
| 586 | + | ||
| 587 | + | // Resolve strategy to concrete (vfs_id, parent_id, flat) | |
| 588 | + | let (vfs_id, parent_id, flat) = match strategy { | |
| 589 | + | ImportStrategy::Flat { vfs_id, parent_id } => (vfs_id, parent_id, true), | |
| 590 | + | ImportStrategy::NewVfs { vfs_name } => match vfs::create_vfs(db, &vfs_name) { | |
| 591 | + | Ok(id) => (id, None, false), | |
| 592 | + | Err(e) => { | |
| 593 | + | sink.emit(ImportEvent::Complete { | |
| 594 | + | imported: Vec::new(), | |
| 595 | + | total_files: 0, | |
| 596 | + | errors: 1, | |
| 597 | + | duplicates: 0, | |
| 598 | + | folders: Vec::new(), | |
| 599 | + | }); | |
| 600 | + | tracing::error!("Failed to create VFS '{vfs_name}': {e}"); | |
| 601 | + | return; | |
| 602 | + | } | |
| 603 | + | }, | |
| 604 | + | ImportStrategy::MergeIntoVfs { vfs_id, parent_id } => (vfs_id, parent_id, false), | |
| 605 | + | }; | |
| 606 | + | ||
| 607 | + | // Phase 1: pre-walk to count audio files and sum sizes | |
| 608 | + | let (total, total_bytes) = match count_audio_files(&source, cancel, sink) { | |
| 609 | + | Some(result) => result, | |
| 610 | + | None => { | |
| 611 | + | sink.emit(ImportEvent::Complete { | |
| 592 | 612 | imported: Vec::new(), | |
| 593 | 613 | total_files: 0, | |
| 594 | - | errors: 1, | |
| 614 | + | errors: 0, | |
| 595 | 615 | duplicates: 0, | |
| 596 | 616 | folders: Vec::new(), | |
| 597 | 617 | }); | |
| 598 | - | error!("Import worker failed to open store: {e}"); | |
| 599 | 618 | return; | |
| 600 | 619 | } | |
| 601 | 620 | }; | |
| 602 | 621 | ||
| 603 | - | while let Ok(cmd) = cmd_rx.recv() { | |
| 604 | - | match cmd { | |
| 605 | - | ImportCommand::Shutdown => break, | |
| 606 | - | ImportCommand::Cancel => continue, | |
| 607 | - | ImportCommand::ImportDirectory { source, strategy } => { | |
| 608 | - | // Resolve strategy to concrete (vfs_id, parent_id, flat) | |
| 609 | - | let (vfs_id, parent_id, flat) = match strategy { | |
| 610 | - | ImportStrategy::Flat { vfs_id, parent_id } => (vfs_id, parent_id, true), | |
| 611 | - | ImportStrategy::NewVfs { vfs_name } => { | |
| 612 | - | match vfs::create_vfs(&db, &vfs_name) { | |
| 613 | - | Ok(id) => (id, None, false), | |
| 614 | - | Err(e) => { | |
| 615 | - | let _ = event_tx.send(ImportEvent::Complete { | |
| 616 | - | imported: Vec::new(), | |
| 617 | - | total_files: 0, | |
| 618 | - | errors: 1, | |
| 619 | - | duplicates: 0, | |
| 620 | - | folders: Vec::new(), | |
| 621 | - | }); | |
| 622 | - | error!("Failed to create VFS '{vfs_name}': {e}"); | |
| 623 | - | continue; | |
| 624 | - | } | |
| 625 | - | } | |
| 626 | - | } | |
| 627 | - | ImportStrategy::MergeIntoVfs { vfs_id, parent_id } => { | |
| 628 | - | (vfs_id, parent_id, false) | |
| 629 | - | } | |
| 630 | - | }; | |
| 622 | + | sink.emit(ImportEvent::WalkComplete { total, total_bytes }); | |
| 631 | 623 | ||
| 632 | - | // Phase 1: pre-walk to count audio files and sum sizes | |
| 633 | - | let (total, total_bytes) = match count_audio_files(&source, &cmd_rx, &event_tx) { | |
| 634 | - | Some(result) => result, | |
| 635 | - | None => { | |
| 636 | - | let _ = event_tx.send(ImportEvent::Complete { | |
| 637 | - | imported: Vec::new(), | |
| 638 | - | total_files: 0, | |
| 639 | - | errors: 0, | |
| 640 | - | duplicates: 0, | |
| 641 | - | folders: Vec::new(), | |
| 642 | - | }); | |
| 643 | - | continue; | |
| 644 | - | } | |
| 645 | - | }; | |
| 646 | - | ||
| 647 | - | let _ = event_tx.send(ImportEvent::WalkComplete { total, total_bytes }); | |
| 648 | - | ||
| 649 | - | // Check if loose-files mode is enabled for this vault. Distinguish | |
| 650 | - | // "not configured" (legitimate default: off) from a real DB error, | |
| 651 | - | // which must not silently flip import into copy mode unnoticed. | |
| 652 | - | let loose_files = match db.conn().query_row( | |
| 653 | - | "SELECT value FROM user_config WHERE key = 'loose_files'", | |
| 654 | - | [], | |
| 655 | - | |row| row.get::<_, String>(0), | |
| 656 | - | ) { | |
| 657 | - | Ok(v) => v == "1", | |
| 658 | - | Err(rusqlite::Error::QueryReturnedNoRows) => false, | |
| 659 | - | Err(e) => { | |
| 660 | - | tracing::warn!("Failed to read loose_files config, defaulting to off: {e}"); | |
| 661 | - | false | |
| 662 | - | } | |
| 663 | - | }; | |
| 664 | - | ||
| 665 | - | // Phase 2: import files with progress | |
| 666 | - | let mut completed = 0usize; | |
| 667 | - | let mut errors = 0usize; | |
| 668 | - | let mut duplicates = 0usize; | |
| 669 | - | let mut imported = Vec::new(); | |
| 670 | - | ||
| 671 | - | let mut ctx = ImportContext { | |
| 672 | - | store: &store, | |
| 673 | - | db: &db, | |
| 674 | - | event_tx: &event_tx, | |
| 675 | - | cmd_rx: &cmd_rx, | |
| 676 | - | completed: &mut completed, | |
| 677 | - | total, | |
| 678 | - | errors: &mut errors, | |
| 679 | - | duplicates: &mut duplicates, | |
| 680 | - | imported: &mut imported, | |
| 681 | - | loose_files, | |
| 682 | - | }; | |
| 624 | + | // Check if loose-files mode is enabled for this vault. Distinguish | |
| 625 | + | // "not configured" (legitimate default: off) from a real DB error, | |
| 626 | + | // which must not silently flip import into copy mode unnoticed. | |
| 627 | + | let loose_files = match db.conn().query_row( | |
| 628 | + | "SELECT value FROM user_config WHERE key = 'loose_files'", | |
| 629 | + | [], | |
| 630 | + | |row| row.get::<_, String>(0), | |
| 631 | + | ) { | |
| 632 | + | Ok(v) => v == "1", | |
| 633 | + | Err(rusqlite::Error::QueryReturnedNoRows) => false, | |
| 634 | + | Err(e) => { | |
| 635 | + | tracing::warn!("Failed to read loose_files config, defaulting to off: {e}"); | |
| 636 | + | false | |
| 637 | + | } | |
| 638 | + | }; | |
| 683 | 639 | ||
| 684 | - | let (cancelled, folders) = if flat { | |
| 685 | - | let c = import_directory_flat( | |
| 686 | - | &source, vfs_id, parent_id, &mut ctx, | |
| 687 | - | ); | |
| 688 | - | (c, Vec::new()) | |
| 689 | - | } else { | |
| 690 | - | import_structured( | |
| 691 | - | &source, vfs_id, parent_id, &mut ctx, | |
| 692 | - | ) | |
| 693 | - | }; | |
| 640 | + | // Phase 2: import files with progress | |
| 641 | + | let mut completed = 0usize; | |
| 642 | + | let mut errors = 0usize; | |
| 643 | + | let mut duplicates = 0usize; | |
| 644 | + | let mut imported = Vec::new(); | |
| 645 | + | ||
| 646 | + | let mut import_ctx = ImportContext { | |
| 647 | + | store, | |
| 648 | + | db, | |
| 649 | + | sink, | |
| 650 | + | cancel, | |
| 651 | + | completed: &mut completed, | |
| 652 | + | total, | |
| 653 | + | errors: &mut errors, | |
| 654 | + | duplicates: &mut duplicates, | |
| 655 | + | imported: &mut imported, | |
| 656 | + | loose_files, | |
| 657 | + | }; | |
| 694 | 658 | ||
| 695 | - | let total_files = if cancelled { completed } else { total }; | |
| 659 | + | let (cancelled, folders) = if flat { | |
| 660 | + | let c = import_directory_flat(&source, vfs_id, parent_id, &mut import_ctx); | |
| 661 | + | (c, Vec::new()) | |
| 662 | + | } else { | |
| 663 | + | import_structured(&source, vfs_id, parent_id, &mut import_ctx) | |
| 664 | + | }; | |
| 696 | 665 | ||
| 697 | - | // Checkpoint WAL after large import to keep -shm file fresh | |
| 698 | - | // and avoid stale memory-mapped state on macOS. | |
| 699 | - | if let Err(e) = db.wal_checkpoint() { | |
| 700 | - | warn!("WAL checkpoint after import failed: {e}"); | |
| 701 | - | } | |
| 666 | + | let total_files = if cancelled { completed } else { total }; | |
| 702 | 667 | ||
| 703 | - | let _ = event_tx.send(ImportEvent::Complete { | |
| 704 | - | imported, | |
| 705 | - | total_files, | |
| 706 | - | errors, | |
| 707 | - | duplicates, | |
| 708 | - | folders, | |
| 709 | - | }); | |
| 710 | - | } | |
| 711 | - | } | |
| 668 | + | // Checkpoint WAL after large import to keep -shm file fresh | |
| 669 | + | // and avoid stale memory-mapped state on macOS. | |
| 670 | + | if let Err(e) = db.wal_checkpoint() { | |
| 671 | + | warn!("WAL checkpoint after import failed: {e}"); | |
| 712 | 672 | } | |
| 673 | + | ||
| 674 | + | sink.emit(ImportEvent::Complete { | |
| 675 | + | imported, | |
| 676 | + | total_files, | |
| 677 | + | errors, | |
| 678 | + | duplicates, | |
| 679 | + | folders, | |
| 680 | + | }); | |
| 713 | 681 | } | |
| 714 | 682 | ||
| 715 | 683 | #[cfg(test)] | |
| @@ -739,7 +707,6 @@ mod tests { | |||
| 739 | 707 | }, | |
| 740 | 708 | }; | |
| 741 | 709 | let _cancel = ImportCommand::Cancel; | |
| 742 | - | let _shutdown = ImportCommand::Shutdown; | |
| 743 | 710 | } | |
| 744 | 711 | ||
| 745 | 712 | #[test] | |
| @@ -794,14 +761,14 @@ mod tests { | |||
| 794 | 761 | let store = SampleStore::new(tmp.path().join("store")).unwrap(); | |
| 795 | 762 | let vfs_id = vfs::create_vfs(&db, "T").unwrap(); | |
| 796 | 763 | ||
| 797 | - | let (event_tx, _event_rx) = mpsc::channel(); | |
| 798 | - | let (_cmd_tx, cmd_rx) = mpsc::channel(); | |
| 764 | + | let (sink, _event_rx) = EventSink::channel(); | |
| 765 | + | let cancel = AtomicBool::new(false); | |
| 799 | 766 | let (mut completed, mut errors, mut dups, mut imported) = (0, 0, 0, Vec::new()); | |
| 800 | 767 | let mut ctx = ImportContext { | |
| 801 | 768 | store: &store, | |
| 802 | 769 | db: &db, | |
| 803 | - | event_tx: &event_tx, | |
| 804 | - | cmd_rx: &cmd_rx, | |
| 770 | + | sink: &sink, | |
| 771 | + | cancel: &cancel, | |
| 805 | 772 | completed: &mut completed, | |
| 806 | 773 | total: 3, | |
| 807 | 774 | errors: &mut errors, |
| @@ -4,12 +4,11 @@ | |||
| 4 | 4 | //! a dedicated thread with its own Database, polled each frame via channels. | |
| 5 | 5 | ||
| 6 | 6 | use std::path::PathBuf; | |
| 7 | - | use std::sync::{mpsc, Mutex}; | |
| 8 | - | use std::thread; | |
| 9 | 7 | ||
| 10 | - | use tracing::{error, instrument}; | |
| 8 | + | use tracing::instrument; | |
| 11 | 9 | ||
| 12 | 10 | use audiofiles_core::db::Database; | |
| 11 | + | use audiofiles_core::worker_runtime::{spawn_worker, WorkerCtx, WorkerHandle}; | |
| 13 | 12 | ||
| 14 | 13 | /// Command from the GUI thread to the loose-files worker. | |
| 15 | 14 | pub enum LooseFilesCommand { | |
| @@ -19,8 +18,6 @@ pub enum LooseFilesCommand { | |||
| 19 | 18 | Relocate(PathBuf), | |
| 20 | 19 | /// Remove loose-files samples whose source file is missing. | |
| 21 | 20 | Purge, | |
| 22 | - | /// Shut the worker down. | |
| 23 | - | Shutdown, | |
| 24 | 21 | } | |
| 25 | 22 | ||
| 26 | 23 | /// Event from the loose-files worker back to the GUI thread. | |
| @@ -37,103 +34,61 @@ pub enum LooseFilesEvent { | |||
| 37 | 34 | ||
| 38 | 35 | /// Handle for communicating with the loose-files worker. | |
| 39 | 36 | /// | |
| 40 | - | /// The receiver is wrapped in a `Mutex` so `BrowserState` stays `Sync` (nih-plug); | |
| 41 | - | /// only the GUI thread calls `try_recv`, so contention is zero. | |
| 42 | - | pub struct LooseFilesHandle { | |
| 43 | - | cmd_tx: mpsc::Sender<LooseFilesCommand>, | |
| 44 | - | event_rx: Mutex<mpsc::Receiver<LooseFilesEvent>>, | |
| 45 | - | _thread: Option<thread::JoinHandle<()>>, | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | impl LooseFilesHandle { | |
| 49 | - | /// Poll for the next event without blocking. | |
| 50 | - | pub fn try_recv(&self) -> Option<LooseFilesEvent> { | |
| 51 | - | self.event_rx.lock().ok()?.try_recv().ok() | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | /// Send a command to the worker. | |
| 55 | - | pub fn send(&self, cmd: LooseFilesCommand) { | |
| 56 | - | let _ = self.cmd_tx.send(cmd); | |
| 57 | - | } | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | impl Drop for LooseFilesHandle { | |
| 61 | - | fn drop(&mut self) { | |
| 62 | - | let _ = self.cmd_tx.send(LooseFilesCommand::Shutdown); | |
| 63 | - | if let Some(handle) = self._thread.take() { | |
| 64 | - | let _ = handle.join(); | |
| 65 | - | } | |
| 66 | - | } | |
| 67 | - | } | |
| 37 | + | /// Handle for the loose-files worker. No Cancel command, so the runtime handle | |
| 38 | + | /// is used directly. | |
| 39 | + | /// | |
| 40 | + | /// Previously this worker hand-rolled its own recv loop and was the one worker | |
| 41 | + | /// that omitted `catch_unwind` — a panic in relocate/purge silently killed the | |
| 42 | + | /// thread and dropped all future commands. Routing through [`spawn_worker`] | |
| 43 | + | /// makes that drift unrepresentable: the runtime owns the loop and the guard. | |
| 44 | + | pub type LooseFilesHandle = WorkerHandle<LooseFilesCommand, LooseFilesEvent>; | |
| 68 | 45 | ||
| 69 | 46 | /// Spawn the loose-files worker. It opens its own `Database` to avoid contending | |
| 70 | 47 | /// with the GUI connection. | |
| 71 | 48 | #[instrument(skip_all)] | |
| 72 | 49 | pub fn spawn_loose_files_worker(db_path: PathBuf) -> std::io::Result<LooseFilesHandle> { | |
| 73 | - | let (cmd_tx, cmd_rx) = mpsc::channel::<LooseFilesCommand>(); | |
| 74 | - | let (event_tx, event_rx) = mpsc::channel::<LooseFilesEvent>(); | |
| 75 | - | ||
| 76 | - | let thread = thread::Builder::new() | |
| 77 | - | .name("loose-files-worker".to_string()) | |
| 78 | - | .spawn(move || worker_loop(&cmd_rx, &event_tx, &db_path))?; | |
| 79 | - | ||
| 80 | - | Ok(LooseFilesHandle { | |
| 81 | - | cmd_tx, | |
| 82 | - | event_rx: Mutex::new(event_rx), | |
| 83 | - | _thread: Some(thread), | |
| 84 | - | }) | |
| 50 | + | spawn_worker( | |
| 51 | + | "loose-files-worker", | |
| 52 | + | move || Database::open(&db_path), | |
| 53 | + | |e| LooseFilesEvent::Failed { | |
| 54 | + | message: format!("could not open the library database: {e}"), | |
| 55 | + | }, | |
| 56 | + | |_state| LooseFilesEvent::Failed { | |
| 57 | + | message: "loose-files maintenance panicked (internal error)".to_string(), | |
| 58 | + | }, | |
| 59 | + | loose_files_step, | |
| 60 | + | ) | |
| 85 | 61 | } | |
| 86 | 62 | ||
| 87 | - | #[instrument(skip_all)] | |
| 88 | - | fn worker_loop( | |
| 89 | - | cmd_rx: &mpsc::Receiver<LooseFilesCommand>, | |
| 90 | - | event_tx: &mpsc::Sender<LooseFilesEvent>, | |
| 91 | - | db_path: &std::path::Path, | |
| 92 | - | ) { | |
| 93 | - | let db = match Database::open(db_path) { | |
| 94 | - | Ok(d) => d, | |
| 95 | - | Err(e) => { | |
| 96 | - | error!("Loose-files worker failed to open database: {e}"); | |
| 97 | - | let _ = event_tx.send(LooseFilesEvent::Failed { | |
| 98 | - | message: "could not open the library database".to_string(), | |
| 99 | - | }); | |
| 100 | - | return; | |
| 101 | - | } | |
| 102 | - | }; | |
| 103 | - | ||
| 104 | - | while let Ok(cmd) = cmd_rx.recv() { | |
| 105 | - | let event = match cmd { | |
| 106 | - | LooseFilesCommand::Shutdown => break, | |
| 107 | - | LooseFilesCommand::CheckIntegrity => { | |
| 108 | - | match audiofiles_core::store::check_loose_files_integrity(&db) { | |
| 109 | - | Ok((_valid, missing)) => LooseFilesEvent::IntegrityResult { missing }, | |
| 110 | - | Err(e) => LooseFilesEvent::Failed { | |
| 111 | - | message: e.to_string(), | |
| 112 | - | }, | |
| 113 | - | } | |
| 63 | + | fn loose_files_step(db: &mut Database, cmd: LooseFilesCommand, ctx: &WorkerCtx<LooseFilesEvent>) { | |
| 64 | + | let event = match cmd { | |
| 65 | + | LooseFilesCommand::CheckIntegrity => { | |
| 66 | + | match audiofiles_core::store::check_loose_files_integrity(db) { | |
| 67 | + | Ok((_valid, missing)) => LooseFilesEvent::IntegrityResult { missing }, | |
| 68 | + | Err(e) => LooseFilesEvent::Failed { | |
| 69 | + | message: e.to_string(), | |
| 70 | + | }, | |
| 114 | 71 | } | |
| 115 | - | LooseFilesCommand::Relocate(root) => { | |
| 116 | - | match audiofiles_core::store::relocate_missing_loose_files(&db, &root) { | |
| 117 | - | Ok((relocated, still_missing)) => LooseFilesEvent::RelocateResult { | |
| 118 | - | relocated, | |
| 119 | - | still_missing, | |
| 120 | - | }, | |
| 121 | - | Err(e) => LooseFilesEvent::Failed { | |
| 122 | - | message: e.to_string(), | |
| 123 | - | }, | |
| 124 | - | } | |
| 125 | - | } | |
| 126 | - | LooseFilesCommand::Purge => { | |
| 127 | - | match audiofiles_core::store::purge_missing_loose_files(&db) { | |
| 128 | - | Ok(purged) => LooseFilesEvent::PurgeResult { purged }, | |
| 129 | - | Err(e) => LooseFilesEvent::Failed { | |
| 130 | - | message: e.to_string(), | |
| 131 | - | }, | |
| 132 | - | } | |
| 72 | + | } | |
| 73 | + | LooseFilesCommand::Relocate(root) => { | |
| 74 | + | match audiofiles_core::store::relocate_missing_loose_files(db, &root) { | |
| 75 | + | Ok((relocated, still_missing)) => LooseFilesEvent::RelocateResult { | |
| 76 | + | relocated, | |
| 77 | + | still_missing, | |
| 78 | + | }, | |
| 79 | + | Err(e) => LooseFilesEvent::Failed { | |
| 80 | + | message: e.to_string(), | |
| 81 | + | }, | |
| 133 | 82 | } | |
| 134 | - | }; | |
| 135 | - | let _ = event_tx.send(event); | |
| 136 | - | } | |
| 83 | + | } | |
| 84 | + | LooseFilesCommand::Purge => match audiofiles_core::store::purge_missing_loose_files(db) { | |
| 85 | + | Ok(purged) => LooseFilesEvent::PurgeResult { purged }, | |
| 86 | + | Err(e) => LooseFilesEvent::Failed { | |
| 87 | + | message: e.to_string(), | |
| 88 | + | }, | |
| 89 | + | }, | |
| 90 | + | }; | |
| 91 | + | ctx.emit(event); | |
| 137 | 92 | } | |
| 138 | 93 | ||
| 139 | 94 | #[cfg(test)] |
| @@ -4,16 +4,17 @@ | |||
| 4 | 4 | //! and emitting [`WorkerEvent`]s back. Batches are processed in parallel using rayon. | |
| 5 | 5 | //! An `AtomicBool` cancel flag allows interrupting in-flight parallel work. | |
| 6 | 6 | ||
| 7 | + | use std::convert::Infallible; | |
| 7 | 8 | use std::path::PathBuf; | |
| 8 | - | use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; | |
| 9 | - | use std::sync::{mpsc, Arc, Mutex}; | |
| 10 | - | use std::thread; | |
| 9 | + | use std::sync::atomic::{AtomicUsize, Ordering}; | |
| 10 | + | use std::sync::Arc; | |
| 11 | 11 | ||
| 12 | 12 | use rayon::prelude::*; | |
| 13 | 13 | ||
| 14 | 14 | use super::config::AnalysisConfig; | |
| 15 | 15 | use super::suggest::TagSuggestion; | |
| 16 | 16 | use super::{analyze_sample, AnalysisResult}; | |
| 17 | + | use crate::worker_runtime::{spawn_worker as spawn_runtime_worker, WorkerCtx, WorkerHandle as RuntimeHandle}; | |
| 17 | 18 | use tracing::instrument; | |
| 18 | 19 | ||
| 19 | 20 | /// Command sent from the GUI thread to the analysis worker. | |
| @@ -25,10 +26,8 @@ pub enum WorkerCommand { | |||
| 25 | 26 | /// Which analysis stages to run. | |
| 26 | 27 | config: AnalysisConfig, | |
| 27 | 28 | }, | |
| 28 | - | /// Cancel the current batch. | |
| 29 | + | /// Cancel the current batch (sets the worker's cancel flag synchronously). | |
| 29 | 30 | Cancel, | |
| 30 | - | /// Shut down the worker thread. | |
| 31 | - | Shutdown, | |
| 32 | 31 | } | |
| 33 | 32 | ||
| 34 | 33 | /// Event sent from the analysis worker back to the GUI thread. | |
| @@ -62,38 +61,23 @@ pub enum WorkerEvent { | |||
| 62 | 61 | ||
| 63 | 62 | /// Handle for communicating with the background worker. | |
| 64 | 63 | /// | |
| 65 | - | /// The receiver is wrapped in a Mutex to satisfy `Sync` bounds required | |
| 66 | - | /// by nih-plug's editor state (BrowserState must be Send + Sync). | |
| 67 | - | /// Only the GUI thread actually calls try_recv, so contention is zero. | |
| 68 | - | pub struct WorkerHandle { | |
| 69 | - | cmd_tx: mpsc::Sender<WorkerCommand>, | |
| 70 | - | event_rx: Mutex<mpsc::Receiver<WorkerEvent>>, | |
| 71 | - | cancel_flag: Arc<AtomicBool>, | |
| 72 | - | thread: Option<thread::JoinHandle<()>>, | |
| 73 | - | } | |
| 64 | + | /// Thin newtype over the generic [`RuntimeHandle`]: `send` maps a `Cancel` | |
| 65 | + | /// command to a synchronous cancel-flag set so an in-flight batch stops at the | |
| 66 | + | /// next per-sample checkpoint. | |
| 67 | + | pub struct WorkerHandle(RuntimeHandle<WorkerCommand, WorkerEvent>); | |
| 74 | 68 | ||
| 75 | 69 | impl WorkerHandle { | |
| 76 | 70 | /// Poll for the next event without blocking. Returns `None` if no events pending. | |
| 77 | 71 | pub fn try_recv(&self) -> Option<WorkerEvent> { | |
| 78 | - | self.event_rx.lock().ok()?.try_recv().ok() | |
| 72 | + | self.0.try_recv() | |
| 79 | 73 | } | |
| 80 | 74 | ||
| 81 | 75 | /// Send a command to the worker. | |
| 82 | 76 | pub fn send(&self, cmd: WorkerCommand) { | |
| 83 | 77 | if matches!(cmd, WorkerCommand::Cancel) { | |
| 84 | - | self.cancel_flag.store(true, Ordering::Release); | |
| 85 | - | } | |
| 86 | - | let _ = self.cmd_tx.send(cmd); | |
| 87 | - | } | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | impl Drop for WorkerHandle { | |
| 91 | - | fn drop(&mut self) { | |
| 92 | - | self.cancel_flag.store(true, Ordering::Release); | |
| 93 | - | let _ = self.cmd_tx.send(WorkerCommand::Shutdown); | |
| 94 | - | if let Some(handle) = self.thread.take() { | |
| 95 | - | let _ = handle.join(); | |
| 78 | + | self.0.request_cancel(); | |
| 96 | 79 | } | |
| 80 | + | self.0.send(cmd); | |
| 97 | 81 | } | |
| 98 | 82 | } | |
| 99 | 83 | ||
| @@ -103,98 +87,90 @@ impl Drop for WorkerHandle { | |||
| 103 | 87 | /// should call `handle.try_recv()` each frame to poll for events. | |
| 104 | 88 | #[instrument(skip_all)] | |
| 105 | 89 | pub fn spawn_worker() -> std::io::Result<WorkerHandle> { | |
| 106 | - | let (cmd_tx, cmd_rx) = mpsc::channel::<WorkerCommand>(); | |
| 107 | - | let (event_tx, event_rx) = mpsc::channel::<WorkerEvent>(); | |
| 108 | - | let cancel_flag = Arc::new(AtomicBool::new(false)); | |
| 109 | - | let cancel_clone = Arc::clone(&cancel_flag); | |
| 110 | - | ||
| 111 | - | let thread = thread::Builder::new() | |
| 112 | - | .name("analysis-worker".to_string()) | |
| 113 | - | .spawn(move || { | |
| 114 | - | worker_loop(cmd_rx, event_tx, cancel_clone); | |
| 115 | - | })?; | |
| 116 | - | ||
| 117 | - | Ok(WorkerHandle { | |
| 118 | - | cmd_tx, | |
| 119 | - | event_rx: Mutex::new(event_rx), | |
| 120 | - | cancel_flag, | |
| 121 | - | thread: Some(thread), | |
| 122 | - | }) | |
| 90 | + | let handle = spawn_worker_impl()?; | |
| 91 | + | Ok(WorkerHandle(handle)) | |
| 123 | 92 | } | |
| 124 | 93 | ||
| 125 | - | fn worker_loop( | |
| 126 | - | cmd_rx: mpsc::Receiver<WorkerCommand>, | |
| 127 | - | event_tx: mpsc::Sender<WorkerEvent>, | |
| 128 | - | cancel_flag: Arc<AtomicBool>, | |
| 129 | - | ) { | |
| 130 | - | while let Ok(cmd) = cmd_rx.recv() { | |
| 131 | - | match cmd { | |
| 132 | - | WorkerCommand::Shutdown => break, | |
| 133 | - | WorkerCommand::Cancel => { | |
| 134 | - | // Cancel flag already set by WorkerHandle::send; nothing else to do | |
| 135 | - | continue; | |
| 136 | - | } | |
| 137 | - | WorkerCommand::AnalyzeBatch { samples, config } => { | |
| 138 | - | // Reset cancel flag for this batch | |
| 139 | - | cancel_flag.store(false, Ordering::Release); | |
| 140 | - | ||
| 141 | - | let total = samples.len(); | |
| 142 | - | let completed = Arc::new(AtomicUsize::new(0)); | |
| 143 | - | ||
| 144 | - | // Process samples in parallel using rayon | |
| 145 | - | samples.par_iter().for_each(|(hash, _ext, path)| { | |
| 146 | - | // Check cancel flag before starting each sample | |
| 147 | - | if cancel_flag.load(Ordering::Acquire) { | |
| 148 | - | return; | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | let name = crate::util::get_filename(path, "unknown"); | |
| 152 | - | let done = completed.load(Ordering::Acquire); | |
| 153 | - | ||
| 154 | - | let _ = event_tx.send(WorkerEvent::Progress { | |
| 155 | - | completed: done, | |
| 156 | - | total, | |
| 157 | - | current_name: name, | |
| 158 | - | }); | |
| 94 | + | fn spawn_worker_impl() -> std::io::Result<RuntimeHandle<WorkerCommand, WorkerEvent>> { | |
| 95 | + | spawn_runtime_worker( | |
| 96 | + | "analysis-worker", | |
| 97 | + | || Ok::<(), Infallible>(()), | |
| 98 | + | |e: Infallible| match e {}, | |
| 99 | + | // An outer panic (outside the per-sample guard below) ends the batch; emit | |
| 100 | + | // BatchComplete so the import/backfill flow does not wedge waiting for it. | |
| 101 | + | |_state: &()| WorkerEvent::BatchComplete, | |
| 102 | + | analysis_step, | |
| 103 | + | ) | |
| 104 | + | } | |
| 159 | 105 | ||
| 160 | - | // Catch panics so a single bad sample doesn't kill the worker | |
| 161 | - | let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 162 | - | analyze_sample(hash, path, &config) | |
| 163 | - | })); | |
| 106 | + | fn analysis_step(_state: &mut (), cmd: WorkerCommand, ctx: &WorkerCtx<WorkerEvent>) { | |
| 107 | + | let WorkerCommand::AnalyzeBatch { samples, config } = cmd else { | |
| 108 | + | // Cancel: the flag was already set synchronously by the handle. | |
| 109 | + | return; | |
| 110 | + | }; | |
| 111 | + | ||
| 112 | + | ctx.reset_cancel(); | |
| 113 | + | let total = samples.len(); | |
| 114 | + | let completed = Arc::new(AtomicUsize::new(0)); | |
| 115 | + | ||
| 116 | + | // Process samples in parallel using rayon. The shared `&WorkerCtx` is Sync, | |
| 117 | + | // so each rayon task can emit events and read the cancel flag. | |
| 118 | + | samples.par_iter().for_each(|(hash, _ext, path)| { | |
| 119 | + | // Check cancel flag before starting each sample. | |
| 120 | + | if ctx.is_cancelled() { | |
| 121 | + | return; | |
| 122 | + | } | |
| 164 | 123 | ||
| 165 | - | match result { | |
| 166 | - | Ok(Ok(result)) => { | |
| 167 | - | let suggestions = if config.auto_suggest_tags { | |
| 168 | - | super::suggest::suggest_tags(&result) | |
| 169 | - | } else { | |
| 170 | - | Vec::new() | |
| 171 | - | }; | |
| 172 | - | let _ = event_tx.send(WorkerEvent::SampleDone { | |
| 173 | - | result: Box::new(result), | |
| 174 | - | suggestions, | |
| 175 | - | }); | |
| 176 | - | } | |
| 177 | - | Ok(Err(e)) => { | |
| 178 | - | let _ = event_tx.send(WorkerEvent::SampleError { | |
| 179 | - | hash: hash.clone(), | |
| 180 | - | error: e.to_string(), | |
| 181 | - | }); | |
| 182 | - | } | |
| 183 | - | Err(_panic) => { | |
| 184 | - | let _ = event_tx.send(WorkerEvent::SampleError { | |
| 185 | - | hash: hash.clone(), | |
| 186 | - | error: "analysis panicked (internal error)".to_string(), | |
| 187 | - | }); | |
| 188 | - | } | |
| 189 | - | } | |
| 124 | + | let done = completed.load(Ordering::Acquire); | |
| 125 | + | // Throttle Progress: it is high-frequency and low-value, and at large | |
| 126 | + | // batch sizes one event per sample can flood the channel faster than the | |
| 127 | + | // GUI drains it. Emit on the first sample and every 8th thereafter; the | |
| 128 | + | // GUI shows a coarse-grained bar regardless. | |
| 129 | + | if done.is_multiple_of(8) { | |
| 130 | + | ctx.emit(WorkerEvent::Progress { | |
| 131 | + | completed: done, | |
| 132 | + | total, | |
| 133 | + | current_name: crate::util::get_filename(path, "unknown"), | |
| 134 | + | }); | |
| 135 | + | } | |
| 190 | 136 | ||
| 191 | - | completed.fetch_add(1, Ordering::Release); | |
| 137 | + | // Inner per-sample panic isolation: a single bad sample must not abort | |
| 138 | + | // the whole batch. (The runtime's outer guard only catches panics | |
| 139 | + | // outside this loop.) | |
| 140 | + | let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 141 | + | analyze_sample(hash, path, &config) | |
| 142 | + | })); | |
| 143 | + | ||
| 144 | + | match result { | |
| 145 | + | Ok(Ok(result)) => { | |
| 146 | + | let suggestions = if config.auto_suggest_tags { | |
| 147 | + | super::suggest::suggest_tags(&result) | |
| 148 | + | } else { | |
| 149 | + | Vec::new() | |
| 150 | + | }; | |
| 151 | + | ctx.emit(WorkerEvent::SampleDone { | |
| 152 | + | result: Box::new(result), | |
| 153 | + | suggestions, | |
| 154 | + | }); | |
| 155 | + | } | |
| 156 | + | Ok(Err(e)) => { | |
| 157 | + | ctx.emit(WorkerEvent::SampleError { | |
| 158 | + | hash: hash.clone(), | |
| 159 | + | error: e.to_string(), | |
| 160 | + | }); | |
| 161 | + | } | |
| 162 | + | Err(_panic) => { | |
| 163 | + | ctx.emit(WorkerEvent::SampleError { | |
| 164 | + | hash: hash.clone(), | |
| 165 | + | error: "analysis panicked (internal error)".to_string(), | |
| 192 | 166 | }); | |
| 193 | - | ||
| 194 | - | let _ = event_tx.send(WorkerEvent::BatchComplete); | |
| 195 | 167 | } | |
| 196 | 168 | } | |
| 197 | - | } | |
| 169 | + | ||
| 170 | + | completed.fetch_add(1, Ordering::Release); | |
| 171 | + | }); | |
| 172 | + | ||
| 173 | + | ctx.emit(WorkerEvent::BatchComplete); | |
| 198 | 174 | } | |
| 199 | 175 | ||
| 200 | 176 | #[cfg(test)] | |
| @@ -209,12 +185,6 @@ mod tests { | |||
| 209 | 185 | } | |
| 210 | 186 | ||
| 211 | 187 | #[test] | |
| 212 | - | fn worker_command_shutdown_construction() { | |
| 213 | - | let cmd = WorkerCommand::Shutdown; | |
| 214 | - | assert!(matches!(cmd, WorkerCommand::Shutdown)); | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | #[test] | |
| 218 | 188 | fn worker_command_analyze_batch_construction() { | |
| 219 | 189 | let config = AnalysisConfig::default(); | |
| 220 | 190 | let samples = vec![ | |
| @@ -262,12 +232,10 @@ mod tests { | |||
| 262 | 232 | } | |
| 263 | 233 | ||
| 264 | 234 | #[test] | |
| 265 | - | fn cancel_flag_set_on_cancel() { | |
| 235 | + | fn cancel_then_drop_does_not_hang() { | |
| 266 | 236 | let handle = spawn_worker().unwrap(); | |
| 267 | 237 | handle.send(WorkerCommand::Cancel); | |
| 268 | - | // Give it a moment to process | |
| 269 | 238 | std::thread::sleep(std::time::Duration::from_millis(10)); | |
| 270 | - | assert!(handle.cancel_flag.load(Ordering::Acquire)); | |
| 271 | 239 | drop(handle); | |
| 272 | 240 | } | |
| 273 | 241 | } |
| @@ -3,10 +3,9 @@ | |||
| 3 | 3 | //! Follows the same pattern as `analysis::worker`: a dedicated thread receiving | |
| 4 | 4 | //! commands over a channel and emitting events back. | |
| 5 | 5 | ||
| 6 | + | use std::convert::Infallible; | |
| 6 | 7 | use std::path::{Path, PathBuf}; | |
| 7 | 8 | use std::sync::atomic::{AtomicBool, Ordering}; | |
| 8 | - | use std::sync::{mpsc, Arc, Mutex}; | |
| 9 | - | use std::thread; | |
| 10 | 9 | ||
| 11 | 10 | use tracing::instrument; | |
| 12 | 11 | ||
| @@ -14,6 +13,7 @@ use super::{apply_edit, EditOperation}; | |||
| 14 | 13 | use crate::export::convert::ConvertedAudio; | |
| 15 | 14 | use crate::export::decode::decode_multichannel; | |
| 16 | 15 | use crate::export::encode::encode_wav; | |
| 16 | + | use crate::worker_runtime::{spawn_worker, WorkerCtx, WorkerHandle}; | |
| 17 | 17 | ||
| 18 | 18 | /// Command sent from the GUI thread to the edit worker. | |
| 19 | 19 | pub enum EditCommand { | |
| @@ -24,10 +24,8 @@ pub enum EditCommand { | |||
| 24 | 24 | path: PathBuf, | |
| 25 | 25 | operation: EditOperation, | |
| 26 | 26 | }, | |
| 27 | - | /// Cancel the current operation. | |
| 27 | + | /// Cancel the current operation (sets the worker's cancel flag synchronously). | |
| 28 | 28 | Cancel, | |
| 29 | - | /// Shut down the worker thread. | |
| 30 | - | Shutdown, | |
| 31 | 29 | } | |
| 32 | 30 | ||
| 33 | 31 | /// Event sent from the edit worker back to the GUI thread. | |
| @@ -45,58 +43,52 @@ pub enum EditEvent { | |||
| 45 | 43 | } | |
| 46 | 44 | ||
| 47 | 45 | /// Handle for communicating with the background edit worker. | |
| 48 | - | pub struct EditWorkerHandle { | |
| 49 | - | cmd_tx: mpsc::Sender<EditCommand>, | |
| 50 | - | event_rx: Mutex<mpsc::Receiver<EditEvent>>, | |
| 51 | - | cancel_flag: Arc<AtomicBool>, | |
| 52 | - | thread: Option<thread::JoinHandle<()>>, | |
| 53 | - | } | |
| 46 | + | /// | |
| 47 | + | /// Thin newtype over the generic [`WorkerHandle`]: `send` maps a `Cancel` | |
| 48 | + | /// command to a synchronous cancel-flag set so an in-flight edit aborts at its | |
| 49 | + | /// next checkpoint, before the queued command is even dequeued. | |
| 50 | + | pub struct EditWorkerHandle(WorkerHandle<EditCommand, EditEvent>); | |
| 54 | 51 | ||
| 55 | 52 | impl EditWorkerHandle { | |
| 56 | 53 | /// Poll for the next event without blocking. | |
| 57 | 54 | pub fn try_recv(&self) -> Option<EditEvent> { | |
| 58 | - | self.event_rx.lock().ok()?.try_recv().ok() | |
| 55 | + | self.0.try_recv() | |
| 59 | 56 | } | |
| 60 | 57 | ||
| 61 | 58 | /// Send a command to the worker. | |
| 62 | 59 | pub fn send(&self, cmd: EditCommand) { | |
| 63 | 60 | if matches!(cmd, EditCommand::Cancel) { | |
| 64 | - | self.cancel_flag.store(true, Ordering::Release); | |
| 61 | + | self.0.request_cancel(); | |
| 65 | 62 | } | |
| 66 | - | let _ = self.cmd_tx.send(cmd); | |
| 63 | + | self.0.send(cmd); | |
| 67 | 64 | } | |
| 68 | 65 | } | |
| 69 | 66 | ||
| 70 | - | impl Drop for EditWorkerHandle { | |
| 71 | - | fn drop(&mut self) { | |
| 72 | - | self.cancel_flag.store(true, Ordering::Release); | |
| 73 | - | let _ = self.cmd_tx.send(EditCommand::Shutdown); | |
| 74 | - | if let Some(handle) = self.thread.take() { | |
| 75 | - | let _ = handle.join(); | |
| 76 | - | } | |
| 77 | - | } | |
| 67 | + | /// Per-worker state: the hash of the edit currently in flight, so a panic mid- | |
| 68 | + | /// edit still reports a terminal `Error` for the right sample (un-wedging the UI). | |
| 69 | + | #[derive(Default)] | |
| 70 | + | struct EditState { | |
| 71 | + | current_hash: Option<String>, | |
| 78 | 72 | } | |
| 79 | 73 | ||
| 80 | 74 | /// Spawn the background edit worker thread. | |
| 81 | 75 | #[instrument(skip_all)] | |
| 82 | 76 | pub fn spawn_edit_worker() -> std::io::Result<EditWorkerHandle> { | |
| 83 | - | let (cmd_tx, cmd_rx) = mpsc::channel::<EditCommand>(); | |
| 84 | - | let (event_tx, event_rx) = mpsc::channel::<EditEvent>(); | |
| 85 | - | let cancel_flag = Arc::new(AtomicBool::new(false)); | |
| 86 | - | let cancel_clone = Arc::clone(&cancel_flag); | |
| 87 | - | ||
| 88 | - | let thread = thread::Builder::new() | |
| 89 | - | .name("edit-worker".to_string()) | |
| 90 | - | .spawn(move || { | |
| 91 | - | edit_worker_loop(cmd_rx, event_tx, cancel_clone); | |
| 92 | - | })?; | |
| 93 | - | ||
| 94 | - | Ok(EditWorkerHandle { | |
| 95 | - | cmd_tx, | |
| 96 | - | event_rx: Mutex::new(event_rx), | |
| 97 | - | cancel_flag, | |
| 98 | - | thread: Some(thread), | |
| 99 | - | }) | |
| 77 | + | let handle = spawn_worker( | |
| 78 | + | "edit-worker", | |
| 79 | + | || { | |
| 80 | + | // Clean up stale temp files from prior sessions once on startup. | |
| 81 | + | cleanup_edit_temp_dir(); | |
| 82 | + | Ok::<EditState, Infallible>(EditState::default()) | |
| 83 | + | }, | |
| 84 | + | |e: Infallible| match e {}, | |
| 85 | + | |state: &EditState| EditEvent::Error { | |
| 86 | + | hash: state.current_hash.clone().unwrap_or_default(), | |
| 87 | + | error: "edit panicked (internal error)".to_string(), | |
| 88 | + | }, | |
| 89 | + | edit_step, | |
| 90 | + | )?; | |
| 91 | + | Ok(EditWorkerHandle(handle)) | |
| 100 | 92 | } | |
| 101 | 93 | ||
| 102 | 94 | /// Clean up leftover temp files from previous edit sessions. | |
| @@ -112,64 +104,41 @@ fn cleanup_edit_temp_dir() { | |||
| 112 | 104 | } | |
| 113 | 105 | } | |
| 114 | 106 | ||
| 115 | - | fn edit_worker_loop( | |
| 116 | - | cmd_rx: mpsc::Receiver<EditCommand>, | |
| 117 | - | event_tx: mpsc::Sender<EditEvent>, | |
| 118 | - | cancel_flag: Arc<AtomicBool>, | |
| 119 | - | ) { | |
| 120 | - | // Clean up stale temp files from prior sessions | |
| 121 | - | cleanup_edit_temp_dir(); | |
| 122 | - | ||
| 123 | - | while let Ok(cmd) = cmd_rx.recv() { | |
| 124 | - | match cmd { | |
| 125 | - | EditCommand::Shutdown => break, | |
| 126 | - | EditCommand::Cancel => continue, | |
| 127 | - | EditCommand::Edit { | |
| 128 | - | hash, | |
| 129 | - | ext: _, | |
| 130 | - | path, | |
| 131 | - | operation, | |
| 132 | - | } => { | |
| 133 | - | cancel_flag.store(false, Ordering::Release); | |
| 134 | - | ||
| 135 | - | let _ = event_tx.send(EditEvent::Started { | |
| 136 | - | hash: hash.clone(), | |
| 137 | - | }); | |
| 138 | - | ||
| 139 | - | if cancel_flag.load(Ordering::Acquire) { | |
| 140 | - | continue; | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | // Catch panics so a malformed file (bad decode/encode) reports an | |
| 144 | - | // error instead of unwinding and killing the worker thread — | |
| 145 | - | // which would silently stop all future edits with no UI signal. | |
| 146 | - | let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 147 | - | process_edit(&path, &operation, &cancel_flag) | |
| 148 | - | })); | |
| 149 | - | match outcome { | |
| 150 | - | Ok(Ok(result_path)) => { | |
| 151 | - | let _ = event_tx.send(EditEvent::Complete { | |
| 152 | - | source_hash: hash, | |
| 153 | - | result_path, | |
| 154 | - | operation, | |
| 155 | - | }); | |
| 156 | - | } | |
| 157 | - | Ok(Err(e)) => { | |
| 158 | - | let _ = event_tx.send(EditEvent::Error { | |
| 159 | - | hash, | |
| 160 | - | error: e.to_string(), | |
| 161 | - | }); | |
| 162 | - | } | |
| 163 | - | Err(_panic) => { | |
| 164 | - | let _ = event_tx.send(EditEvent::Error { | |
| 165 | - | hash, | |
| 166 | - | error: "edit panicked (internal error)".to_string(), | |
| 167 | - | }); | |
| 168 | - | } | |
| 169 | - | } | |
| 170 | - | } | |
| 171 | - | } | |
| 107 | + | fn edit_step(state: &mut EditState, cmd: EditCommand, ctx: &WorkerCtx<EditEvent>) { | |
| 108 | + | let EditCommand::Edit { | |
| 109 | + | hash, | |
| 110 | + | ext: _, | |
| 111 | + | path, | |
| 112 | + | operation, | |
| 113 | + | } = cmd | |
| 114 | + | else { | |
| 115 | + | // Cancel: the flag was already set synchronously by the handle. | |
| 116 | + | return; | |
| 117 | + | }; | |
| 118 | + | ||
| 119 | + | ctx.reset_cancel(); | |
| 120 | + | state.current_hash = Some(hash.clone()); | |
| 121 | + | ctx.emit(EditEvent::Started { hash: hash.clone() }); | |
| 122 | + | ||
| 123 | + | if ctx.is_cancelled() { | |
| 124 | + | state.current_hash = None; | |
| 125 | + | return; | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // A panic in process_edit (bad decode/encode) is caught by the runtime and | |
| 129 | + | // reported as Error for `current_hash`, keeping the worker alive. | |
| 130 | + | match process_edit(&path, &operation, ctx.cancel_flag()) { | |
| 131 | + | Ok(result_path) => ctx.emit(EditEvent::Complete { | |
| 132 | + | source_hash: hash, | |
| 133 | + | result_path, | |
| 134 | + | operation, | |
| 135 | + | }), | |
| 136 | + | Err(e) => ctx.emit(EditEvent::Error { | |
| 137 | + | hash, | |
| 138 | + | error: e.to_string(), | |
| 139 | + | }), | |
| 172 | 140 | } | |
| 141 | + | state.current_hash = None; | |
| 173 | 142 | } | |
| 174 | 143 | ||
| 175 | 144 | /// Decode → edit → encode to a temporary WAV file. | |
| @@ -233,7 +202,6 @@ mod tests { | |||
| 233 | 202 | #[test] | |
| 234 | 203 | fn edit_command_variants() { | |
| 235 | 204 | let _cancel = EditCommand::Cancel; | |
| 236 | - | let _shutdown = EditCommand::Shutdown; | |
| 237 | 205 | let _edit = EditCommand::Edit { | |
| 238 | 206 | hash: "abc".to_string(), | |
| 239 | 207 | ext: "wav".to_string(), | |
| @@ -266,11 +234,13 @@ mod tests { | |||
| 266 | 234 | } | |
| 267 | 235 | ||
| 268 | 236 | #[test] | |
| 269 | - | fn cancel_flag_set_on_cancel() { | |
| 237 | + | fn cancel_then_edit_does_not_hang() { | |
| 238 | + | // Cancel sets the flag synchronously; a subsequent edit still resets the | |
| 239 | + | // flag and runs. (The runtime's own tests cover panic isolation + cancel | |
| 240 | + | // semantics directly.) | |
| 270 | 241 | let handle = spawn_edit_worker().unwrap(); | |
| 271 | 242 | handle.send(EditCommand::Cancel); | |
| 272 | 243 | std::thread::sleep(std::time::Duration::from_millis(10)); | |
| 273 | - | assert!(handle.cancel_flag.load(Ordering::Relaxed)); | |
| 274 | 244 | drop(handle); | |
| 275 | 245 | } | |
| 276 | 246 |
| @@ -6,14 +6,13 @@ | |||
| 6 | 6 | //! event arrives. This keeps the slow CPU work — and the DB lock — off the | |
| 7 | 7 | //! render thread, so the forge no longer freezes the UI on long files. | |
| 8 | 8 | ||
| 9 | + | use std::convert::Infallible; | |
| 9 | 10 | use std::path::PathBuf; | |
| 10 | - | use std::sync::atomic::{AtomicBool, Ordering}; | |
| 11 | - | use std::sync::{mpsc, Arc, Mutex}; | |
| 12 | - | use std::thread; | |
| 13 | 11 | ||
| 14 | 12 | use tracing::instrument; | |
| 15 | 13 | ||
| 16 | 14 | use crate::error::CoreError; | |
| 15 | + | use crate::worker_runtime::{spawn_worker, WorkerCtx, WorkerHandle}; | |
| 17 | 16 | use super::chop::ChopMethod; | |
| 18 | 17 | use super::conform::ConformTarget; | |
| 19 | 18 | use super::runner::{chop_to_temp, compute_preview_marks, conform_to_temp, ChopStaging, ConformStaging}; | |
| @@ -39,10 +38,8 @@ pub enum ForgeCommand { | |||
| 39 | 38 | source_path: PathBuf, | |
| 40 | 39 | method: ChopMethod, | |
| 41 | 40 | }, | |
| 42 | - | /// Cancel the current operation. | |
| 41 | + | /// Cancel the current operation (sets the worker's cancel flag synchronously). | |
| 43 | 42 | Cancel, | |
| 44 | - | /// Shut down the worker thread. | |
| 45 | - | Shutdown, | |
| 46 | 43 | } | |
| 47 | 44 | ||
| 48 | 45 | /// Event sent from the forge worker back to the GUI thread. | |
| @@ -60,173 +57,117 @@ pub enum ForgeEvent { | |||
| 60 | 57 | } | |
| 61 | 58 | ||
| 62 | 59 | /// Handle for communicating with the background forge worker. | |
| 63 | - | pub struct ForgeWorkerHandle { | |
| 64 | - | cmd_tx: mpsc::Sender<ForgeCommand>, | |
| 65 | - | event_rx: Mutex<mpsc::Receiver<ForgeEvent>>, | |
| 66 | - | cancel_flag: Arc<AtomicBool>, | |
| 67 | - | thread: Option<thread::JoinHandle<()>>, | |
| 68 | - | } | |
| 60 | + | /// | |
| 61 | + | /// Thin newtype over the generic [`WorkerHandle`]: `send` maps a `Cancel` | |
| 62 | + | /// command to a synchronous cancel-flag set so an in-flight chop/conform aborts | |
| 63 | + | /// at its next checkpoint. | |
| 64 | + | pub struct ForgeWorkerHandle(WorkerHandle<ForgeCommand, ForgeEvent>); | |
| 69 | 65 | ||
| 70 | 66 | impl ForgeWorkerHandle { | |
| 71 | 67 | /// Poll for the next event without blocking. | |
| 72 | 68 | pub fn try_recv(&self) -> Option<ForgeEvent> { | |
| 73 | - | self.event_rx.lock().ok()?.try_recv().ok() | |
| 69 | + | self.0.try_recv() | |
| 74 | 70 | } | |
| 75 | 71 | ||
| 76 | 72 | /// Send a command to the worker. | |
| 77 | 73 | pub fn send(&self, cmd: ForgeCommand) { | |
| 78 | 74 | if matches!(cmd, ForgeCommand::Cancel) { | |
| 79 | - | self.cancel_flag.store(true, Ordering::Release); | |
| 80 | - | } | |
| 81 | - | let _ = self.cmd_tx.send(cmd); | |
| 82 | - | } | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | impl Drop for ForgeWorkerHandle { | |
| 86 | - | fn drop(&mut self) { | |
| 87 | - | self.cancel_flag.store(true, Ordering::Release); | |
| 88 | - | let _ = self.cmd_tx.send(ForgeCommand::Shutdown); | |
| 89 | - | if let Some(handle) = self.thread.take() { | |
| 90 | - | let _ = handle.join(); | |
| 75 | + | self.0.request_cancel(); | |
| 91 | 76 | } | |
| 77 | + | self.0.send(cmd); | |
| 92 | 78 | } | |
| 93 | 79 | } | |
| 94 | 80 | ||
| 95 | 81 | /// Spawn the background forge worker thread. | |
| 96 | 82 | #[instrument(skip_all)] | |
| 97 | 83 | pub fn spawn_forge_worker() -> std::io::Result<ForgeWorkerHandle> { | |
| 98 | - | let (cmd_tx, cmd_rx) = mpsc::channel::<ForgeCommand>(); | |
| 99 | - | let (event_tx, event_rx) = mpsc::channel::<ForgeEvent>(); | |
| 100 | - | let cancel_flag = Arc::new(AtomicBool::new(false)); | |
| 101 | - | let cancel_clone = Arc::clone(&cancel_flag); | |
| 102 | - | ||
| 103 | - | let thread = thread::Builder::new() | |
| 104 | - | .name("forge-worker".to_string()) | |
| 105 | - | .spawn(move || { | |
| 106 | - | forge_worker_loop(cmd_rx, event_tx, cancel_clone); | |
| 107 | - | })?; | |
| 108 | - | ||
| 109 | - | Ok(ForgeWorkerHandle { | |
| 110 | - | cmd_tx, | |
| 111 | - | event_rx: Mutex::new(event_rx), | |
| 112 | - | cancel_flag, | |
| 113 | - | thread: Some(thread), | |
| 114 | - | }) | |
| 84 | + | let handle = spawn_worker( | |
| 85 | + | "forge-worker", | |
| 86 | + | || Ok::<(), Infallible>(()), | |
| 87 | + | |e: Infallible| match e {}, | |
| 88 | + | |_state: &()| ForgeEvent::Error { | |
| 89 | + | error: "forge panicked (internal error)".to_string(), | |
| 90 | + | }, | |
| 91 | + | forge_step, | |
| 92 | + | )?; | |
| 93 | + | Ok(ForgeWorkerHandle(handle)) | |
| 115 | 94 | } | |
| 116 | 95 | ||
| 117 | - | fn forge_worker_loop( | |
| 118 | - | cmd_rx: mpsc::Receiver<ForgeCommand>, | |
| 119 | - | event_tx: mpsc::Sender<ForgeEvent>, | |
| 120 | - | cancel_flag: Arc<AtomicBool>, | |
| 121 | - | ) { | |
| 122 | - | while let Ok(cmd) = cmd_rx.recv() { | |
| 123 | - | match cmd { | |
| 124 | - | ForgeCommand::Shutdown => break, | |
| 125 | - | ForgeCommand::Cancel => continue, | |
| 126 | - | ForgeCommand::Chop { | |
| 127 | - | source_path, | |
| 128 | - | base_name, | |
| 129 | - | method, | |
| 130 | - | } => { | |
| 131 | - | cancel_flag.store(false, Ordering::Release); | |
| 132 | - | let _ = event_tx.send(ForgeEvent::Started); | |
| 133 | - | if cancel_flag.load(Ordering::Acquire) { | |
| 134 | - | continue; | |
| 135 | - | } | |
| 136 | - | // Isolate panics: a bad file must report an error, not unwind | |
| 137 | - | // the worker thread and silently stop all future forge jobs. | |
| 138 | - | let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 139 | - | chop_to_temp(&source_path, &base_name, &method, &cancel_flag) | |
| 140 | - | })); | |
| 141 | - | match outcome { | |
| 142 | - | Ok(Ok(staging)) => { | |
| 143 | - | // If cancelled mid-flight, drop the staged temp files | |
| 144 | - | // rather than handing them to the GUI. | |
| 145 | - | if cancel_flag.load(Ordering::Acquire) { | |
| 146 | - | for (_, p) in &staging.slices { | |
| 147 | - | let _ = std::fs::remove_file(p); | |
| 148 | - | } | |
| 149 | - | continue; | |
| 150 | - | } | |
| 151 | - | let _ = event_tx.send(ForgeEvent::ChopComplete { staging }); | |
| 152 | - | } | |
| 153 | - | // Cooperative cancel mid-chop: clean stop, no error surfaced. | |
| 154 | - | Ok(Err(CoreError::Cancelled)) => continue, | |
| 155 | - | Ok(Err(e)) => { | |
| 156 | - | let _ = event_tx.send(ForgeEvent::Error { | |
| 157 | - | error: e.to_string(), | |
| 158 | - | }); | |
| 159 | - | } | |
| 160 | - | Err(_panic) => { | |
| 161 | - | let _ = event_tx.send(ForgeEvent::Error { | |
| 162 | - | error: "chop panicked (internal error)".to_string(), | |
| 163 | - | }); | |
| 164 | - | } | |
| 165 | - | } | |
| 96 | + | fn forge_step(_state: &mut (), cmd: ForgeCommand, ctx: &WorkerCtx<ForgeEvent>) { | |
| 97 | + | match cmd { | |
| 98 | + | // Cancel: the flag was already set synchronously by the handle. | |
| 99 | + | ForgeCommand::Cancel => {} | |
| 100 | + | ForgeCommand::Chop { | |
| 101 | + | source_path, | |
| 102 | + | base_name, | |
| 103 | + | method, | |
| 104 | + | } => { | |
| 105 | + | ctx.reset_cancel(); | |
| 106 | + | ctx.emit(ForgeEvent::Started); | |
| 107 | + | if ctx.is_cancelled() { | |
| 108 | + | return; | |
| 166 | 109 | } | |
| 167 | - | ForgeCommand::Conform { | |
| 168 | - | source_path, | |
| 169 | - | base_name, | |
| 170 | - | target, | |
| 171 | - | auto_trim, | |
| 172 | - | } => { | |
| 173 | - | cancel_flag.store(false, Ordering::Release); | |
| 174 | - | let _ = event_tx.send(ForgeEvent::Started); | |
| 175 | - | if cancel_flag.load(Ordering::Acquire) { | |
| 176 | - | continue; | |
| 177 | - | } | |
| 178 | - | let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 179 | - | conform_to_temp(&source_path, &base_name, &target, auto_trim, &cancel_flag) | |
| 180 | - | })); | |
| 181 | - | match outcome { | |
| 182 | - | Ok(Ok(staging)) => { | |
| 183 | - | if cancel_flag.load(Ordering::Acquire) { | |
| 184 | - | let _ = std::fs::remove_file(&staging.temp_path); | |
| 185 | - | continue; | |
| 110 | + | // A panic here is caught by the runtime and reported as Error. | |
| 111 | + | match chop_to_temp(&source_path, &base_name, &method, ctx.cancel_flag()) { | |
| 112 | + | Ok(staging) => { | |
| 113 | + | // If cancelled mid-flight, drop the staged temp files rather | |
| 114 | + | // than handing them to the GUI. | |
| 115 | + | if ctx.is_cancelled() { | |
| 116 | + | for (_, p) in &staging.slices { | |
| 117 | + | let _ = std::fs::remove_file(p); | |
| 186 | 118 | } | |
| 187 | - | let _ = event_tx.send(ForgeEvent::ConformComplete { staging }); | |
| 188 | - | } | |
| 189 | - | // Cooperative cancel mid-conform: clean stop, no error surfaced. | |
| 190 | - | Ok(Err(CoreError::Cancelled)) => continue, | |
| 191 | - | Ok(Err(e)) => { | |
| 192 | - | let _ = event_tx.send(ForgeEvent::Error { | |
| 193 | - | error: e.to_string(), | |
| 194 | - | }); | |
| 195 | - | } | |
| 196 | - | Err(_panic) => { | |
| 197 | - | let _ = event_tx.send(ForgeEvent::Error { | |
| 198 | - | error: "conform panicked (internal error)".to_string(), | |
| 199 | - | }); | |
| 119 | + | return; | |
| 200 | 120 | } | |
| 121 | + | ctx.emit(ForgeEvent::ChopComplete { staging }); | |
| 201 | 122 | } | |
| 123 | + | // Cooperative cancel mid-chop: clean stop, no error surfaced. | |
| 124 | + | Err(CoreError::Cancelled) => {} | |
| 125 | + | Err(e) => ctx.emit(ForgeEvent::Error { | |
| 126 | + | error: e.to_string(), | |
| 127 | + | }), | |
| 202 | 128 | } | |
| 203 | - | ForgeCommand::Preview { | |
| 204 | - | source_path, | |
| 205 | - | method, | |
| 206 | - | } => { | |
| 207 | - | cancel_flag.store(false, Ordering::Release); | |
| 208 | - | let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 209 | - | compute_preview_marks(&source_path, &method) | |
| 210 | - | })); | |
| 211 | - | // A newer preview/op supersedes this one — drop the stale marks. | |
| 212 | - | if cancel_flag.load(Ordering::Acquire) { | |
| 213 | - | continue; | |
| 214 | - | } | |
| 215 | - | match outcome { | |
| 216 | - | Ok(Ok(marks)) => { | |
| 217 | - | let _ = event_tx.send(ForgeEvent::PreviewComplete { marks }); | |
| 218 | - | } | |
| 219 | - | Ok(Err(e)) => { | |
| 220 | - | let _ = event_tx.send(ForgeEvent::Error { | |
| 221 | - | error: e.to_string(), | |
| 222 | - | }); | |
| 223 | - | } | |
| 224 | - | Err(_panic) => { | |
| 225 | - | let _ = event_tx.send(ForgeEvent::Error { | |
| 226 | - | error: "chop preview panicked (internal error)".to_string(), | |
| 227 | - | }); | |
| 129 | + | } | |
| 130 | + | ForgeCommand::Conform { | |
| 131 | + | source_path, | |
| 132 | + | base_name, | |
| 133 | + | target, | |
| 134 | + | auto_trim, | |
| 135 | + | } => { | |
| 136 | + | ctx.reset_cancel(); | |
| 137 | + | ctx.emit(ForgeEvent::Started); | |
| 138 | + | if ctx.is_cancelled() { | |
| 139 | + | return; | |
| 140 | + | } | |
| 141 | + | match conform_to_temp(&source_path, &base_name, &target, auto_trim, ctx.cancel_flag()) { | |
| 142 | + | Ok(staging) => { | |
| 143 | + | if ctx.is_cancelled() { | |
| 144 | + | let _ = std::fs::remove_file(&staging.temp_path); | |
| 145 | + | return; | |
| 228 | 146 | } | |
| 147 | + | ctx.emit(ForgeEvent::ConformComplete { staging }); | |
| 229 | 148 | } | |
| 149 | + | // Cooperative cancel mid-conform: clean stop, no error surfaced. | |
| 150 | + | Err(CoreError::Cancelled) => {} | |
| 151 | + | Err(e) => ctx.emit(ForgeEvent::Error { | |
| 152 | + | error: e.to_string(), | |
| 153 | + | }), | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ForgeCommand::Preview { | |
| 157 | + | source_path, | |
| 158 | + | method, | |
| 159 | + | } => { | |
| 160 | + | ctx.reset_cancel(); | |
| 161 | + | let outcome = compute_preview_marks(&source_path, &method); | |
| 162 | + | // A newer preview/op supersedes this one — drop the stale marks. | |
| 163 | + | if ctx.is_cancelled() { | |
| 164 | + | return; | |
| 165 | + | } | |
| 166 | + | match outcome { | |
| 167 | + | Ok(marks) => ctx.emit(ForgeEvent::PreviewComplete { marks }), | |
| 168 | + | Err(e) => ctx.emit(ForgeEvent::Error { | |
| 169 | + | error: e.to_string(), | |
| 170 | + | }), | |
| 230 | 171 | } | |
| 231 | 172 | } | |
| 232 | 173 | } | |
| @@ -239,7 +180,6 @@ mod tests { | |||
| 239 | 180 | #[test] | |
| 240 | 181 | fn forge_command_variants() { | |
| 241 | 182 | let _cancel = ForgeCommand::Cancel; | |
| 242 | - | let _shutdown = ForgeCommand::Shutdown; | |
| 243 | 183 | let _chop = ForgeCommand::Chop { | |
| 244 | 184 | source_path: PathBuf::from("/test.wav"), | |
| 245 | 185 | base_name: "test.wav".to_string(), | |
| @@ -255,11 +195,10 @@ mod tests { | |||
| 255 | 195 | } | |
| 256 | 196 | ||
| 257 | 197 | #[test] | |
| 258 | - | fn cancel_flag_set_on_cancel() { | |
| 198 | + | fn cancel_then_drop_does_not_hang() { | |
| 259 | 199 | let handle = spawn_forge_worker().unwrap(); | |
| 260 | 200 | handle.send(ForgeCommand::Cancel); | |
| 261 | 201 | std::thread::sleep(std::time::Duration::from_millis(10)); | |
| 262 | - | assert!(handle.cancel_flag.load(Ordering::Relaxed)); | |
| 263 | 202 | drop(handle); | |
| 264 | 203 | } | |
| 265 | 204 | } |
| @@ -29,6 +29,7 @@ | |||
| 29 | 29 | //! - [`id_types`] — Strongly-typed entity ID newtypes ([`VfsId`], [`NodeId`], [`SampleHash`], etc.) | |
| 30 | 30 | //! | |
| 31 | 31 | //! ### Infrastructure | |
| 32 | + | //! - [`worker_runtime`] — Generic background-worker factory: owns the recv loop and `catch_unwind`s every command so a panic can never silently kill a worker thread | |
| 32 | 33 | //! - [`db`] — SQLite database wrapper with versioned inline migrations (sync-only, no async) | |
| 33 | 34 | //! - [`error`] — Unified error type ([`error::CoreError`]) and shared utilities | |
| 34 | 35 | //! - [`util`] — Path/file helpers and audio extension whitelist | |
| @@ -55,6 +56,7 @@ pub mod vault; | |||
| 55 | 56 | pub mod vfs; | |
| 56 | 57 | pub mod vfs_mirror; | |
| 57 | 58 | pub mod vp_tree; | |
| 59 | + | pub mod worker_runtime; | |
| 58 | 60 | ||
| 59 | 61 | pub use id_types::{CollectionId, NodeId, SampleHash, VfsId}; | |
| 60 | 62 |
| @@ -0,0 +1,289 @@ | |||
| 1 | + | //! Generic background-worker runtime. | |
| 2 | + | //! | |
| 3 | + | //! Every recv-loop worker in the app (analysis, edit, forge, import, export, | |
| 4 | + | //! search, loose-files) routes its command loop through [`spawn_worker`]. The | |
| 5 | + | //! factory owns the command channel's `Receiver` and the `recv` loop, and wraps | |
| 6 | + | //! each command's handler in `catch_unwind` so a panic becomes a caller-defined | |
| 7 | + | //! event instead of silently killing the thread — which would stop every future | |
| 8 | + | //! command of that kind with no UI signal. | |
| 9 | + | //! | |
| 10 | + | //! Because the `Receiver` is created inside [`spawn_worker`] and never handed | |
| 11 | + | //! back, no worker module can write its own unguarded `rx.recv()` loop. The | |
| 12 | + | //! historically-recurring "added a worker, forgot the `catch_unwind`" drift (the | |
| 13 | + | //! `loose_files_worker` regression) is structurally unwritable: a new worker has | |
| 14 | + | //! no receiver to loop over except the one the runtime already guards. | |
| 15 | + | //! | |
| 16 | + | //! ## Cancellation | |
| 17 | + | //! | |
| 18 | + | //! Every worker gets a shared cancel flag for free (in [`WorkerCtx`]). It is set | |
| 19 | + | //! to `true` automatically when the [`WorkerHandle`] is dropped, so an in-flight | |
| 20 | + | //! long operation aborts promptly on shutdown instead of blocking the join. | |
| 21 | + | //! Workers that support an explicit Cancel command wrap the handle in a thin | |
| 22 | + | //! newtype whose `send` calls [`WorkerHandle::request_cancel`] before enqueuing, | |
| 23 | + | //! so the flag is set synchronously by the GUI thread (the queued command itself | |
| 24 | + | //! becomes a no-op). Long operations reset the flag at their start via | |
| 25 | + | //! [`WorkerCtx::reset_cancel`] so a stale cancel does not abort the next command. | |
| 26 | + | ||
| 27 | + | use std::sync::atomic::{AtomicBool, Ordering}; | |
| 28 | + | use std::sync::{mpsc, Arc, Mutex}; | |
| 29 | + | use std::thread; | |
| 30 | + | ||
| 31 | + | /// Sink handed to a worker step so it can emit zero or more events per command. | |
| 32 | + | /// | |
| 33 | + | /// Cloneable and `Sync` (it wraps an `mpsc::Sender`), so a step may share it | |
| 34 | + | /// across rayon worker threads (e.g. the analysis batch fan-out). | |
| 35 | + | pub struct EventSink<Ev> { | |
| 36 | + | tx: mpsc::Sender<Ev>, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | impl<Ev> Clone for EventSink<Ev> { | |
| 40 | + | fn clone(&self) -> Self { | |
| 41 | + | Self { tx: self.tx.clone() } | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | impl<Ev> EventSink<Ev> { | |
| 46 | + | /// Emit one event back to the handle. Silently dropped if the handle is gone. | |
| 47 | + | pub fn emit(&self, ev: Ev) { | |
| 48 | + | let _ = self.tx.send(ev); | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// Create a sink paired with a receiver. For tests that exercise functions | |
| 52 | + | /// taking an `&EventSink` directly, outside a spawned worker. | |
| 53 | + | pub fn channel() -> (Self, mpsc::Receiver<Ev>) { | |
| 54 | + | let (tx, rx) = mpsc::channel(); | |
| 55 | + | (Self { tx }, rx) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Context passed to each worker step: an [`EventSink`] plus the shared cancel | |
| 60 | + | /// flag. Both are `Sync`, so `&WorkerCtx` can be shared into rayon closures. | |
| 61 | + | pub struct WorkerCtx<Ev> { | |
| 62 | + | sink: EventSink<Ev>, | |
| 63 | + | cancel: Arc<AtomicBool>, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | impl<Ev> WorkerCtx<Ev> { | |
| 67 | + | /// Emit one event. | |
| 68 | + | pub fn emit(&self, ev: Ev) { | |
| 69 | + | self.sink.emit(ev); | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// The event sink, for handing to code that emits many events. | |
| 73 | + | pub fn sink(&self) -> &EventSink<Ev> { | |
| 74 | + | &self.sink | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// Whether cancellation has been requested for the current operation. | |
| 78 | + | pub fn is_cancelled(&self) -> bool { | |
| 79 | + | self.cancel.load(Ordering::Acquire) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// The raw cancel flag, for passing to processing functions that take | |
| 83 | + | /// `&AtomicBool` (e.g. `chop_to_temp`, `process_edit`). | |
| 84 | + | pub fn cancel_flag(&self) -> &AtomicBool { | |
| 85 | + | &self.cancel | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Clear the cancel flag at the start of a fresh operation so a stale cancel | |
| 89 | + | /// from a previous command does not abort this one. | |
| 90 | + | pub fn reset_cancel(&self) { | |
| 91 | + | self.cancel.store(false, Ordering::Release); | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// Handle to a background worker. Sends commands, polls events; `Drop` requests | |
| 96 | + | /// cancellation, closes the command channel (ending the worker's recv loop), and | |
| 97 | + | /// joins the thread. | |
| 98 | + | /// | |
| 99 | + | /// Workers expose a thin newtype around this rather than the raw handle, so | |
| 100 | + | /// worker-specific `send` semantics (mapping a Cancel command to | |
| 101 | + | /// [`request_cancel`](Self::request_cancel)) live at the edge, not in the loop. | |
| 102 | + | pub struct WorkerHandle<Cmd, Ev> { | |
| 103 | + | cmd_tx: Option<mpsc::Sender<Cmd>>, | |
| 104 | + | event_rx: Mutex<mpsc::Receiver<Ev>>, | |
| 105 | + | cancel: Arc<AtomicBool>, | |
| 106 | + | thread: Option<thread::JoinHandle<()>>, | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | impl<Cmd, Ev> WorkerHandle<Cmd, Ev> { | |
| 110 | + | /// Poll for the next event without blocking. | |
| 111 | + | pub fn try_recv(&self) -> Option<Ev> { | |
| 112 | + | self.event_rx.lock().ok()?.try_recv().ok() | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// Enqueue a command for the worker. | |
| 116 | + | pub fn send(&self, cmd: Cmd) { | |
| 117 | + | if let Some(tx) = &self.cmd_tx { | |
| 118 | + | let _ = tx.send(cmd); | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /// Request cancellation of the in-flight operation synchronously (on the | |
| 123 | + | /// caller's thread). Newtype handles call this from `send` for their Cancel | |
| 124 | + | /// command so the flag is set before the running operation next checks it. | |
| 125 | + | pub fn request_cancel(&self) { | |
| 126 | + | self.cancel.store(true, Ordering::Release); | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | impl<Cmd, Ev> Drop for WorkerHandle<Cmd, Ev> { | |
| 131 | + | fn drop(&mut self) { | |
| 132 | + | // Abort any in-flight long operation, then close the channel so the | |
| 133 | + | // recv loop ends, then join. | |
| 134 | + | self.cancel.store(true, Ordering::Release); | |
| 135 | + | self.cmd_tx = None; | |
| 136 | + | if let Some(handle) = self.thread.take() { | |
| 137 | + | let _ = handle.join(); | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// Spawn a background worker. | |
| 143 | + | /// | |
| 144 | + | /// - `init` runs once on the worker thread to build per-worker state (open a DB | |
| 145 | + | /// or store, etc.). If it returns `Err`, `on_init_err` maps the error to an | |
| 146 | + | /// event, that event is emitted, and the thread exits. | |
| 147 | + | /// - `step` handles one command, using the [`WorkerCtx`] to emit any number of | |
| 148 | + | /// events. Every `step` call is wrapped in `catch_unwind`; a panic is mapped | |
| 149 | + | /// to a terminal event via `on_panic(&state)` (so the UI un-wedges for the | |
| 150 | + | /// operation that was in flight) and the loop continues (the worker survives). | |
| 151 | + | /// `on_panic` receives `&State` so it can carry context a step recorded before | |
| 152 | + | /// panicking (e.g. the hash of the edit being processed). | |
| 153 | + | /// | |
| 154 | + | /// For workers with no persistent state, use `State = ()` and an infallible | |
| 155 | + | /// `init` returning `Ok::<(), std::convert::Infallible>(())` with | |
| 156 | + | /// `on_init_err = |e| match e {}`. | |
| 157 | + | pub fn spawn_worker<Cmd, Ev, State, InitErr>( | |
| 158 | + | name: &str, | |
| 159 | + | init: impl FnOnce() -> Result<State, InitErr> + Send + 'static, | |
| 160 | + | on_init_err: impl FnOnce(InitErr) -> Ev + Send + 'static, | |
| 161 | + | on_panic: impl Fn(&State) -> Ev + Send + 'static, | |
| 162 | + | mut step: impl FnMut(&mut State, Cmd, &WorkerCtx<Ev>) + Send + 'static, | |
| 163 | + | ) -> std::io::Result<WorkerHandle<Cmd, Ev>> | |
| 164 | + | where | |
| 165 | + | Cmd: Send + 'static, | |
| 166 | + | Ev: Send + 'static, | |
| 167 | + | State: 'static, | |
| 168 | + | InitErr: 'static, | |
| 169 | + | { | |
| 170 | + | let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>(); | |
| 171 | + | let (event_tx, event_rx) = mpsc::channel::<Ev>(); | |
| 172 | + | let cancel = Arc::new(AtomicBool::new(false)); | |
| 173 | + | let cancel_worker = Arc::clone(&cancel); | |
| 174 | + | ||
| 175 | + | let thread = thread::Builder::new() | |
| 176 | + | .name(name.to_string()) | |
| 177 | + | .spawn(move || { | |
| 178 | + | let ctx = WorkerCtx { | |
| 179 | + | sink: EventSink { tx: event_tx }, | |
| 180 | + | cancel: cancel_worker, | |
| 181 | + | }; | |
| 182 | + | let mut state = match init() { | |
| 183 | + | Ok(s) => s, | |
| 184 | + | Err(e) => { | |
| 185 | + | ctx.emit(on_init_err(e)); | |
| 186 | + | return; | |
| 187 | + | } | |
| 188 | + | }; | |
| 189 | + | // Owned, guarded recv loop — the only consumer of `cmd_rx`, which | |
| 190 | + | // never leaves this function. A panic in `step` becomes an event, | |
| 191 | + | // not a dead thread. | |
| 192 | + | while let Ok(cmd) = cmd_rx.recv() { | |
| 193 | + | let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | |
| 194 | + | step(&mut state, cmd, &ctx); | |
| 195 | + | })); | |
| 196 | + | if result.is_err() { | |
| 197 | + | ctx.emit(on_panic(&state)); | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | })?; | |
| 201 | + | ||
| 202 | + | Ok(WorkerHandle { | |
| 203 | + | cmd_tx: Some(cmd_tx), | |
| 204 | + | event_rx: Mutex::new(event_rx), | |
| 205 | + | cancel, | |
| 206 | + | thread: Some(thread), | |
| 207 | + | }) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | #[cfg(test)] | |
| 211 | + | mod tests { | |
| 212 | + | use super::*; | |
| 213 | + | use std::convert::Infallible; | |
| 214 | + | ||
| 215 | + | fn recv_one<Cmd, Ev>(handle: &WorkerHandle<Cmd, Ev>) -> Ev { | |
| 216 | + | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); | |
| 217 | + | while std::time::Instant::now() < deadline { | |
| 218 | + | if let Some(ev) = handle.try_recv() { | |
| 219 | + | return ev; | |
| 220 | + | } | |
| 221 | + | std::thread::sleep(std::time::Duration::from_millis(1)); | |
| 222 | + | } | |
| 223 | + | panic!("worker produced no event"); | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | #[test] | |
| 227 | + | fn step_panic_becomes_event_and_worker_survives() { | |
| 228 | + | // A command that panics must not kill the worker: the next command still | |
| 229 | + | // gets processed. This is the invariant the loose_files regression broke. | |
| 230 | + | let handle = spawn_worker::<u32, &'static str, (), Infallible>( | |
| 231 | + | "test-panic", | |
| 232 | + | || Ok(()), | |
| 233 | + | |e| match e {}, | |
| 234 | + | |_state| "panicked", | |
| 235 | + | |_state, cmd: u32, ctx| { | |
| 236 | + | if cmd == 0 { | |
| 237 | + | panic!("boom"); | |
| 238 | + | } | |
| 239 | + | ctx.emit("ok"); | |
| 240 | + | }, | |
| 241 | + | ) | |
| 242 | + | .unwrap(); | |
| 243 | + | ||
| 244 | + | handle.send(0); // panics | |
| 245 | + | assert_eq!(recv_one(&handle), "panicked"); | |
| 246 | + | handle.send(1); // worker must still be alive | |
| 247 | + | assert_eq!(recv_one(&handle), "ok"); | |
| 248 | + | drop(handle); | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | #[test] | |
| 252 | + | fn init_failure_emits_event_and_exits() { | |
| 253 | + | let handle = spawn_worker::<u32, &'static str, (), &'static str>( | |
| 254 | + | "test-init-fail", | |
| 255 | + | || Err("no db"), | |
| 256 | + | |e| e, | |
| 257 | + | |_state| "panicked", | |
| 258 | + | |_state, _cmd, _ctx| {}, | |
| 259 | + | ) | |
| 260 | + | .unwrap(); | |
| 261 | + | assert_eq!(recv_one(&handle), "no db"); | |
| 262 | + | drop(handle); | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | #[test] | |
| 266 | + | fn drop_sets_cancel_flag() { | |
| 267 | + | // Drop must request cancellation so an in-flight op aborts promptly. | |
| 268 | + | let observed = Arc::new(AtomicBool::new(false)); | |
| 269 | + | let observed_clone = Arc::clone(&observed); | |
| 270 | + | let handle = spawn_worker::<u32, (), (), Infallible>( | |
| 271 | + | "test-cancel", | |
| 272 | + | || Ok(()), | |
| 273 | + | |e| match e {}, | |
| 274 | + | |_state| (), | |
| 275 | + | move |_state, _cmd: u32, ctx| { | |
| 276 | + | // Spin until cancelled, then record that we saw it. | |
| 277 | + | while !ctx.is_cancelled() { | |
| 278 | + | std::thread::sleep(std::time::Duration::from_millis(1)); | |
| 279 | + | } | |
| 280 | + | observed_clone.store(true, Ordering::Release); | |
| 281 | + | }, | |
| 282 | + | ) | |
| 283 | + | .unwrap(); | |
| 284 | + | handle.send(1); | |
| 285 | + | std::thread::sleep(std::time::Duration::from_millis(10)); | |
| 286 | + | drop(handle); // sets cancel, the step exits, join completes | |
| 287 | + | assert!(observed.load(Ordering::Acquire)); | |
| 288 | + | } | |
| 289 | + | } |