pray-core 1.22.0

Core library for Prayfile, the package manager for the language placed before inference
Documentation
use crate::{PrayError, PrayResult};
use std::fs;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

const LOCK_ATTEMPTS: u32 = 2_400;
const LOCK_RETRY_MILLIS: u64 = 25;
const STALE_LOCK_SECS: u64 = 600;

pub(crate) struct GitCacheLock {
    lock_directory: PathBuf,
}

impl Drop for GitCacheLock {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.lock_directory);
    }
}

pub(crate) fn lock_path_for_git_db(db: &Path) -> PathBuf {
    let mut lock = db.as_os_str().to_os_string();
    lock.push(".lock.d");
    PathBuf::from(lock)
}

pub(crate) fn acquire_git_cache_lock(db: &Path) -> PrayResult<GitCacheLock> {
    let lock_directory = lock_path_for_git_db(db);
    if let Some(parent) = lock_directory.parent() {
        fs::create_dir_all(parent)?;
    }
    for _ in 0..LOCK_ATTEMPTS {
        match fs::create_dir(&lock_directory) {
            Ok(()) => {
                write_lock_owner(&lock_directory)?;
                return Ok(GitCacheLock { lock_directory });
            }
            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
                if try_clear_stale_lock(&lock_directory) {
                    continue;
                }
                thread::sleep(Duration::from_millis(LOCK_RETRY_MILLIS));
            }
            Err(error) => {
                return Err(PrayError::Resolution(format!(
                    "failed to lock git cache {}: {error}",
                    lock_directory.display()
                )));
            }
        }
    }
    Err(PrayError::Resolution(format!(
        "timed out waiting for git cache lock {}. \
         If no other pray process is using that cache, remove the lock directory and retry.",
        lock_directory.display()
    )))
}

fn write_lock_owner(lock_directory: &Path) -> PrayResult<()> {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|value| value.as_secs())
        .unwrap_or(0);
    let body = format!("{}\n{now}\n", std::process::id());
    fs::write(lock_directory.join("owner"), body).map_err(|error| {
        PrayError::Resolution(format!(
            "failed to write git cache lock owner {}: {error}",
            lock_directory.display()
        ))
    })
}

fn try_clear_stale_lock(lock_directory: &Path) -> bool {
    let age_secs = lock_age_secs(lock_directory);
    if age_secs < STALE_LOCK_SECS {
        return false;
    }
    let _ = fs::remove_dir_all(lock_directory);
    !lock_directory.exists()
}

fn lock_age_secs(lock_directory: &Path) -> u64 {
    if let Ok(text) = fs::read_to_string(lock_directory.join("owner")) {
        if let Some(stamp_text) = text.lines().nth(1) {
            if let Ok(stamp) = stamp_text.parse::<u64>() {
                let now = SystemTime::now()
                    .duration_since(UNIX_EPOCH)
                    .map(|value| value.as_secs())
                    .unwrap_or(0);
                return now.saturating_sub(stamp);
            }
        }
    }
    let Ok(metadata) = fs::metadata(lock_directory) else {
        return 0;
    };
    let Ok(modified) = metadata.modified() else {
        return 0;
    };
    SystemTime::now()
        .duration_since(modified)
        .map(|value| value.as_secs())
        .unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::{
        acquire_git_cache_lock, lock_path_for_git_db, try_clear_stale_lock, STALE_LOCK_SECS,
    };
    use std::fs;
    use std::time::{SystemTime, UNIX_EPOCH};

    #[test]
    fn clears_owner_lock_when_stamp_is_stale() {
        let root = std::env::temp_dir().join(format!(
            "pray-lock-stale-{}",
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .expect("clock")
                .as_nanos()
        ));
        let db = root.join("cache");
        fs::create_dir_all(&db).expect("db");
        let lock = lock_path_for_git_db(&db);
        fs::create_dir_all(&lock).expect("lock");
        let old = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("clock")
            .as_secs()
            .saturating_sub(STALE_LOCK_SECS + 5);
        fs::write(lock.join("owner"), format!("1\n{old}\n")).expect("owner");
        assert!(try_clear_stale_lock(&lock));
        assert!(!lock.exists());
        let _guard = acquire_git_cache_lock(&db).expect("acquire");
        assert!(lock.exists());
        drop(_guard);
        let _ = fs::remove_dir_all(root);
    }
}