Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/cross-process-model-download-locking.md
Original file line number Diff line number Diff line change
@@ -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 `<portable-root>/.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.
104 changes: 104 additions & 0 deletions src-tauri/src/model_download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,45 @@ 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<fs::File, AppError> {
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<DownloadStatus, AppError> {
let model_id = self.catalog_entry.id.clone();
self.download_catalog_model(&model_id).await
Expand All @@ -203,6 +242,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))?;
Expand Down Expand Up @@ -824,6 +866,68 @@ 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_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_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);

// 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 portable_root = PortableRootManager::from_root(root.clone());
let manager = ModelDownloadManager::new(portable_root);
assert!(manager.begin_root_install().is_err());
assert!(path.is_dir());
}

#[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 = 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();
Expand Down
Loading