use std::fs;
use std::path::Path;
use std::time::{Duration, Instant};
use ckg_core::{Error, Result};
use fs2::FileExt;
fn jittered_poll_interval() -> Duration {
let pid = std::process::id() as u64;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
let seed = pid.wrapping_mul(6364136223846793005).wrapping_add(nanos);
let half_base = LOCK_POLL_BASE_MS / 2;
let jitter = seed % (half_base + 1); let ms = LOCK_POLL_BASE_MS.wrapping_sub(half_base / 2).saturating_add(jitter);
Duration::from_millis(ms)
}
struct LockGuard(fs::File);
impl Drop for LockGuard {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.0);
}
}
const LOCK_TIMEOUT: Duration = Duration::from_secs(60);
const LOCK_POLL_BASE_MS: u64 = 50;
pub fn with_repo_lock<F, T>(repo_id: &str, base: &Path, op: F) -> Result<T>
where
F: FnOnce() -> Result<T>,
{
let parent = base.join("workspace_folders");
fs::create_dir_all(&parent)?;
let lock_path = parent.join(format!("{repo_id}.lock"));
let lock_file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)
.map_err(|e| {
Error::Storage(format!(
"could not open per-repo lock at {}: {e}",
lock_path.display()
))
})?;
let deadline = Instant::now() + LOCK_TIMEOUT;
loop {
match lock_file.try_lock_exclusive() {
Ok(()) => break,
Err(e) if Instant::now() < deadline => {
let _ = e;
std::thread::sleep(jittered_poll_interval());
}
Err(e) => {
return Err(Error::Storage(format!(
"could not acquire per-repo lock at {} after {}s: {e}",
lock_path.display(),
LOCK_TIMEOUT.as_secs()
)));
}
}
}
let _guard = LockGuard(lock_file);
op()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn lock_acquired_and_released() {
let dir = tempdir().unwrap();
let id = "abcdef0123456789abcd1234";
with_repo_lock(id, dir.path(), || Ok(())).unwrap();
with_repo_lock(id, dir.path(), || Ok(())).unwrap();
}
#[test]
fn lock_guard_releases_on_error_return() {
let dir = tempdir().unwrap();
let id = "abcdef0123456789abcd9999";
let _ = with_repo_lock(id, dir.path(), || -> ckg_core::Result<()> {
Err(ckg_core::Error::Storage("simulated failure".into()))
});
with_repo_lock(id, dir.path(), || Ok(()))
.expect("lock must be released after error return");
}
#[test]
fn lock_file_persists_after_inner_dir_removal() {
let dir = tempdir().unwrap();
let id = "0123456789abcdef01234567";
let workspace = dir.path().join("workspace_folders");
fs::create_dir_all(workspace.join(id)).unwrap();
with_repo_lock(id, dir.path(), || {
fs::remove_dir_all(workspace.join(id))?;
Ok(())
})
.unwrap();
assert!(workspace.join(format!("{id}.lock")).is_file());
}
}