use crate::fs_mount_options::read_mount_facts;
use crate::pid_namespace::current_pid_namespace;
use crate::process_start::{ProcessStart, linux_start, now_ms};
use crate::process_use::{UseScan, UseTarget, processes_using_through};
use crate::stale_ref_lock_found::PidFile;
use crate::stale_ref_lock_nested_numbers::nested_holders_of;
use crate::stopped_git_locks::pid_named_by;
use crate::stale_ref_lock_walk::pid_file_beside;
pub use crate::stale_ref_lock_identity::{Identity, identity_of};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
pub const LONGEST_LIVE_REF_STEP: Duration = Duration::from_millis(3438);
pub const MARGIN_MULTIPLE: u64 = 2;
pub const MARGIN: Duration = Duration::from_secs((LONGEST_LIVE_REF_STEP.as_millis() as u64 * MARGIN_MULTIPLE).div_ceil(1000));
const INITIAL_PID_NAMESPACE: &str = "pid:[4026531836]";
const PROC_MOUNT_POINT: &str = "/proc";
const HIDEPID_OPTION: &str = "hidepid=";
const HIDEPID_OFF: [&str; 2] = ["0", "off"];
const LOCAL_FILE_SYSTEMS: [&str; 8] = ["btrfs", "ext4", "xfs", "f2fs", "tmpfs", "overlay", "zfs", "bcachefs"];
pub const COARSE_CLOCK_SLACK: Duration = Duration::from_millis(50);
const REUSE_SLACK_MS: f64 = 100.0;
const GIT_COMMAND_PREFIX: &str = "git";
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Proof {
PidGone(i64),
PidReused {
pid: i64,
command: String,
},
OrphanStaging,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Verdict {
TooNew,
Held(String),
Unproven(String),
Stale(Proof),
}
#[derive(Clone, Debug)]
pub struct OwnEnded {
pub pids: Vec<i64>,
pub since: SystemTime,
}
#[derive(Clone, Debug)]
pub struct Judge {
pub proc_root: PathBuf,
pub margin: Duration,
pub pid_view_complete: bool,
pub boot: Option<SystemTime>,
pub local_file_systems: Vec<String>,
pub own_ended: Option<OwnEnded>,
}
#[derive(Clone, Debug)]
pub struct Examined {
pub verdict: Verdict,
pub pid_file: PidFile,
pub identity: Identity,
pub born: Option<SystemTime>,
}
impl Judge {
pub fn here() -> Self {
return Self {
proc_root: PathBuf::from(PROC_MOUNT_POINT),
margin: MARGIN,
pid_view_complete: current_pid_namespace().as_deref() == Some(INITIAL_PID_NAMESPACE) && proc_hides_no_process(),
boot: boot_time(),
local_file_systems: LOCAL_FILE_SYSTEMS.iter().map(|name| { return String::from(*name); }).collect(),
own_ended: None,
};
}
fn local_check(&self, path: &Path) -> Result<(), String> {
let facts = read_mount_facts(path)?;
let Some(mount) = facts else {
return Err(String::from("the file system that holds it is not in the mount table"));
};
if self.local_file_systems.contains(&mount.filesystem_type) {
return Ok(());
}
return Err(format!("it sits on a `{}` file system, which a Git on another machine may share", mount.filesystem_type));
}
fn pid_file_state(lock: &Path, lock_born: Option<SystemTime>) -> PidFile {
let pid_path: PathBuf = pid_file_beside(lock);
let Some(pid) = pid_named_by(pid_path.as_path()) else {
return PidFile::Absent;
};
let pid_born: Option<SystemTime> = std::fs::symlink_metadata(pid_path.as_path()).ok().and_then(|metadata| { return metadata.created().ok(); });
return match (lock_born, pid_born) {
(Some(lock_at), Some(pid_at)) if pid_at >= lock_at => PidFile::Names(i64::from(pid)),
_ => PidFile::Older(i64::from(pid)),
};
}
fn judge_pid(&self, pid: i64, lock_born: Option<SystemTime>) -> Verdict {
if !self.pid_view_complete {
if self.names_ended_own_child(pid, lock_born) {
return self.judge_own_ended_pid(pid);
}
return Verdict::Unproven(format!(
"the pid file beside it names process {pid}, but this process runs in a PID namespace of its own, or cannot see every process of the machine, so it cannot tell whether {pid} is gone"
));
}
let verdict: Verdict = self.judge_listed_pid(pid, lock_born);
if !matches!(verdict, Verdict::Stale(_)) {
return verdict;
}
return match nested_holders_of(self.proc_root.as_path(), pid) {
Ok(holders) if holders.is_empty() => verdict,
Ok(holders) => Verdict::Unproven(format!(
"the pid file beside it names process {pid}, and process {} is process {pid} of a PID namespace of its own, so the writer may be that process",
holders.first().copied().unwrap_or_default()
)),
Err(reason) => Verdict::Unproven(format!(
"the pid file beside it names process {pid}, and the processes could not be searched for one that is process {pid} of a PID namespace of its own: {reason}"
)),
};
}
fn names_ended_own_child(&self, pid: i64, lock_born: Option<SystemTime>) -> bool {
let Some(own) = self.own_ended.as_ref() else {
return false;
};
return own.pids.contains(&pid) && lock_born.is_some_and(|born| { return born + COARSE_CLOCK_SLACK >= own.since; });
}
fn judge_own_ended_pid(&self, pid: i64) -> Verdict {
return match linux_start(pid, self.proc_root.as_path(), now_ms()) {
ProcessStart::Missing => Verdict::Stale(Proof::PidGone(pid)),
ProcessStart::Unknown(reason) => Verdict::Unproven(format!("the pid file beside it names process {pid}, which could not be looked at: {reason}")),
ProcessStart::Running { .. } => Verdict::Held(format!("process {pid}, which the pid file beside it names, ended with the child this wrapper stopped, and a process has the number again")),
};
}
fn judge_listed_pid(&self, pid: i64, lock_born: Option<SystemTime>) -> Verdict {
return match linux_start(pid, self.proc_root.as_path(), now_ms()) {
ProcessStart::Missing => Verdict::Stale(Proof::PidGone(pid)),
ProcessStart::Unknown(reason) => Verdict::Unproven(format!("the pid file beside it names process {pid}, which could not be looked at: {reason}")),
ProcessStart::Running { started_at_ms, resolution_ms } => {
let command: String = std::fs::read_to_string(self.proc_root.join(pid.to_string()).join("comm")).map(|text| { return text.trim().to_owned(); }).unwrap_or_default();
let born_ms: Option<f64> = lock_born.and_then(|at| { return at.duration_since(SystemTime::UNIX_EPOCH).ok(); }).map(|elapsed| { return elapsed.as_secs_f64() * 1000.0; });
let started_after: bool = born_ms.is_some_and(|born| { return started_at_ms > born + resolution_ms as f64 + REUSE_SLACK_MS; });
if command.starts_with(GIT_COMMAND_PREFIX) || !started_after {
Verdict::Held(format!("process {pid} ({command}), which the pid file beside it names, is running"))
} else {
Verdict::Stale(Proof::PidReused { pid, command })
}
}
};
}
fn looks_older_than_boot(&self, born: Option<SystemTime>, identity: &Identity) -> bool {
let Some(boot) = self.boot else {
return false;
};
let modified: SystemTime = nanoseconds_to_time(identity.modified_ns);
return born.is_some_and(|at| { return at < boot; }) && modified < boot;
}
fn unreadable_git(&self, scan: &UseScan) -> Option<String> {
for reason in &scan.unreadable {
let number: Option<&str> = reason.strip_prefix("PID ").and_then(|rest| { return rest.split(' ').next(); });
let command: Option<String> = number.and_then(|pid| { return std::fs::read_to_string(self.proc_root.join(pid).join("comm")).ok(); });
match (number, command) {
(Some(_), None) => {}
(_, Some(name)) if !name.trim().starts_with(GIT_COMMAND_PREFIX) => {}
_ => return Some(reason.clone()),
}
}
return None;
}
pub fn examine_staging(&self, staging: &Path) -> Option<Examined> {
let identity: Identity = identity_of(staging)?;
let born: Option<SystemTime> = std::fs::symlink_metadata(staging).ok()?.created().ok();
let finish = |verdict: Verdict| { return Some(Examined { verdict, pid_file: PidFile::Absent, identity, born }); };
let newest: SystemTime = born.map_or(nanoseconds_to_time(identity.modified_ns), |at| { return at.max(nanoseconds_to_time(identity.modified_ns)); });
if SystemTime::now().duration_since(newest).unwrap_or_default() < self.margin {
return finish(Verdict::TooNew);
}
if let Err(reason) = self.local_check(staging) {
return finish(Verdict::Unproven(reason));
}
let scan: UseScan = processes_using_through(self.proc_root.as_path(), &UseTarget::File { device: identity.device, inode: identity.inode }, std::process::id());
if let Some(reason) = scan.unavailable.as_ref() {
return finish(Verdict::Unproven(format!("the process table could not be listed: {reason}")));
}
if let Some(user) = scan.users.first() {
return finish(Verdict::Held(format!("process {} ({}) has the file open", user.pid, user.command.clone().unwrap_or_default())));
}
return finish(Verdict::Stale(Proof::OrphanStaging));
}
pub fn examine(&self, lock: &Path) -> Option<Examined> {
let identity: Identity = identity_of(lock)?;
let metadata: std::fs::Metadata = std::fs::symlink_metadata(lock).ok()?;
let born: Option<SystemTime> = metadata.created().ok();
let pid_file: PidFile = Self::pid_file_state(lock, born);
let finish = |verdict: Verdict| { return Some(Examined { verdict, pid_file: pid_file.clone(), identity, born }); };
if !metadata.is_file() {
return finish(Verdict::TooNew);
}
let newest: SystemTime = born.map_or(nanoseconds_to_time(identity.modified_ns), |at| { return at.max(nanoseconds_to_time(identity.modified_ns)); });
let own: bool = matches!(&pid_file, PidFile::Names(pid) if self.names_ended_own_child(*pid, born));
if !own && SystemTime::now().duration_since(newest).unwrap_or_default() < self.margin {
return finish(Verdict::TooNew);
}
if let Err(reason) = self.local_check(lock) {
return finish(Verdict::Unproven(reason));
}
let scan: UseScan = processes_using_through(self.proc_root.as_path(), &UseTarget::File { device: identity.device, inode: identity.inode }, std::process::id());
if let Some(reason) = scan.unavailable {
return finish(Verdict::Unproven(format!("the process table could not be listed: {reason}")));
}
if let Some(user) = scan.users.first() {
return finish(Verdict::Held(format!("process {} ({}) has the file open", user.pid, user.command.clone().unwrap_or_default())));
}
if let Some(reason) = self.unreadable_git(&scan) {
return finish(Verdict::Unproven(format!("a Git process of this account could not be looked into ({reason}), so it may hold the lock")));
}
if let PidFile::Names(pid) = pid_file {
return finish(self.judge_pid(pid, born));
}
let mut why: String = match pid_file {
PidFile::Older(pid) => format!("the pid file beside it (process {pid}) is older than the lock, so it belongs to an earlier holder"),
_ => String::from("no pid file beside it names a holder (Git writes one only under `core.lockfilePid`), and no process has it open"),
};
if self.looks_older_than_boot(born, &identity) {
why.push_str("; its times are earlier than the start of this boot, which a reboot would explain, but so would a correction of the clock, so the times alone prove nothing");
}
return finish(Verdict::Unproven(why));
}
}
fn proc_hides_no_process() -> bool {
let Ok(Some(mount)) = read_mount_facts(Path::new(PROC_MOUNT_POINT)) else {
return false;
};
return !mount.mount_options.iter().chain(mount.super_options.iter()).any(|option| {
return option.strip_prefix(HIDEPID_OPTION).is_some_and(|value| { return !HIDEPID_OFF.contains(&value); });
});
}
fn nanoseconds_to_time(nanoseconds: i128) -> SystemTime {
let magnitude: u64 = u64::try_from(nanoseconds.unsigned_abs()).unwrap_or(u64::MAX);
return if nanoseconds >= 0 { SystemTime::UNIX_EPOCH + Duration::from_nanos(magnitude) } else { SystemTime::UNIX_EPOCH - Duration::from_nanos(magnitude) };
}
fn boot_time() -> Option<SystemTime> {
let text: String = std::fs::read_to_string("/proc/stat").ok()?;
let seconds: u64 = text.lines().find_map(|line| { return line.strip_prefix("btime ")?.trim().parse::<u64>().ok(); })?;
return Some(SystemTime::UNIX_EPOCH + Duration::from_secs(seconds));
}
#[cfg(test)]
#[path = "stale_ref_lock_judge_tests.rs"]
mod tests;