use super::types::UpdateError;
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static LOCK_TOKEN_COUNTER: AtomicU64 = AtomicU64::new(0);
pub struct UpdateLock {
path: PathBuf,
body: String,
}
impl UpdateLock {
pub fn acquire() -> Result<Self, UpdateError> {
let path = get_lock_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
UpdateError::InstallFailed(format!("Failed to create lock dir: {}", e))
})?;
}
let mut attempted_sweep = false;
loop {
match exclusive_create(&path) {
Ok(mut file) => {
let body = new_lock_body();
file.write_all(body.as_bytes()).map_err(|e| {
let _ = fs::remove_file(&path);
UpdateError::InstallFailed(format!("Failed to write lock file: {}", e))
})?;
file.sync_all().map_err(|e| {
let _ = fs::remove_file(&path);
UpdateError::InstallFailed(format!("Failed to sync lock file: {}", e))
})?;
return Ok(Self { path, body });
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
let Some(existing_pid) = read_pid(&path) else {
return Err(UpdateError::LockHeld);
};
if is_process_running(existing_pid) {
return Err(UpdateError::LockHeld);
}
if attempted_sweep {
return Err(UpdateError::LockHeld);
}
attempted_sweep = true;
if !try_sweep_stale(&path, existing_pid) {
continue;
}
}
Err(e) => {
return Err(UpdateError::InstallFailed(format!(
"Failed to create lock file: {}",
e
)));
}
}
}
}
#[allow(dead_code)]
pub fn is_locked() -> bool {
if let Ok(path) = get_lock_path()
&& path.exists()
&& let Some(pid) = read_pid(&path)
{
return is_process_running(pid);
}
false
}
}
impl Drop for UpdateLock {
fn drop(&mut self) {
if read_lock_body(&self.path).as_deref() == Some(self.body.as_str()) {
let _ = fs::remove_file(&self.path);
}
}
}
fn get_lock_path() -> Result<PathBuf, UpdateError> {
let data_dir = dirs::data_dir().ok_or_else(|| {
UpdateError::InstallFailed("Could not determine data directory".to_string())
})?;
Ok(data_dir.join("rch/update.lock"))
}
fn exclusive_create(path: &Path) -> std::io::Result<File> {
OpenOptions::new().write(true).create_new(true).open(path)
}
fn new_lock_body() -> String {
let timestamp_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let counter = LOCK_TOKEN_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{} {timestamp_ns:x} {counter:x}", std::process::id())
}
fn read_lock_body(path: &Path) -> Option<String> {
let mut file = File::open(path).ok()?;
let mut contents = String::new();
file.read_to_string(&mut contents).ok()?;
Some(contents)
}
fn read_pid(path: &Path) -> Option<u32> {
let contents = read_lock_body(path)?;
contents.split_whitespace().next()?.parse().ok()
}
fn try_sweep_stale(path: &Path, observed_pid: u32) -> bool {
let current = read_pid(path);
if current != Some(observed_pid) {
return false;
}
match fs::remove_file(path) {
Ok(()) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
Err(_) => false,
}
}
fn is_process_running(pid: u32) -> bool {
#[cfg(target_os = "linux")]
{
let proc_path = format!("/proc/{}", pid);
std::path::Path::new(&proc_path).exists()
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("ps")
.args(["-p", &pid.to_string()])
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
#[cfg(windows)]
{
false
}
#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
{
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[test]
fn test_lock_acquire_and_release() {
let _guard = test_lock().lock().unwrap();
let lock1 = UpdateLock::acquire();
assert!(lock1.is_ok());
drop(lock1);
let lock3 = UpdateLock::acquire();
assert!(lock3.is_ok());
}
#[test]
fn test_is_locked_false_when_no_lock() {
let _guard = test_lock().lock().unwrap();
if let Ok(path) = get_lock_path() {
let _ = std::fs::remove_file(path);
}
assert!(!UpdateLock::is_locked());
}
#[test]
fn test_current_process_is_running() {
let pid = std::process::id();
assert!(is_process_running(pid));
}
#[test]
fn test_nonexistent_process_not_running() {
assert!(!is_process_running(999999999));
}
#[test]
fn test_lock_file_contains_pid() {
let _guard = test_lock().lock().unwrap();
if let Ok(path) = get_lock_path() {
let _ = std::fs::remove_file(&path);
}
let lock = UpdateLock::acquire().unwrap();
let path = get_lock_path().unwrap();
let our_pid = std::process::id();
assert_eq!(read_pid(&path), Some(our_pid));
drop(lock);
}
#[test]
fn test_lock_removed_on_drop() {
let _guard = test_lock().lock().unwrap();
if let Ok(path) = get_lock_path() {
let _ = std::fs::remove_file(&path);
}
let path = get_lock_path().unwrap();
{
let _lock = UpdateLock::acquire().unwrap();
assert!(path.exists());
}
assert!(!path.exists());
}
#[test]
fn test_stale_lock_from_dead_process() {
let _guard = test_lock().lock().unwrap();
let path = get_lock_path().unwrap();
let _ = std::fs::remove_file(&path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, "999999999").unwrap();
let lock = UpdateLock::acquire();
assert!(lock.is_ok());
drop(lock);
}
#[test]
fn test_malformed_lock_is_not_swept() {
let _guard = test_lock().lock().unwrap();
let path = get_lock_path().unwrap();
let _ = std::fs::remove_file(&path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, "").unwrap();
let result = UpdateLock::acquire();
assert!(
matches!(result, Err(UpdateError::LockHeld)),
"expected LockHeld for malformed lock"
);
assert!(path.exists(), "malformed lock must not be swept");
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_is_locked_with_active_lock() {
let _guard = test_lock().lock().unwrap();
if let Ok(path) = get_lock_path() {
let _ = std::fs::remove_file(&path);
}
assert!(!UpdateLock::is_locked());
let _lock = UpdateLock::acquire().unwrap();
assert!(UpdateLock::is_locked());
}
#[test]
fn test_drop_preserves_replaced_lock_body() {
let _guard = test_lock().lock().unwrap();
let path = get_lock_path().unwrap();
let _ = std::fs::remove_file(&path);
let old_lock = UpdateLock::acquire().expect("old lock acquire");
let replacement_body = new_lock_body();
std::fs::write(&path, &replacement_body).expect("replace lock body");
drop(old_lock);
let body_after_old_drop =
std::fs::read_to_string(&path).expect("replacement lock should remain");
assert_eq!(body_after_old_drop, replacement_body);
let replacement_lock = UpdateLock {
path: path.clone(),
body: replacement_body,
};
drop(replacement_lock);
assert!(!path.exists(), "replacement owner should remove its lock");
}
#[test]
fn test_read_pid_accepts_tokenized_lock_body() {
let _guard = test_lock().lock().unwrap();
let path = get_lock_path().unwrap();
let _ = std::fs::remove_file(&path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, "12345 abc def").unwrap();
assert_eq!(read_pid(&path), Some(12345));
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_is_process_running_with_zero_pid() {
let _ = is_process_running(0);
}
#[test]
fn test_is_process_running_with_pid_one() {
#[cfg(unix)]
{
assert!(is_process_running(1));
}
}
#[test]
fn test_second_acquire_without_drop_is_rejected() {
let _guard = test_lock().lock().unwrap();
let path = get_lock_path().unwrap();
let _ = std::fs::remove_file(&path);
let first = UpdateLock::acquire().expect("first acquire");
let result = UpdateLock::acquire();
assert!(
matches!(result, Err(UpdateError::LockHeld)),
"expected LockHeld for second acquire"
);
drop(first);
let third = UpdateLock::acquire().expect("re-acquire after drop");
drop(third);
}
#[test]
fn test_acquire_is_atomic_across_threads() -> Result<(), String> {
use std::sync::{Arc, Barrier};
let _guard = test_lock().lock().unwrap();
let path = get_lock_path().unwrap();
let _ = std::fs::remove_file(&path);
let thread_count = 8usize;
let barrier = Arc::new(Barrier::new(thread_count));
let mut handles = Vec::with_capacity(thread_count);
for _ in 0..thread_count {
let b = barrier.clone();
handles.push(std::thread::spawn(move || {
b.wait();
UpdateLock::acquire()
}));
}
let mut successes = Vec::new();
let mut lock_held = 0usize;
for h in handles {
match h.join().expect("join") {
Ok(lock) => successes.push(lock),
Err(UpdateError::LockHeld) => lock_held += 1,
Err(other) => return Err(format!("unexpected error: {other:?}")),
}
}
assert_eq!(
successes.len(),
1,
"exactly one thread must acquire the lock"
);
assert_eq!(
successes.len() + lock_held,
thread_count,
"all acquires must resolve to either success or LockHeld"
);
Ok(())
}
}