use std::fs::{File, TryLockError};
use std::io::Write;
use std::path::{Path, PathBuf};
use camino::Utf8Path;
use crate::applog;
use crate::diagnostic::{Diagnostic, Reason};
use crate::digest::Digest;
use crate::error::RkError;
pub const LOCKS_DIR: &str = "locks";
#[derive(Debug)]
pub struct TargetLock {
_file: File,
path: PathBuf,
}
impl TargetLock {
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
pub fn acquire(target: &Utf8Path) -> Result<TargetLock, RkError> {
let Some(root) = applog::state_root() else {
return Err(rootless());
};
acquire_in(&root.join(LOCKS_DIR), target)
}
fn rootless() -> RkError {
RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
"no state root resolves, so this run cannot take the target, and nothing was written",
)
.expected("one apply against a target at a time, held by a lock under the state root")
.action("set XDG_STATE_HOME, or HOME, and run it again")
.target_state("unchanged"),
)
}
pub fn acquire_in(dir: &Path, target: &Utf8Path) -> Result<TargetLock, RkError> {
let canonical = std::fs::canonicalize(target)
.map_or_else(|_| target.to_string(), |path| path.display().to_string());
std::fs::create_dir_all(dir)?;
let path = dir.join(format!("{}.lock", Digest::of(canonical.as_bytes())));
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
match file.try_lock() {
Ok(()) => {
let _ = file.set_len(0);
let _ = writeln!(file, "{}", std::process::id());
let _ = writeln!(file, "{canonical}");
let _ = file.flush();
Ok(TargetLock { _file: file, path })
}
Err(TryLockError::WouldBlock) => Err(busy(&canonical, &path)),
Err(TryLockError::Error(error)) => Err(RkError::Io(error)),
}
}
fn busy(target: &str, path: &Path) -> RkError {
let holder = std::fs::read_to_string(path)
.ok()
.and_then(|text| text.lines().next().map(str::to_owned))
.filter(|line| !line.is_empty())
.map_or_else(
|| "another run".to_owned(),
|pid| format!("the run at process {pid}"),
);
RkError::refusal(
Diagnostic::new(
Reason::TargetBusy,
format!("{holder} holds {target}, and nothing was written"),
)
.expected("one apply against a target at a time")
.action("wait for that run to finish, then run it again")
.target_state("unchanged"),
)
}
#[cfg(test)]
mod tests {
use super::{acquire_in, busy, rootless};
use crate::diagnostic::Reason;
fn utf8(dir: &tempfile::TempDir) -> camino::Utf8PathBuf {
camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("a utf-8 path")
}
#[test]
fn one_run_holds_a_target_at_a_time() {
let locks = tempfile::tempdir().expect("a scratch locks directory exists");
let target = tempfile::tempdir().expect("a scratch target exists");
let path = utf8(&target);
let first = acquire_in(locks.path(), &path).expect("the first run takes the target");
let second = acquire_in(locks.path(), &path);
assert_eq!(
second.expect_err("the second refuses").reason(),
Reason::TargetBusy
);
drop(first);
acquire_in(locks.path(), &path).expect("the target is free again");
}
#[test]
fn two_targets_are_two_locks() {
let locks = tempfile::tempdir().expect("a scratch locks directory exists");
let a = tempfile::tempdir().expect("a scratch target exists");
let b = tempfile::tempdir().expect("a second scratch target exists");
let _first = acquire_in(locks.path(), &utf8(&a)).expect("the first target is taken");
acquire_in(locks.path(), &utf8(&b)).expect("the second target is free");
}
#[test]
fn a_lock_file_without_a_live_holder_is_taken_over() {
let locks = tempfile::tempdir().expect("a scratch locks directory exists");
let target = tempfile::tempdir().expect("a scratch target exists");
let path = utf8(&target);
let held = acquire_in(locks.path(), &path)
.expect("the first run takes the target")
.path()
.to_path_buf();
drop(acquire_in(locks.path(), &path));
std::fs::write(&held, "4242\n/some/target\n").expect("the corpse's line writes");
assert!(held.exists(), "a killed run leaves its lock file");
let taken = acquire_in(locks.path(), &path).expect("the next run takes the target");
assert_eq!(taken.path(), held, "it is the same lock file");
let body = std::fs::read_to_string(&held).expect("the lock file reads");
assert!(
body.starts_with(&format!("{}\n", std::process::id())),
"the taking run names itself, and no tail of the corpse survives: {body:?}"
);
}
#[test]
fn a_host_with_no_state_root_refuses() {
let error = rootless();
assert_eq!(error.reason(), Reason::PrerequisiteUnmet);
let diagnostic = error.diagnostic();
assert!(
diagnostic
.action
.unwrap_or_default()
.contains("XDG_STATE_HOME"),
"{:?}",
diagnostic.message
);
assert_eq!(diagnostic.target_state.as_deref(), Some("unchanged"));
}
#[test]
fn the_refusal_names_the_holder() {
let dir = tempfile::tempdir().expect("a scratch directory exists");
let path = dir.path().join("held.lock");
std::fs::write(&path, "4242\n/some/target\n").expect("the lock file is written");
let diagnostic = busy("/some/target", &path).diagnostic();
assert!(diagnostic.message.contains("4242"), "{diagnostic:?}");
assert!(
diagnostic.message.contains("/some/target"),
"{diagnostic:?}"
);
}
}