use crate::git;
use std::os::unix::io::AsRawFd;
use std::time::{SystemTime, UNIX_EPOCH};
pub struct Held {
_file: std::fs::File,
}
impl Drop for Held {
fn drop(&mut self) {
CLAIM.store(-1, std::sync::atomic::Ordering::Relaxed);
}
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn note(path: &std::path::Path) {
let _ = std::fs::write(path, now().to_string());
}
fn how_long(path: &std::path::Path) -> String {
match std::fs::read_to_string(path)
.ok()
.and_then(|text| text.trim().parse::<u64>().ok())
{
Some(taken) if taken > 0 && taken <= now() => format!("{}s so far", now() - taken),
_ => "for a time this run cannot tell".to_string(),
}
}
pub fn take() -> Result<Held, String> {
let path = git::git_path("agent-verdict.lock")?;
let file = std::fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)
.map_err(|e| format!("cannot open {}: {e}", path.display()))?;
let fd = file.as_raw_fd();
if unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) } != 0 {
let why = std::io::Error::last_os_error();
if why.kind() != std::io::ErrorKind::WouldBlock {
return Err(format!("cannot claim {}: {why}", path.display()));
}
return Err(format!(
"another review is already running in this repo — {}.\nOne at a time: two runs review the same gate, pay for it twice, and the second to finish drops the first's verdict.\nWait for it, or end what holds it: {}",
how_long(&path),
holders(&path)
));
}
CLAIM.store(fd, std::sync::atomic::Ordering::Relaxed);
note(&path);
Ok(Held { _file: file })
}
static CLAIM: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
pub fn passed_to(command: &mut std::process::Command) {
let fd = CLAIM.load(std::sync::atomic::Ordering::Relaxed);
if fd < 0 {
return;
}
use std::os::unix::process::CommandExt;
unsafe {
command.pre_exec(move || {
let flags = libc::fcntl(fd, libc::F_GETFD);
if flags >= 0 {
libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
}
Ok(())
});
}
}
fn holders(path: &std::path::Path) -> String {
let Ok(claim) = std::fs::canonicalize(path) else {
return NOBODY.to_string();
};
let held = from_proc(&claim).or_else(|| from_lsof(&claim));
match held {
Some(held) if !held.is_empty() => format!("\n {}", held.join("\n ")),
Some(_) => {
"nothing this user can see holds it, so it belongs to another account".to_string()
}
None => NOBODY.to_string(),
}
}
const NOBODY: &str = "nothing here can tell what holds it";
fn ours(pid: u32) -> bool {
pid == std::process::id()
}
fn from_proc(claim: &std::path::Path) -> Option<Vec<String>> {
let mut held = Vec::new();
for process in std::fs::read_dir("/proc").ok()?.flatten() {
let Ok(pid) = process.file_name().to_string_lossy().parse::<u32>() else {
continue;
};
if ours(pid) {
continue;
}
let Ok(open) = std::fs::read_dir(process.path().join("fd")) else {
continue;
};
if open
.flatten()
.any(|fd| std::fs::read_link(fd.path()).is_ok_and(|at| at == claim))
{
let named = std::fs::read_to_string(process.path().join("comm")).unwrap_or_default();
held.push(ending(pid, named.trim()));
}
}
Some(held)
}
fn from_lsof(claim: &std::path::Path) -> Option<Vec<String>> {
let asked = std::process::Command::new("lsof")
.args(["-t", "--"])
.arg(claim)
.output()
.ok()?;
Some(
String::from_utf8_lossy(&asked.stdout)
.lines()
.filter_map(|line| line.trim().parse::<u32>().ok())
.filter(|pid| !ours(*pid))
.map(|pid| ending(pid, "holding this repo"))
.collect(),
)
}
fn ending(pid: u32, named: &str) -> String {
format!("kill {pid} # {named}")
}