use std::ffi::OsStr;
use std::fs::{File, OpenOptions, TryLockError};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
pub(crate) struct Lease {
file: File,
record: Option<PathBuf>,
}
impl Lease {
fn new(file: File) -> Self {
Self { file, record: None }
}
}
impl Drop for Lease {
fn drop(&mut self) {
if let Some(record) = &self.record {
let _ = std::fs::remove_file(record);
}
let _ = self.file.unlock();
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Holder {
pub(crate) pid: u32,
pub(crate) what: String,
pub(crate) since: u64,
}
pub(crate) struct Reservation {
holder: PathBuf,
_lease: Lease,
}
impl Drop for Reservation {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.holder);
}
}
fn open(cache_dir: &Path) -> io::Result<File> {
std::fs::create_dir_all(cache_dir)?;
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(cache_dir.join("target-use.lock"))
}
fn holder_record(cache_dir: &Path) -> PathBuf {
cache_dir.join("target-use.holder")
}
fn holders_dir(cache_dir: &Path) -> PathBuf {
cache_dir.join("target-use.holders")
}
fn record_holder(cache_dir: &Path, what: &str) -> Option<PathBuf> {
let dir = holders_dir(cache_dir);
std::fs::create_dir_all(&dir).ok()?;
let since = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let pid = std::process::id();
let record = dir.join(format!("{pid}-{}", since.as_nanos()));
std::fs::write(&record, format!("{}\t{what}", since.as_secs())).ok()?;
Some(record)
}
fn parse_holder(name: &str, text: &str) -> Option<Holder> {
let pid = name.split('-').next()?.parse().ok()?;
let (since, what) = text.split_once('\t')?;
Some(Holder {
pid,
what: what.trim().to_owned(),
since: since.parse().ok()?,
})
}
pub(crate) fn holders(cache_dir: &Path) -> Vec<Holder> {
let Ok(entries) = std::fs::read_dir(holders_dir(cache_dir)) else {
return Vec::new();
};
let mut holders = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let Some(holder) = std::fs::read_to_string(entry.path())
.ok()
.and_then(|text| parse_holder(&name, &text))
else {
continue;
};
if crate::daemon::process_is_alive(holder.pid) {
holders.push(holder);
} else {
let _ = std::fs::remove_file(entry.path());
}
}
holders.sort_by_key(|holder| (holder.since, holder.pid));
holders
}
pub(crate) fn blockers(cache_dir: &Path) -> Option<Vec<Holder>> {
let file = open(cache_dir).ok()?;
match file.try_lock() {
Ok(()) => {
let _ = file.unlock();
None
}
Err(_) => Some(holders(cache_dir)),
}
}
pub(crate) fn shared(cache_dir: &Path, what: &str, notice: &mut dyn Write) -> io::Result<Lease> {
let file = open(cache_dir)?;
match file.try_lock_shared() {
Ok(()) => {}
Err(TryLockError::WouldBlock) => {
let _ = notice.write_all(waiting(cache_dir).as_bytes());
file.lock_shared()?;
}
Err(TryLockError::Error(error)) => return Err(error),
}
let mut lease = Lease::new(file);
lease.record = record_holder(cache_dir, what);
Ok(lease)
}
pub(crate) fn shared_for<'a>(
cache_dir: &Path,
what: &str,
args: impl IntoIterator<Item = &'a OsStr>,
) -> io::Result<Lease> {
if quiet(args) {
shared(cache_dir, what, &mut io::sink())
} else {
shared(cache_dir, what, &mut io::stderr())
}
}
fn quiet<'a>(args: impl IntoIterator<Item = &'a OsStr>) -> bool {
args.into_iter()
.take_while(|arg| *arg != "--")
.any(|arg| arg == "-q" || arg == "--quiet")
}
fn waiting(cache_dir: &Path) -> String {
let pid = std::fs::read_to_string(holder_record(cache_dir))
.ok()
.and_then(|text| text.trim().parse::<u32>().ok());
match pid {
Some(pid) => format!("kache: waiting for target cleanup (pid {pid}) to finish\n"),
None => "kache: waiting for target cleanup to finish\n".to_owned(),
}
}
pub(crate) fn try_exclusive(cache_dir: &Path) -> io::Result<Option<Reservation>> {
let file = open(cache_dir)?;
match file.try_lock() {
Ok(()) => {
let lease = Lease::new(file);
let holder = holder_record(cache_dir);
let _ = std::fs::write(&holder, std::process::id().to_string());
Ok(Some(Reservation {
holder,
_lease: lease,
}))
}
Err(TryLockError::WouldBlock) => Ok(None),
Err(TryLockError::Error(error)) => Err(error),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
struct Said(std::sync::mpsc::Sender<Vec<u8>>);
impl Write for Said {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
let _ = self.0.send(bytes.to_vec());
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn naming(pid: u32) -> String {
format!("kache: waiting for target cleanup (pid {pid}) to finish\n")
}
#[test]
fn a_shared_lease_names_its_command_until_it_ends() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(blockers(dir.path()), None);
let run = shared(dir.path(), "cargo run", &mut io::sink()).unwrap();
let named = holders(dir.path());
assert_eq!(named.len(), 1);
assert_eq!(
(named[0].pid, named[0].what.as_str()),
(std::process::id(), "cargo run")
);
assert_eq!(blockers(dir.path()), Some(named));
drop(run);
assert!(holders(dir.path()).is_empty());
assert_eq!(blockers(dir.path()), None);
assert!(try_exclusive(dir.path()).unwrap().is_some());
}
#[test]
fn a_record_left_by_a_gone_process_is_dropped() {
let dir = tempfile::tempdir().unwrap();
let records = holders_dir(dir.path());
std::fs::create_dir_all(&records).unwrap();
std::fs::write(records.join("1-5"), "5\tcargo run").unwrap();
std::fs::write(records.join("garbage"), "no tab").unwrap();
assert!(holders(dir.path()).is_empty());
assert!(!records.join("1-5").exists());
assert_eq!(
parse_holder("42-9", "17\tcargo test --all"),
Some(Holder {
pid: 42,
what: "cargo test --all".to_owned(),
since: 17
})
);
assert_eq!(parse_holder("x-9", "17\tcargo"), None);
}
#[test]
fn a_running_command_blocks_cleanup_until_it_exits() {
let dir = tempfile::tempdir().unwrap();
let first = shared(dir.path(), "cargo build", &mut io::sink()).unwrap();
let second = shared(dir.path(), "cargo build", &mut io::sink()).unwrap();
assert!(try_exclusive(dir.path()).unwrap().is_none());
drop(first);
assert!(try_exclusive(dir.path()).unwrap().is_none());
drop(second);
assert!(try_exclusive(dir.path()).unwrap().is_some());
}
#[test]
fn a_command_says_it_waits_for_cleanup_then_runs_once_cleanup_ends() {
let dir = tempfile::tempdir().unwrap();
let cleanup = try_exclusive(dir.path()).unwrap().unwrap();
let (said, heard) = std::sync::mpsc::channel();
let cache = dir.path().to_path_buf();
let command =
std::thread::spawn(move || shared(&cache, "cargo build", &mut Said(said)).unwrap());
let line = heard
.recv_timeout(Duration::from_secs(10))
.expect("the command never said it was waiting");
assert_eq!(String::from_utf8(line).unwrap(), naming(std::process::id()));
std::thread::sleep(Duration::from_millis(100));
assert!(
!command.is_finished(),
"the command started while cleanup held the lock"
);
drop(cleanup);
let lease = command.join().unwrap();
assert!(
try_exclusive(dir.path()).unwrap().is_none(),
"the command runs without its lease"
);
drop(lease);
assert!(heard.try_recv().is_err(), "more than one line");
}
#[test]
fn a_command_takes_a_free_lock_without_a_word() {
let dir = tempfile::tempdir().unwrap();
let mut said = Vec::new();
let first = shared(dir.path(), "cargo build", &mut said).unwrap();
let second = shared(dir.path(), "cargo build", &mut said).unwrap();
assert!(said.is_empty(), "{}", String::from_utf8_lossy(&said));
drop((first, second));
}
#[test]
fn the_wait_names_the_cleanup_process_only_while_it_holds_the_lock() {
let dir = tempfile::tempdir().unwrap();
let anonymous = "kache: waiting for target cleanup to finish\n";
assert_eq!(waiting(dir.path()), anonymous);
let cleanup = try_exclusive(dir.path()).unwrap().unwrap();
assert_eq!(waiting(dir.path()), naming(std::process::id()));
drop(cleanup);
assert_eq!(waiting(dir.path()), anonymous);
std::fs::write(holder_record(dir.path()), "not a pid").unwrap();
assert_eq!(waiting(dir.path()), anonymous);
}
#[test]
fn quiet_cargo_hides_the_wait() {
let is_quiet = |args: &[&str]| quiet(args.iter().map(OsStr::new));
assert!(!is_quiet(&["build"]));
assert!(is_quiet(&["build", "-q"]));
assert!(is_quiet(&["--quiet", "test"]));
assert!(!is_quiet(&["run", "--", "-q"]));
}
#[cfg(unix)]
#[test]
fn closing_a_lease_releases_an_inherited_descriptor() {
let dir = tempfile::tempdir().unwrap();
let lease = shared(dir.path(), "cargo build", &mut io::sink()).unwrap();
let inherited = lease.file.try_clone().unwrap();
drop(lease);
assert!(try_exclusive(dir.path()).unwrap().is_some());
drop(inherited);
}
}