src/sync_lock.rs
Ref: Size: 6.7 KiB
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::error::Error;
/// Default staleness threshold in minutes.
const STALE_THRESHOLD_MINUTES: u64 = 10;
/// Metadata stored in the lockfile for stale detection and error reporting.
#[derive(Debug, Serialize, Deserialize)]
pub struct SyncLockInfo {
pub pid: u32,
pub timestamp: String,
}
impl SyncLockInfo {
/// Serialize to JSON string.
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("SyncLockInfo serialization should not fail")
}
/// Deserialize from JSON string.
pub fn from_json(json: &str) -> Result<Self, Error> {
Ok(serde_json::from_str(json)?)
}
}
/// RAII guard that owns the lockfile. Deletes the lockfile on drop.
#[derive(Debug)]
pub struct SyncLock {
pub lock_path: PathBuf,
}
impl SyncLock {
/// Attempt to acquire the sync lock for the given repository.
///
/// Creates `.git/collab/sync.lock` atomically using `create_new(true)`.
/// If the lockfile already exists, checks for staleness before returning an error.
pub fn acquire(repo: &git2::Repository) -> Result<Self, Error> {
let collab_dir = repo.path().join("collab");
fs::create_dir_all(&collab_dir)?;
let lock_path = collab_dir.join("sync.lock");
match Self::try_create_lock(&lock_path) {
Ok(lock) => Ok(lock),
Err(_) => {
// Lockfile exists — check if stale
Self::handle_existing_lock(&lock_path)
}
}
}
/// Try to atomically create the lockfile. Returns Err on any failure.
fn try_create_lock(lock_path: &PathBuf) -> Result<Self, Error> {
let info = SyncLockInfo {
pid: std::process::id(),
timestamp: chrono::Utc::now().to_rfc3339(),
};
let json = info.to_json();
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(lock_path)?;
file.write_all(json.as_bytes())?;
file.flush()?;
Ok(SyncLock {
lock_path: lock_path.clone(),
})
}
/// Handle the case where a lockfile already exists.
/// Check staleness and either clean up and retry, or return SyncLocked error.
fn handle_existing_lock(lock_path: &PathBuf) -> Result<Self, Error> {
let content = match fs::read_to_string(lock_path) {
Ok(c) => c,
Err(_) => {
// Can't read — treat as corrupted/stale
Self::remove_stale_and_retry(lock_path)?;
return Self::try_create_or_contention(lock_path);
}
};
let info = match SyncLockInfo::from_json(&content) {
Ok(info) => info,
Err(_) => {
// Corrupted JSON — treat as stale
Self::remove_stale_and_retry(lock_path)?;
return Self::try_create_or_contention(lock_path);
}
};
if is_stale(&info, STALE_THRESHOLD_MINUTES) {
Self::remove_stale_and_retry(lock_path)?;
return Self::try_create_or_contention(lock_path);
}
// Lock is held by a live process — return error with enhanced message
Err(Error::SyncLocked {
pid: info.pid,
since: format!(
"{} — wait for it to finish, or remove .git/collab/sync.lock if the process is no longer running",
format_lock_age(&info.timestamp)
),
})
}
/// Remove the stale lockfile. Ignores errors if the file is already gone.
fn remove_stale_and_retry(lock_path: &PathBuf) -> Result<(), Error> {
eprintln!("Removing stale sync lock...");
let _ = fs::remove_file(lock_path);
Ok(())
}
/// Try to create the lock; if it fails (race with another process), read the
/// new holder's info and return SyncLocked.
fn try_create_or_contention(lock_path: &PathBuf) -> Result<Self, Error> {
match Self::try_create_lock(lock_path) {
Ok(lock) => Ok(lock),
Err(_) => {
// Another process won the race — read the new lock info
let content = fs::read_to_string(lock_path).unwrap_or_default();
let info = SyncLockInfo::from_json(&content).unwrap_or(SyncLockInfo {
pid: 0,
timestamp: String::new(),
});
Err(Error::SyncLocked {
pid: info.pid,
since: format!(
"{} — wait for it to finish, or remove .git/collab/sync.lock if the process is no longer running",
format_lock_age(&info.timestamp)
),
})
}
}
}
}
impl Drop for SyncLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.lock_path);
}
}
/// Check if a process with the given PID is alive using `kill -0`.
pub fn is_process_alive(pid: u32) -> bool {
Command::new("kill")
.args(["-0", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Check if a lock is stale based on PID liveness and timestamp age.
pub fn is_stale(info: &SyncLockInfo, threshold_minutes: u64) -> bool {
// If PID is dead, it's stale
if !is_process_alive(info.pid) {
return true;
}
// If the lock is older than the threshold, treat as stale (PID reuse protection)
if let Ok(lock_time) = chrono::DateTime::parse_from_rfc3339(&info.timestamp) {
let age = chrono::Utc::now() - lock_time.with_timezone(&chrono::Utc);
if age.num_minutes() >= threshold_minutes as i64 {
return true;
}
} else {
// Can't parse timestamp — treat as stale
return true;
}
false
}
/// Convert an RFC 3339 timestamp to a human-readable duration (e.g., "3 seconds ago").
pub fn format_lock_age(timestamp: &str) -> String {
let lock_time = match chrono::DateTime::parse_from_rfc3339(timestamp) {
Ok(t) => t,
Err(_) => return format!("unknown time ({})", timestamp),
};
let age = chrono::Utc::now() - lock_time.with_timezone(&chrono::Utc);
let secs = age.num_seconds();
if secs < 0 {
"just now".to_string()
} else if secs < 60 {
format!("{} second{} ago", secs, if secs == 1 { "" } else { "s" })
} else if secs < 3600 {
let mins = secs / 60;
format!("{} minute{} ago", mins, if mins == 1 { "" } else { "s" })
} else {
let hours = secs / 3600;
format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" })
}
}