use std::fmt;
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use fs4::fs_std::FileExt;
use serde::{Deserialize, Serialize};
pub const EXIT_LOCK_CONTENDED: i32 = 75;
#[derive(Debug, Clone)]
pub enum LockMode {
Default,
Replace,
Wait(Option<Duration>),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HolderInfo {
pub pid: u32,
pub started_at: i64,
pub argv: Vec<String>,
pub snapshot_root: String,
}
impl fmt::Display for HolderInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let started = chrono::DateTime::from_timestamp(self.started_at, 0)
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
.unwrap_or_else(|| "?".into());
let argv = if self.argv.is_empty() {
"<unknown>".to_string()
} else {
self.argv.join(" ")
};
write!(f, "pid={} started={} argv={}", self.pid, started, argv)
}
}
#[derive(Debug)]
pub enum LockError {
HeldBy(HolderInfo),
Io(std::io::Error),
WaitTimeout(HolderInfo),
}
impl fmt::Display for LockError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LockError::HeldBy(info) => write!(
f,
"watch already running for this repo ({info}); pass --replace to recycle or --wait to block"
),
LockError::Io(e) => write!(f, "watch lock IO error: {e}"),
LockError::WaitTimeout(info) => write!(
f,
"timed out waiting for watch lock; holder still alive ({info})"
),
}
}
}
impl std::error::Error for LockError {}
impl From<std::io::Error> for LockError {
fn from(e: std::io::Error) -> Self {
LockError::Io(e)
}
}
pub struct WatchLock {
file: File,
path: PathBuf,
snapshot_root: PathBuf,
}
impl WatchLock {
pub fn lock_path_for(snapshot_root: &Path) -> PathBuf {
snapshot_root.join(".loctree").join("scan.lock")
}
pub fn snapshot_root(&self) -> &Path {
&self.snapshot_root
}
pub fn lock_path(&self) -> &Path {
&self.path
}
}
impl Drop for WatchLock {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.file);
}
}
fn read_holder_info(file: &mut File) -> HolderInfo {
let mut buf = String::new();
let _ = file.seek(SeekFrom::Start(0));
let _ = file.read_to_string(&mut buf);
serde_json::from_str::<HolderInfo>(&buf).unwrap_or_default()
}
fn write_holder_info(file: &mut File, info: &HolderInfo) -> std::io::Result<()> {
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
let payload = serde_json::to_string(info).unwrap_or_else(|_| "{}".into());
file.write_all(payload.as_bytes())?;
file.flush()?;
Ok(())
}
fn try_signal_term(_pid: u32) -> std::io::Result<()> {
#[cfg(unix)]
{
let rc = unsafe { libc::kill(_pid as libc::pid_t, libc::SIGTERM) };
if rc != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
#[cfg(not(unix))]
{
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"--replace not yet supported on non-Unix platforms",
))
}
}
pub fn acquire(snapshot_root: &Path, mode: LockMode) -> Result<WatchLock, LockError> {
let snapshot_root = snapshot_root
.canonicalize()
.unwrap_or_else(|_| snapshot_root.to_path_buf());
let loctree_dir = snapshot_root.join(".loctree");
std::fs::create_dir_all(&loctree_dir)?;
let lock_path = loctree_dir.join("scan.lock");
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)?;
match &mode {
LockMode::Default => {
if FileExt::try_lock_exclusive(&file)? {
inscribe_self(&mut file, &snapshot_root)?;
Ok(WatchLock {
file,
path: lock_path,
snapshot_root,
})
} else {
Err(LockError::HeldBy(read_holder_info(&mut file)))
}
}
LockMode::Replace => {
if FileExt::try_lock_exclusive(&file)? {
inscribe_self(&mut file, &snapshot_root)?;
return Ok(WatchLock {
file,
path: lock_path,
snapshot_root,
});
}
let holder = read_holder_info(&mut file);
if holder.pid != 0 {
let _ = try_signal_term(holder.pid);
}
let deadline = Instant::now() + Duration::from_secs(3);
loop {
if FileExt::try_lock_exclusive(&file)? {
inscribe_self(&mut file, &snapshot_root)?;
return Ok(WatchLock {
file,
path: lock_path,
snapshot_root,
});
}
if Instant::now() >= deadline {
return Err(LockError::HeldBy(read_holder_info(&mut file)));
}
std::thread::sleep(Duration::from_millis(100));
}
}
LockMode::Wait(timeout) => {
let start = Instant::now();
loop {
if FileExt::try_lock_exclusive(&file)? {
inscribe_self(&mut file, &snapshot_root)?;
return Ok(WatchLock {
file,
path: lock_path,
snapshot_root,
});
}
if let Some(limit) = timeout
&& start.elapsed() >= *limit
{
return Err(LockError::WaitTimeout(read_holder_info(&mut file)));
}
std::thread::sleep(Duration::from_millis(200));
}
}
}
}
pub fn is_held(snapshot_root: &Path) -> bool {
let snapshot_root = snapshot_root
.canonicalize()
.unwrap_or_else(|_| snapshot_root.to_path_buf());
let lock_path = WatchLock::lock_path_for(&snapshot_root);
let Ok(file) = OpenOptions::new().read(true).open(&lock_path) else {
return false;
};
match FileExt::try_lock_shared(&file) {
Ok(true) => {
let _ = FileExt::unlock(&file);
false
}
Ok(false) => true,
Err(_) => false,
}
}
fn inscribe_self(file: &mut File, snapshot_root: &Path) -> std::io::Result<()> {
let info = HolderInfo {
pid: std::process::id(),
started_at: chrono::Utc::now().timestamp(),
argv: std::env::args().collect(),
snapshot_root: snapshot_root.to_string_lossy().to_string(),
};
write_holder_info(file, &info)
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
fn touch_repo(dir: &Path) {
std::fs::create_dir_all(dir.join(".git")).unwrap();
}
#[test]
fn acquire_creates_lock_file_under_loctree_dir() {
let tmp = TempDir::new().unwrap();
touch_repo(tmp.path());
let guard = acquire(tmp.path(), LockMode::Default).expect("first acquire ok");
let expected = tmp
.path()
.canonicalize()
.unwrap()
.join(".loctree/scan.lock");
assert_eq!(guard.lock_path(), expected.as_path());
assert!(expected.exists(), "lock file should be created");
}
#[test]
fn second_acquire_within_same_process_is_rejected() {
let tmp = TempDir::new().unwrap();
touch_repo(tmp.path());
let _guard = acquire(tmp.path(), LockMode::Default).expect("first ok");
let second = acquire(tmp.path(), LockMode::Default);
assert!(matches!(second, Err(LockError::HeldBy(_))));
}
#[test]
fn drop_releases_lock() {
let tmp = TempDir::new().unwrap();
touch_repo(tmp.path());
{
let _guard = acquire(tmp.path(), LockMode::Default).expect("first ok");
}
let _again = acquire(tmp.path(), LockMode::Default).expect("re-acquire after drop");
}
#[test]
fn holder_info_round_trip() {
let tmp = TempDir::new().unwrap();
touch_repo(tmp.path());
let _guard = acquire(tmp.path(), LockMode::Default).expect("ok");
let lock_path = tmp
.path()
.canonicalize()
.unwrap()
.join(".loctree/scan.lock");
let mut f = OpenOptions::new().read(true).open(&lock_path).unwrap();
let info = read_holder_info(&mut f);
assert_eq!(info.pid, std::process::id());
assert!(!info.argv.is_empty(), "argv recorded");
}
#[test]
fn is_held_probe_tracks_lock_lifecycle_without_side_effects() {
let tmp = TempDir::new().unwrap();
touch_repo(tmp.path());
assert!(!is_held(tmp.path()), "no watcher yet");
assert!(
!WatchLock::lock_path_for(&tmp.path().canonicalize().unwrap()).exists(),
"probe must not create the lock file"
);
let guard = acquire(tmp.path(), LockMode::Default).expect("acquire ok");
assert!(is_held(tmp.path()), "held while guard alive");
let mut f = OpenOptions::new()
.read(true)
.open(guard.lock_path())
.unwrap();
let info = read_holder_info(&mut f);
assert_eq!(info.pid, std::process::id(), "holder info preserved");
drop(guard);
assert!(!is_held(tmp.path()), "released after drop");
}
#[test]
fn wait_mode_times_out_when_held() {
let tmp = TempDir::new().unwrap();
touch_repo(tmp.path());
let _guard = acquire(tmp.path(), LockMode::Default).expect("ok");
let started = Instant::now();
let res = acquire(tmp.path(), LockMode::Wait(Some(Duration::from_millis(400))));
let elapsed = started.elapsed();
assert!(matches!(res, Err(LockError::WaitTimeout(_))));
assert!(
elapsed >= Duration::from_millis(400),
"should wait at least the timeout"
);
assert!(
elapsed < Duration::from_millis(2000),
"should not wait excessively past timeout"
);
}
#[test]
fn lock_path_resolves_through_relative_and_absolute_to_same_file() {
let tmp = TempDir::new().unwrap();
touch_repo(tmp.path());
let abs = tmp.path().canonicalize().unwrap();
let guard = acquire(&abs, LockMode::Default).expect("ok");
let alt = abs.join("./.");
let second = acquire(&alt, LockMode::Default);
assert!(matches!(second, Err(LockError::HeldBy(_))));
drop(guard);
}
#[test]
fn fork_child_holding_lock_blocks_parent_acquire() {
if std::env::var("LOCT_SKIP_FORK_TEST").is_ok() {
return;
}
let tmp = TempDir::new().unwrap();
touch_repo(tmp.path());
let tmp_path = tmp.path().to_path_buf();
let flock_avail = Command::new("which")
.arg("flock")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !flock_avail {
return;
}
std::fs::create_dir_all(tmp_path.join(".loctree")).unwrap();
let lock_file = tmp_path.join(".loctree/scan.lock");
std::fs::write(&lock_file, "{}").unwrap();
let mut child = Command::new("flock")
.args(["-x", lock_file.to_str().unwrap(), "-c", "sleep 2"])
.spawn()
.expect("spawn flock child");
std::thread::sleep(Duration::from_millis(200));
let parent_attempt = acquire(&tmp_path, LockMode::Default);
assert!(
matches!(parent_attempt, Err(LockError::HeldBy(_))),
"parent should see lock as held"
);
let _ = child.kill();
let _ = child.wait();
}
}