From 0dbeef7e18565f697f10f35bac63f390ad3d415f Mon Sep 17 00:00:00 2001 From: Shahanur Islam Shagor Date: Tue, 20 Oct 2026 10:17:18 -0400 Subject: [PATCH 1/4] fix: coordinate model installs across OpenMindAI processes with OS file lock --- src-tauri/src/model_download.rs | 103 ++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src-tauri/src/model_download.rs b/src-tauri/src/model_download.rs index 4ae5e9d..4d5afe5 100644 --- a/src-tauri/src/model_download.rs +++ b/src-tauri/src/model_download.rs @@ -193,6 +193,46 @@ impl ModelDownloadManager { }) } + /// Coordinate independent OpenMindAI processes sharing one portable root. + /// + /// This file is intentionally persistent: deleting a locked file can let + /// another process lock a different inode. The OS releases the advisory + /// lock automatically if the process exits or the handle is dropped. + fn begin_root_install(&self) -> Result { + let root = self.root.root(); + fs::create_dir_all(root)?; + let lock_path = root.join(".openmindai-model-install.lock"); + ensure_contained(root, &lock_path)?; + match fs::symlink_metadata(&lock_path) { + Ok(info) if info.file_type().is_symlink() || !info.file_type().is_file() => { + return Err(AppError::ModelDownloadFailed( + "model install lock is not a regular file".to_string(), + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&lock_path)?; + // A replaced symlink or moved portable root must not cause the + // downloader to coordinate against a file outside its root. + ensure_contained(root, &lock_path)?; + match file.try_lock() { + Ok(()) => Ok(file), + Err(fs::TryLockError::WouldBlock) => Err(AppError::ModelDownloadFailed( + "another OpenMindAI process is installing models in this portable root" + .to_string(), + )), + Err(fs::TryLockError::Error(error)) => Err(AppError::ModelDownloadFailed( + format!("cannot acquire model install lock: {error}"), + )), + } + } + pub async fn download_qwen_q4_k_m(&self) -> Result { let model_id = self.catalog_entry.id.clone(); self.download_catalog_model(&model_id).await @@ -203,6 +243,9 @@ impl ModelDownloadManager { // permit is shared across clones and held through the final manifest // write; a parallel caller cannot clobber an active install. let _in_flight = self.begin_download()?; + // Lock the root before changing status, cancellation, or disk files. + // Keep both guards alive until the install's final state is recorded. + let _root_lock = self.begin_root_install()?; let entry = entry_by_id(model_id)?; tracing::info!(repo = %entry.repo, model_id = %entry.id, "installing AI model package"); self.update_status(|status| *status = DownloadStatus::queued(&entry))?; @@ -824,6 +867,66 @@ mod tests { assert!(other_window.begin_download().is_ok()); } + #[test] + fn independent_managers_share_one_os_model_install_lock() { + let temp = tempfile::tempdir().unwrap(); + let shared_root = temp.path().join("OpenMindAI"); + let first = ModelDownloadManager::new(PortableRootManager::from_root( + shared_root.clone(), + )); + let second = ModelDownloadManager::new(PortableRootManager::from_root( + shared_root.clone(), + )); + let first_lock = first.begin_root_install().unwrap(); + let rejected = second.begin_root_install().unwrap_err().to_string(); + assert!(rejected.contains("another OpenMindAI process"), "{rejected}"); + assert_eq!(second.status().unwrap().state, DownloadState::Queued); + + // A separate portable installation must remain usable. + let other = ModelDownloadManager::new(PortableRootManager::from_root( + temp.path().join("OtherOpenMindAI"), + )); + let other_lock = other.begin_root_install().unwrap(); + drop(other_lock); + drop(first_lock); + + // The lock file must stay at its stable path, even after release. + assert!(shared_root.join(".openmindai-model-install.lock").is_file()); + let reacquired = second.begin_root_install().unwrap(); + drop(reacquired); + } + + #[test] + fn install_lock_rejects_symlink_or_non_file_destination() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("OpenMindAI"); + fs::create_dir_all(&root).unwrap(); + let path = root.join(".openmindai-model-install.lock"); + fs::create_dir(&path).unwrap(); + let manager = ModelDownloadManager::new(PortableRootManager::from_root(root.clone())); + assert!(manager.begin_root_install().is_err()); + assert!(path.is_dir()); + } + + #[test] + fn cross_process_lock_rejection_does_not_replace_download_status() { + let temp = tempfile::tempdir().unwrap(); + let root = PortableRootManager::from_root(temp.path().join("OpenMindAI")); + let owner = ModelDownloadManager::new(root.clone()); + let other = ModelDownloadManager::new(root); + let lock = owner.begin_root_install().unwrap(); + let before = other.status().unwrap(); + let rejected = tokio::runtime::Runtime::new().unwrap().block_on(async { + other.download_catalog_model("kokoro-82m-onnx").await + }); + assert!(rejected.is_err()); + assert!(rejected.unwrap_err().to_string().contains("another OpenMindAI process")); + let after = other.status().unwrap(); + assert_eq!(after.model_id, before.model_id); + assert_eq!(after.state, before.state); + drop(lock); + } + #[test] fn pause_and_cancel_when_idle_do_not_corrupt_download_status() { let temp = tempfile::tempdir().unwrap(); From b3e0536418a85db7cf5d6f29273b15f4fb3ef6b9 Mon Sep 17 00:00:00 2001 From: Shahanur Islam Shagor Date: Tue, 20 Oct 2026 10:18:12 -0400 Subject: [PATCH 2/4] test: simplify platform-independent model install locking regression fixtures --- src-tauri/src/model_download.rs | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/model_download.rs b/src-tauri/src/model_download.rs index 4d5afe5..6a21725 100644 --- a/src-tauri/src/model_download.rs +++ b/src-tauri/src/model_download.rs @@ -871,21 +871,18 @@ mod tests { fn independent_managers_share_one_os_model_install_lock() { let temp = tempfile::tempdir().unwrap(); let shared_root = temp.path().join("OpenMindAI"); - let first = ModelDownloadManager::new(PortableRootManager::from_root( - shared_root.clone(), - )); - let second = ModelDownloadManager::new(PortableRootManager::from_root( - shared_root.clone(), - )); + let first_root = PortableRootManager::from_root(shared_root.clone()); + let second_root = PortableRootManager::from_root(shared_root.clone()); + let first = ModelDownloadManager::new(first_root); + let second = ModelDownloadManager::new(second_root); let first_lock = first.begin_root_install().unwrap(); let rejected = second.begin_root_install().unwrap_err().to_string(); assert!(rejected.contains("another OpenMindAI process"), "{rejected}"); assert_eq!(second.status().unwrap().state, DownloadState::Queued); // A separate portable installation must remain usable. - let other = ModelDownloadManager::new(PortableRootManager::from_root( - temp.path().join("OtherOpenMindAI"), - )); + let other_root = PortableRootManager::from_root(temp.path().join("OtherOpenMindAI")); + let other = ModelDownloadManager::new(other_root); let other_lock = other.begin_root_install().unwrap(); drop(other_lock); drop(first_lock); @@ -903,22 +900,21 @@ mod tests { fs::create_dir_all(&root).unwrap(); let path = root.join(".openmindai-model-install.lock"); fs::create_dir(&path).unwrap(); - let manager = ModelDownloadManager::new(PortableRootManager::from_root(root.clone())); + let portable_root = PortableRootManager::from_root(root.clone()); + let manager = ModelDownloadManager::new(portable_root); assert!(manager.begin_root_install().is_err()); assert!(path.is_dir()); } - #[test] - fn cross_process_lock_rejection_does_not_replace_download_status() { + #[tokio::test] + async fn cross_process_lock_rejection_does_not_replace_download_status() { let temp = tempfile::tempdir().unwrap(); let root = PortableRootManager::from_root(temp.path().join("OpenMindAI")); let owner = ModelDownloadManager::new(root.clone()); let other = ModelDownloadManager::new(root); let lock = owner.begin_root_install().unwrap(); let before = other.status().unwrap(); - let rejected = tokio::runtime::Runtime::new().unwrap().block_on(async { - other.download_catalog_model("kokoro-82m-onnx").await - }); + let rejected = other.download_catalog_model("kokoro-82m-onnx").await; assert!(rejected.is_err()); assert!(rejected.unwrap_err().to_string().contains("another OpenMindAI process")); let after = other.status().unwrap(); From 26d062dc733e3cd3312df5cc37a9f4542d26e6c7 Mon Sep 17 00:00:00 2001 From: Shahanur Islam Shagor Date: Tue, 20 Oct 2026 10:18:36 -0400 Subject: [PATCH 3/4] docs: describe cross-process portable model installation locking and guarantees --- docs/cross-process-model-download-locking.md | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/cross-process-model-download-locking.md diff --git a/docs/cross-process-model-download-locking.md b/docs/cross-process-model-download-locking.md new file mode 100644 index 0000000..9dd7e84 --- /dev/null +++ b/docs/cross-process-model-download-locking.md @@ -0,0 +1,45 @@ +# Cross-process model installation coordination + +OpenMindAI can use the same portable root from two independent desktop +processes. The in-memory one-install permit shared by the views in a single +process cannot prevent a second process from writing the same model, +dependency or manifest paths. + +Each Model Download Manager now takes a **nonblocking exclusive OS file +lock** on `/.openmindai-model-install.lock` before changing +download status, cancellation state, partially downloaded data or installed +model manifests. The lock is held by the file handle until the full install +returns. A second process trying to install to the same root gets a clear +message rather than overwriting files or silently stealing progress. +Independent roots can still install separately. + +This uses the Rust standard library `File::try_lock` API (stable since +Rust 1.89) and therefore does not introduce another package dependency. +Windows uses `LockFileEx`; Unix uses operating-system file locks. Crashes +or other premature process exits automatically release the handle's lock, +so no PID-based stale-lock deletion is needed. + +**Do not delete or replace the lock file.** Its persistent path and inode +are part of the coordination protocol. The code rejects an existing +non-regular lock destination or symlink, checks root containment, and +fails closed when a filesystem does not support an OS lock. A lock file +left after a crash is normal and can be reused. + +## Coverage and limitations + +Rust regression tests verify that two separately instantiated managers +pointing at the same root cannot both hold the lock, that a different root +remains independent, that a released lock is reusable, and that a refused +install does not overwrite an idle manager's status. An invalid lock path +must be rejected without modifying it. + +This is an advisory protocol. Any unrelated writer that ignores this file +lock can still modify assets, and a network/removable filesystem may have +OS-specific locking behavior. All cooperating OpenMindAI model installs +must use the same protocol and must keep the lock file persistent. +This also does not claim power-loss transactional updates across both +model manifests. Checkpoint, voice and config hash validation remains +independent of the lock. + +The real Model Download Manager install + speech, Windows/macOS/Linux +Rust tests, Security and Release Readiness are required before merge. From f200ddef180bd09c79b5612eb11e9c67721f9b76 Mon Sep 17 00:00:00 2001 From: Shahanur Islam Shagor Date: Tue, 20 Oct 2026 12:56:30 -0400 Subject: [PATCH 4/4] fix: apply rustfmt to cross-process install lock and regression checks --- src-tauri/src/model_download.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/model_download.rs b/src-tauri/src/model_download.rs index 6a21725..296cbdc 100644 --- a/src-tauri/src/model_download.rs +++ b/src-tauri/src/model_download.rs @@ -224,12 +224,11 @@ impl ModelDownloadManager { match file.try_lock() { Ok(()) => Ok(file), Err(fs::TryLockError::WouldBlock) => Err(AppError::ModelDownloadFailed( - "another OpenMindAI process is installing models in this portable root" - .to_string(), - )), - Err(fs::TryLockError::Error(error)) => Err(AppError::ModelDownloadFailed( - format!("cannot acquire model install lock: {error}"), + "another OpenMindAI process is installing models in this portable root".to_string(), )), + Err(fs::TryLockError::Error(error)) => Err(AppError::ModelDownloadFailed(format!( + "cannot acquire model install lock: {error}" + ))), } } @@ -877,7 +876,10 @@ mod tests { let second = ModelDownloadManager::new(second_root); let first_lock = first.begin_root_install().unwrap(); let rejected = second.begin_root_install().unwrap_err().to_string(); - assert!(rejected.contains("another OpenMindAI process"), "{rejected}"); + assert!( + rejected.contains("another OpenMindAI process"), + "{rejected}" + ); assert_eq!(second.status().unwrap().state, DownloadState::Queued); // A separate portable installation must remain usable. @@ -916,7 +918,10 @@ mod tests { let before = other.status().unwrap(); let rejected = other.download_catalog_model("kokoro-82m-onnx").await; assert!(rejected.is_err()); - assert!(rejected.unwrap_err().to_string().contains("another OpenMindAI process")); + assert!(rejected + .unwrap_err() + .to_string() + .contains("another OpenMindAI process")); let after = other.status().unwrap(); assert_eq!(after.model_id, before.model_id); assert_eq!(after.state, before.state);