-
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
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
📝 WalkthroughWalkthroughAdds a file-based cross-process locking mechanism and integrates it into package installation; updates error types and exports, adjusts install logic to acquire locks with retry and re-checks to avoid double-installation, and adds a nix Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Installer
participant Lock as FileLock
participant FS as File System
participant DB as CoreRepository
participant Worker as Install Task
Client->>Lock: acquire(package_name) (block, retry 500ms)
activate Lock
Lock->>FS: open/create .lock file
FS-->>Lock: file handle
Lock->>FS: flock exclusive
FS-->>Lock: lock acquired
deactivate Lock
Client->>DB: is_installed(package_name)?
activate DB
DB-->>Client: installed? (yes/no)
deactivate DB
alt not installed
Client->>Worker: perform installation
activate Worker
Worker-->>Client: install_result (install_dir)
deactivate Worker
else already installed
Client-->>Client: log & skip installation
end
Client->>Lock: drop FileLock (unlock on drop)
activate Lock
Lock->>FS: release lock
deactivate Lock
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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.
Actionable comments posted: 5
🤖 Fix all issues with AI agents
In `@crates/soar-cli/src/install.rs`:
- Around line 1094-1116: The loop in install_single_package uses
std::thread::sleep which blocks the tokio worker thread; replace that blocking
call with an awaitable tokio::time::sleep and make the surrounding
lock-acquisition loop async-aware so you can .await the sleep. Locate the loop
that calls FileLock::try_acquire and change
std::thread::sleep(Duration::from_millis(500)) to
tokio::time::sleep(Duration::from_millis(500)).await, ensuring
install_single_package remains async and any error mapping around the loop (the
.map_err and SoarError::Custom handling) is preserved.
- Around line 1108-1116: The code double-wraps the same message: the Err(err)
arm constructs SoarError::Custom("Failed to acquire package lock: {err}") and
then the chained .map_err(...) wraps it again. Remove the trailing .map_err(|e|
SoarError::Custom(format!("Failed to acquire package lock: {}", e))) so the Err
arm’s SoarError::Custom is returned directly (or alternatively, change the Err
arm to propagate the original error and keep the map_err—pick one); reference
the Err(err) arm and the .map_err call around the package-lock acquisition and
ensure only a single SoarError::Custom formatting is performed.
In `@crates/soar-utils/src/lock.rs`:
- Around line 46-57: The current sanitize closure in lock.rs maps all non
[a-zA-Z0-9._-] chars to '_' which can cause distinct package names (e.g.,
"pkg/foo" vs "pkg@foo") to collide; update the lock filename generation to
append or replace the sanitized name with a short collision-resistant digest
(e.g., a hex or base32 hash of the original package name) so filenames remain
filesystem-safe but unique — locate the sanitize closure and the code that
builds the lock filename and change it to include a hash of the original string
(or use the hash as the filename with the sanitized name as a prefix) to avoid
collisions while preserving readability.
- Around line 135-138: The Drop implementation for FileLock currently unlinks
the lock file (fs::remove_file) which creates a race where a new path can be
created while another process still holds the inode lock; remove the unlink from
FileLock::drop so the lock file is not deleted on drop (leave the file on disk
after releasing the flock), or implement the alternative verification: after
acquiring the flock in the FileLock constructor/lock method, compare the locked
fd's inode+device via fstat with the path's stat and retry if they differ;
update references to FileLock, Drop::drop, and any lock-acquisition code paths
used by install.rs accordingly.
- Around line 25-40: The fallback in lock_dir() uses temp_dir which is
world-shared; change lock_dir() so that when XDG_RUNTIME_DIR is None you append
a per-user component (e.g., "soar-{uid}") to the temp_dir before joining "locks"
to isolate users; obtain the current UID (via libc::getuid() or a crate helper)
and incorporate it into the base path used when std::env::var("XDG_RUNTIME_DIR")
is None, then create that directory as before (fs::create_dir_all) so locks are
placed under a user-specific temp subdirectory.
🧹 Nitpick comments (3)
crates/soar-utils/src/lock.rs (1)
74-91:acquire()blocks indefinitely with no timeout.
Flock::lockwithLockExclusivewill block forever if the holding process hangs or crashes without releasing. Consider documenting this behavior, or providing an optional timeout (e.g., converting to atry_acquireloop with a maximum attempt count, similar to howinstall.rsalready retries).crates/soar-cli/src/install.rs (2)
1094-1116: No upper bound on lock retry attempts — can spin indefinitely.If a lock is held indefinitely (e.g., a crashed process that left a stale
flock— though flock is released on process exit, edge cases exist), this loop never terminates. Consider adding a maximum attempt count or total timeout, and returning an error if exceeded.
1042-1050: Using an emptyPathBufas a sentinel for "skipped" is fragile.Returning
PathBuf::new()to signal "not actually installed" couples the caller to checkingis_empty()on the path. A more explicit approach would be to wrap the return type in anOptionor a dedicated enum, making the "skipped" case unambiguous.
| 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) | ||
| } |
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.
Fallback lock directory in /tmp is world-writable — potential for cross-user interference.
When XDG_RUNTIME_DIR is unset, the lock directory falls back to <temp_dir>/soar/locks (typically /tmp/soar/locks). Any user can create or hold lock files there, which could cause:
- A denial-of-service where another user holds the lock indefinitely
- Permission errors if the directory was created by a different user
Since soar is a per-user package manager, consider including the UID in the fallback path (e.g., /tmp/soar-{uid}/locks) to isolate users.
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
In `@crates/soar-utils/src/lock.rs` around lines 25 - 40, The fallback in
lock_dir() uses temp_dir which is world-shared; change lock_dir() so that when
XDG_RUNTIME_DIR is None you append a per-user component (e.g., "soar-{uid}") to
the temp_dir before joining "locks" to isolate users; obtain the current UID
(via libc::getuid() or a crate helper) and incorporate it into the base path
used when std::env::var("XDG_RUNTIME_DIR") is None, then create that directory
as before (fs::create_dir_all) so locks are placed under a user-specific temp
subdirectory.
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.
Not necessary.
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, understood. I'll defer to your judgment on this.
(\_/)
( •_•)
/ > ✓
| // 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>() | ||
| }; |
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.
Sanitization could cause lock collisions between distinct package names.
Characters outside [a-zA-Z0-9._-] are all mapped to _, so e.g. "pkg/foo" and "pkg@foo" would both produce "pkg_foo.lock". If package names can contain such characters, consider using a hash-based filename or a more collision-resistant encoding.
🤖 Prompt for AI Agents
In `@crates/soar-utils/src/lock.rs` around lines 46 - 57, The current sanitize
closure in lock.rs maps all non [a-zA-Z0-9._-] chars to '_' which can cause
distinct package names (e.g., "pkg/foo" vs "pkg@foo") to collide; update the
lock filename generation to append or replace the sanitized name with a short
collision-resistant digest (e.g., a hex or base32 hash of the original package
name) so filenames remain filesystem-safe but unique — locate the sanitize
closure and the code that builds the lock filename and change it to include a
hash of the original string (or use the hash as the filename with the sanitized
name as a prefix) to avoid collisions while preserving readability.
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.
It's fine. Package names shouldn't usually contain those characters.
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, understood! I've noted that for future reference.
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.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@crates/soar-cli/src/install.rs`:
- Line 1096: The lock currently calls
FileLock::try_acquire(&target.package.pkg_name) which only uses pkg_name; change
it to use a composite key built from the package identity used elsewhere
(repo_name, pkg_name, pkg_id) so locks are unique per package instance. Locate
the call to FileLock::try_acquire in install.rs (the line with
FileLock::try_acquire(&target.package.pkg_name)) and replace the argument with a
formatted composite string like format!("{repo}:{name}:{id}") using
target.repository.repo_name (or equivalent), target.package.pkg_name, and
target.package.pkg_id to construct the lock key. Ensure the composite string is
deterministic and used wherever package-level locking is expected so different
repos with the same pkg_name do not contend on the same lock.
🧹 Nitpick comments (2)
crates/soar-utils/src/lock.rs (1)
74-91:acquire()blocks indefinitely with no timeout.The blocking
Flock::lockcall has no timeout mechanism. If a peer process stalls (not crashes — the kernel reclaims on exit), callers will hang forever. The install path ininstall.rswisely usestry_acquirein a polling loop, butacquireis apubmethod. Consider either documenting the no-timeout semantics prominently or providing aacquire_timeout(Duration)variant.crates/soar-cli/src/install.rs (1)
1042-1050: Using an emptyPathBufas a sentinel for "skipped" is fragile.The skip signal from
install_single_packagereturning(PathBuf::new(), Vec::new())(line 1143) is detected here viainstall_dir.as_os_str().is_empty(). This works but couples two distant code sites via an implicit contract on an empty path. A more explicit approach would be to change the return type (e.g.,Option<(PathBuf, Vec<…>)>whereNone= skipped), which would make the skip case self-documenting and compiler-enforced.
|
|
||
| let mut lock_attempts = 0; | ||
| let _package_lock = loop { | ||
| match FileLock::try_acquire(&target.package.pkg_name) { |
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:
#!/bin/bash
# Find and examine the FileLock implementation
find . -name "lock.rs" -o -name "*lock*" | grep -E "\.rs$" | head -10Repository: pkgforge/soar
Length of output: 88
🏁 Script executed:
#!/bin/bash
# Check the install.rs context around line 1096
sed -n '1085,1110p' crates/soar-cli/src/install.rsRepository: pkgforge/soar
Length of output: 968
🏁 Script executed:
#!/bin/bash
# Search for FileLock::try_acquire usage to understand the pattern
rg -n 'FileLock::try_acquire' --type rust -B2 -A2Repository: pkgforge/soar
Length of output: 1125
🏁 Script executed:
#!/bin/bash
# Check if pkg_name is unique across repos or if composite keys are used elsewhere
rg -n '(repo_name|pkg_name|pkg_id)' crates/soar-utils/src/ --type rust | head -30Repository: pkgforge/soar
Length of output: 39
🏁 Script executed:
#!/bin/bash
# Read the FileLock implementation
cat -n crates/soar-utils/src/lock.rsRepository: pkgforge/soar
Length of output: 6806
🏁 Script executed:
#!/bin/bash
# Search for package structures and how repo_name is associated with pkg_name
rg -n 'pub struct Package|struct Package' --type rust -A10 crates/soar-utils/src/Repository: pkgforge/soar
Length of output: 39
🏁 Script executed:
#!/bin/bash
# Check if pkg_name is guaranteed unique across repos or if there's documentation
rg -n 'pkg_name.*unique|unique.*pkg_name' --type rust -iRepository: pkgforge/soar
Length of output: 39
🏁 Script executed:
#!/bin/bash
# Search for how packages are stored/queried to understand if pkg_name is unique within repos only
rg -n 'repo_name.*pkg_name|pkg_name.*repo_name' --type rust -B2 -A2 | head -50Repository: pkgforge/soar
Length of output: 3306
🏁 Script executed:
#!/bin/bash
# Check the apply.rs file around the context mentioned in the learning
sed -n '140,160p' crates/soar-cli/src/apply.rsRepository: pkgforge/soar
Length of output: 698
🏁 Script executed:
#!/bin/bash
# Search for package query patterns to see how uniqueness is handled
rg -n 'WHERE|SELECT' --type rust -B1 -A1 | grep -E 'repo_name|pkg_name|pkg_id' | head -20Repository: pkgforge/soar
Length of output: 166
🏁 Script executed:
#!/bin/bash
# Check if there's documentation or tests showing packages with same name from different repos
rg -n 'different.*repo|multiple.*repo' --type rust -i -C2 | head -30Repository: 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 same pkg_name exists 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 like format!("{}:{}:{}", repo_name, pkg_name, pkg_id) for the lock.
🤖 Prompt for AI Agents
In `@crates/soar-cli/src/install.rs` at line 1096, The lock currently calls
FileLock::try_acquire(&target.package.pkg_name) which only uses pkg_name; change
it to use a composite key built from the package identity used elsewhere
(repo_name, pkg_name, pkg_id) so locks are unique per package instance. Locate
the call to FileLock::try_acquire in install.rs (the line with
FileLock::try_acquire(&target.package.pkg_name)) and replace the argument with a
formatted composite string like format!("{repo}:{name}:{id}") using
target.repository.repo_name (or equivalent), target.package.pkg_name, and
target.package.pkg_id to construct the lock key. Ensure the composite string is
deterministic and used wherever package-level locking is expected so different
repos with the same pkg_name do not contend on the same lock.
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. Using pkg_name as the lock key makes sense because different package variants (different pkg_id or repo_name) that share the same pkg_name will 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_name level to prevent these symlink conflicts. Thank you for the clarification!
✏️ Learnings added
Learnt from: QaidVoid
Repo: pkgforge/soar PR: 154
File: crates/soar-cli/src/install.rs:1096-1096
Timestamp: 2026-02-11T16:23:54.342Z
Learning: In crates/soar-cli/src/install.rs, FileLock uses only pkg_name (not a composite key with repo_name/pkg_id) intentionally. Different package variants with the same pkg_name but different pkg_id or repo_name will symlink to the same binary names in the bin directory. Locking at the pkg_name level prevents symlink race conditions during concurrent installations of different variants.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
🧠 Learnings used
Learnt from: QaidVoid
Repo: pkgforge/soar PR: 137
File: crates/soar-cli/src/apply.rs:149-153
Timestamp: 2026-01-09T16:14:21.516Z
Learning: In crates/soar-cli/src/apply.rs, for URL packages, passing non-installed records (is_installed=false) as existing_install is intentional. The query filters by repo_name, pkg_name, and pkg_id, ensuring the record is for the same package. This allows resuming partial installs and preserving settings (portable paths, profile) from previous installs.
Summary by CodeRabbit
New Features
Bug Fixes
Chores