-
Notifications
You must be signed in to change notification settings - Fork 13
feat(lock): add locking for concurrent process safety #154
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,193 @@ | ||
| //! File-based locking mechanism for preventing concurrent operations. | ||
| //! | ||
| //! This module provides a simple file-based lock using a `.lock` file to ensure | ||
| //! that only one process can operate on a specific resource at a time. | ||
|
|
||
| use std::{ | ||
| fs::{self, File, OpenOptions}, | ||
| path::{Path, PathBuf}, | ||
| }; | ||
|
|
||
| use crate::error::{LockError, LockResult}; | ||
|
|
||
| /// A file-based lock using `flock`. | ||
| /// | ||
| /// The lock is automatically released when `FileLock` is dropped. | ||
| pub struct FileLock { | ||
| _file: nix::fcntl::Flock<File>, | ||
| path: PathBuf, | ||
| } | ||
|
|
||
| impl FileLock { | ||
| /// Get the default lock directory for soar. | ||
| /// | ||
| /// Uses `$XDG_RUNTIME_DIR/soar/locks` or falls back to `/tmp/soar-locks`. | ||
| fn lock_dir() -> LockResult<PathBuf> { | ||
| let xdg_runtime = std::env::var("XDG_RUNTIME_DIR").ok(); | ||
| let base = if let Some(ref runtime) = xdg_runtime { | ||
| PathBuf::from(runtime) | ||
| } else { | ||
| std::env::temp_dir() | ||
| }; | ||
|
|
||
| let lock_dir = base.join("soar").join("locks"); | ||
|
|
||
| if !lock_dir.exists() { | ||
| fs::create_dir_all(&lock_dir)?; | ||
| } | ||
|
|
||
| Ok(lock_dir) | ||
| } | ||
|
Comment on lines
+25
to
+40
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fallback lock directory in When
Since Proposed fix fn lock_dir() -> LockResult<PathBuf> {
let xdg_runtime = std::env::var("XDG_RUNTIME_DIR").ok();
let base = if let Some(ref runtime) = xdg_runtime {
PathBuf::from(runtime)
} else {
- std::env::temp_dir()
+ let uid = nix::unistd::getuid();
+ std::env::temp_dir().join(format!("soar-{}", uid))
};
- let lock_dir = base.join("soar").join("locks");
+ let lock_dir = base.join("soar").join("locks");🤖 Prompt for AI Agents
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not necessary.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| /// Generate a lock file path for a package. | ||
| fn lock_path(name: &str) -> LockResult<PathBuf> { | ||
| let lock_dir = Self::lock_dir()?; | ||
|
|
||
| // Sanitize the package name to ensure a valid filename | ||
| let sanitize = |s: &str| { | ||
| s.chars() | ||
| .map(|c| { | ||
| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { | ||
| c | ||
| } else { | ||
| '_' | ||
| } | ||
| }) | ||
| .collect::<String>() | ||
| }; | ||
|
Comment on lines
+46
to
+57
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sanitization could cause lock collisions between distinct package names. Characters outside 🤖 Prompt for AI Agents
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's fine. Package names shouldn't usually contain those characters.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| let filename = format!("{}.lock", sanitize(name)); | ||
| Ok(lock_dir.join(filename)) | ||
| } | ||
|
|
||
| /// Acquire an exclusive lock on a package. | ||
| /// | ||
| /// This will block until the lock can be acquired. | ||
| /// | ||
| /// # Arguments | ||
| /// | ||
| /// * `name` - Package name | ||
| /// | ||
| /// # Returns | ||
| /// | ||
| /// Returns a `FileLock` that will automatically release the lock when dropped. | ||
| pub fn acquire(name: &str) -> LockResult<Self> { | ||
| let lock_path = Self::lock_path(name)?; | ||
|
|
||
| let file = OpenOptions::new() | ||
| .write(true) | ||
| .create(true) | ||
| .truncate(false) | ||
| .open(&lock_path)?; | ||
|
|
||
| let file = nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive).map_err( | ||
| |(_, err)| LockError::AcquireFailed(format!("{}: {}", lock_path.display(), err)), | ||
| )?; | ||
|
|
||
| Ok(FileLock { | ||
| path: lock_path, | ||
| _file: file, | ||
| }) | ||
| } | ||
|
|
||
| /// Try to acquire an exclusive lock without blocking. | ||
| /// | ||
| /// Returns `None` if the lock is already held by another process. | ||
| /// | ||
| /// # Arguments | ||
| /// | ||
| /// * `name` - Package name | ||
| pub fn try_acquire(name: &str) -> LockResult<Option<Self>> { | ||
| let lock_path = Self::lock_path(name)?; | ||
|
|
||
| let file = OpenOptions::new() | ||
| .write(true) | ||
| .create(true) | ||
| .truncate(false) | ||
| .open(&lock_path)?; | ||
|
|
||
| match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusiveNonblock) { | ||
| Ok(file) => { | ||
| Ok(Some(FileLock { | ||
| path: lock_path, | ||
| _file: file, | ||
| })) | ||
| } | ||
| Err((_, err)) => { | ||
| if matches!(err, nix::errno::Errno::EWOULDBLOCK) { | ||
| return Ok(None); | ||
| } | ||
| Err(LockError::AcquireFailed(format!( | ||
| "{}: {}", | ||
| lock_path.display(), | ||
| err | ||
| ))) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Get the path to the lock file. | ||
| pub fn path(&self) -> &Path { | ||
| &self.path | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use std::{thread, time::Duration}; | ||
|
|
||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn test_lock_path_generation() { | ||
| let path = FileLock::lock_path("test-pkg").unwrap(); | ||
| assert!(path.to_string_lossy().ends_with("test-pkg.lock")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_lock_sanitization() { | ||
| let path = FileLock::lock_path("test/pkg").unwrap(); | ||
| assert!(path.to_string_lossy().contains("test_pkg")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_exclusive_lock() { | ||
| let lock1 = FileLock::acquire("test-exclusive").unwrap(); | ||
|
|
||
| let lock2 = FileLock::try_acquire("test-exclusive").unwrap(); | ||
| assert!(lock2.is_none(), "Should not be able to acquire lock"); | ||
|
|
||
| drop(lock1); | ||
|
|
||
| let lock3 = FileLock::try_acquire("test-exclusive").unwrap(); | ||
| assert!( | ||
| lock3.is_some(), | ||
| "Should be able to acquire lock after release" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_concurrent_locks_different_packages() { | ||
| let lock1 = FileLock::acquire("pkg-a").unwrap(); | ||
| let lock2 = FileLock::acquire("pkg-b").unwrap(); | ||
|
|
||
| assert!(lock1.path() != lock2.path()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_lock_blocks_until_released() { | ||
| let lock1 = FileLock::acquire("test-block").unwrap(); | ||
| let path = lock1.path().to_path_buf(); | ||
|
|
||
| let handle = thread::spawn(move || { | ||
| let lock2 = FileLock::acquire("test-block").unwrap(); | ||
| assert_eq!(lock2.path(), &path); | ||
| }); | ||
|
|
||
| thread::sleep(Duration::from_millis(100)); | ||
|
|
||
| drop(lock1); | ||
|
|
||
| handle.join().unwrap(); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: pkgforge/soar
Length of output: 88
🏁 Script executed:
Repository: pkgforge/soar
Length of output: 968
🏁 Script executed:
Repository: pkgforge/soar
Length of output: 1125
🏁 Script executed:
Repository: pkgforge/soar
Length of output: 39
🏁 Script executed:
Repository: pkgforge/soar
Length of output: 6806
🏁 Script executed:
Repository: pkgforge/soar
Length of output: 39
🏁 Script executed:
Repository: pkgforge/soar
Length of output: 39
🏁 Script executed:
Repository: pkgforge/soar
Length of output: 3306
🏁 Script executed:
Repository: pkgforge/soar
Length of output: 698
🏁 Script executed:
Repository: pkgforge/soar
Length of output: 166
🏁 Script executed:
Repository: pkgforge/soar
Length of output: 1896
Consider using a composite lock key if same package names can exist across different repositories.
The lock uses only
pkg_name, but packages throughout the codebase are identified by the composite tuple(repo_name, pkg_name, pkg_id). Database queries and deduplication logic all use this composite key pattern. If the samepkg_nameexists in different repositories, they'll unnecessarily serialize on a shared lock file. If this scenario is rare/acceptable, it's fine—but for consistency and to prevent unnecessary contention, consider using a composite key likeformat!("{}:{}:{}", repo_name, pkg_name, pkg_id)for the lock.🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Even though the different pkg_id or repo_name is used, they might symlink as a same binary resulting in conflicts.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@QaidVoid, that's a great point! You're absolutely right. Usingpkg_nameas the lock key makes sense because different package variants (differentpkg_idorrepo_name) that share the samepkg_namewill ultimately create symlinks with the same binary names in the bin directory. If they installed concurrently with a more granular lock, they'd race on those symlink creations.The current implementation correctly serializes installations at the
pkg_namelevel to prevent these symlink conflicts. Thank you for the clarification!✏️ Learnings added
🧠 Learnings used