a73x

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" })
    }
}