use std::fmt;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::guidance;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Pid(i32);
impl Pid {
fn new(raw: u32) -> Option<Self> {
i32::try_from(raw).ok().filter(|pid| *pid > 1).map(Pid)
}
}
impl fmt::Display for Pid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "pid {}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ParentPid(u32);
#[cfg(any(target_os = "linux", target_os = "macos", test))]
impl ParentPid {
fn new(raw: u32) -> Option<Self> {
(raw > 0).then_some(ParentPid(raw))
}
}
impl fmt::Display for ParentPid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "parent pid {}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Signal {
Terminate,
Kill,
}
#[derive(Debug, Clone)]
pub struct Holder {
pid: Pid,
cwd: PathBuf,
vitals: Vitals,
}
impl Holder {
pub fn pid(&self) -> Pid {
self.pid
}
}
#[derive(Debug, Clone, Default)]
struct Vitals {
parent: Option<ParentPid>,
state: Option<State>,
running_for: Option<Duration>,
}
#[cfg_attr(
not(all(test, target_os = "linux")),
expect(
dead_code,
reason = "each host names a subset — `Starting` is macOS's alone, `WaitingOnTheHost` \
and `StoppedByATracer` are Linux's, and a build with no process table to \
read names none — so which variants a target constructs is a fact about \
that target"
)
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Starting,
Running,
Sleeping,
WaitingOnTheHost,
Stopped,
StoppedByATracer,
}
impl fmt::Display for State {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
State::Starting => "starting",
State::Running => "running",
State::Sleeping => "sleeping",
State::WaitingOnTheHost => "waiting on the host",
State::Stopped => "stopped",
State::StoppedByATracer => "stopped by a tracer",
})
}
}
impl fmt::Display for Holder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{pid} in {cwd}, {parent}, {state}, {running}",
pid = self.pid,
cwd = self.cwd.display(),
parent = self.vitals.parent.map_or_else(
|| "parent unanswered".to_owned(),
|parent| parent.to_string()
),
state = self.vitals.state.map_or_else(
|| "state unanswered".to_owned(),
|state| format!("state {state}"),
),
running = self.vitals.running_for.map_or_else(
|| "running time unanswered".to_owned(),
|window| format!("running for {}", guidance::describe_duration(window)),
),
)
}
}
const GRACE: Duration = Duration::from_secs(2);
const POLL: Duration = Duration::from_millis(20);
pub fn holding(run_root: &Path) -> Vec<Holder> {
let ours = ancestry();
let mut found: Vec<Holder> = live_pids()
.into_iter()
.filter(|pid| !ours.contains(pid))
.filter_map(Pid::new)
.filter_map(|pid| working_dir(pid).map(|cwd| (pid, cwd)))
.filter(|(_, cwd)| cwd.starts_with(run_root))
.map(|(pid, cwd)| Holder {
pid,
cwd,
vitals: vitals(pid),
})
.collect();
found.sort_by_key(|holder| holder.pid.0);
found
}
pub enum Outcome {
Released(Pid),
Holding(Holder),
}
pub fn stop(holders: &[Holder], run_root: &Path) -> Vec<Outcome> {
let mut left: Vec<Holder> = holders.to_vec();
let mut signalled: Vec<Pid> = Vec::new();
for signal in [Signal::Terminate, Signal::Kill] {
left.retain(|holder| still_holding(holder, run_root));
for holder in &left {
signal_to(holder.pid, signal);
if !signalled.contains(&holder.pid) {
signalled.push(holder.pid);
}
}
let deadline = Instant::now() + GRACE;
while !left.is_empty() && Instant::now() < deadline {
std::thread::sleep(POLL);
left.retain(|holder| still_holding(holder, run_root));
}
if left.is_empty() {
break;
}
}
let mut outcomes: Vec<Outcome> = signalled
.into_iter()
.filter(|pid| !left.iter().any(|holder| holder.pid == *pid))
.map(Outcome::Released)
.collect();
outcomes.extend(left.into_iter().map(Outcome::Holding));
outcomes
}
fn still_holding(holder: &Holder, run_root: &Path) -> bool {
working_dir(holder.pid).is_some_and(|cwd| cwd.starts_with(run_root))
}
fn ancestry() -> Vec<u32> {
let mut chain = vec![std::process::id()];
while chain.len() < 64 {
let Some(parent) = chain.last().copied().and_then(parent_of) else {
break;
};
if parent == 0 || chain.contains(&parent) {
break;
}
chain.push(parent);
}
chain
}
#[cfg(target_os = "linux")]
fn live_pids() -> Vec<u32> {
let Ok(entries) = std::fs::read_dir("/proc") else {
return Vec::new();
};
entries
.flatten()
.filter_map(|entry| entry.file_name().to_string_lossy().parse::<u32>().ok())
.collect()
}
#[cfg(target_os = "linux")]
fn working_dir(pid: Pid) -> Option<PathBuf> {
std::fs::read_link(format!("/proc/{}/cwd", pid.0)).ok()
}
#[cfg(target_os = "linux")]
fn parent_of(pid: u32) -> Option<u32> {
stat_fields(pid)?.get(PARENT_FIELD)?.parse().ok()
}
#[cfg(target_os = "linux")]
fn stat_fields(pid: u32) -> Option<Vec<String>> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
Some(
stat.rsplit_once(')')?
.1
.split_whitespace()
.map(str::to_owned)
.collect(),
)
}
#[cfg(target_os = "linux")]
const STATE_FIELD: usize = 0;
#[cfg(target_os = "linux")]
const PARENT_FIELD: usize = 1;
#[cfg(target_os = "linux")]
const STARTED_FIELD: usize = 19;
#[cfg(target_os = "linux")]
const STATES: [(&str, State); 5] = [
("R", State::Running),
("S", State::Sleeping),
("D", State::WaitingOnTheHost),
("T", State::Stopped),
("t", State::StoppedByATracer),
];
#[cfg(target_os = "linux")]
fn vitals(pid: Pid) -> Vitals {
let Some(fields) = u32::try_from(pid.0).ok().and_then(stat_fields) else {
return Vitals::default();
};
Vitals {
parent: fields
.get(PARENT_FIELD)
.and_then(|parent| parent.parse().ok())
.and_then(ParentPid::new),
state: fields.get(STATE_FIELD).and_then(|state| {
STATES
.iter()
.find(|(letter, _)| *letter == state.as_str())
.map(|(_, named)| *named)
}),
running_for: fields
.get(STARTED_FIELD)
.and_then(|ticks| ticks.parse().ok())
.and_then(running_since_boot),
}
}
#[cfg(target_os = "linux")]
fn running_since_boot(start_ticks: u64) -> Option<Duration> {
let hz = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
let hz = u64::try_from(hz).ok().filter(|hz| *hz > 0)?;
let uptime = std::fs::read_to_string("/proc/uptime").ok()?;
let uptime: u64 = uptime
.split_whitespace()
.next()?
.split('.')
.next()?
.parse()
.ok()?;
Duration::from_secs(uptime).checked_sub(Duration::from_secs(start_ticks / hz))
}
#[cfg(target_os = "macos")]
fn live_pids() -> Vec<u32> {
use std::ffi::c_int;
let needed = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
let Ok(needed) = usize::try_from(needed) else {
return Vec::new();
};
let room = needed + 64;
let mut buffer = vec![0 as c_int; room];
let Ok(size) = c_int::try_from(room * std::mem::size_of::<c_int>()) else {
return Vec::new();
};
let filled = unsafe { libc::proc_listallpids(buffer.as_mut_ptr().cast(), size) };
if filled <= 0 {
return Vec::new();
}
buffer
.into_iter()
.filter_map(|pid| u32::try_from(pid).ok())
.collect()
}
#[cfg(target_os = "macos")]
fn working_dir(pid: Pid) -> Option<PathBuf> {
use std::ffi::c_int;
use std::os::unix::ffi::OsStringExt;
let pid: c_int = pid.0;
let size = c_int::try_from(std::mem::size_of::<libc::proc_vnodepathinfo>()).ok()?;
let mut info = std::mem::MaybeUninit::<libc::proc_vnodepathinfo>::zeroed();
let read = unsafe {
libc::proc_pidinfo(
pid,
libc::PROC_PIDVNODEPATHINFO,
0,
info.as_mut_ptr().cast(),
size,
)
};
if read != size {
return None;
}
let info = unsafe { info.assume_init() };
let path: Vec<u8> = info
.pvi_cdir
.vip_path
.iter()
.flatten()
.map(|byte| *byte as u8)
.take_while(|byte| *byte != 0)
.collect();
match path.is_empty() {
true => None,
false => Some(PathBuf::from(std::ffi::OsString::from_vec(path))),
}
}
#[cfg(target_os = "macos")]
fn parent_of(pid: u32) -> Option<u32> {
Some(bsd_info(pid)?.pbi_ppid)
}
#[cfg(target_os = "macos")]
fn bsd_info(pid: u32) -> Option<libc::proc_bsdinfo> {
use std::ffi::c_int;
let pid = c_int::try_from(pid).ok().filter(|pid| *pid > 0)?;
let size = c_int::try_from(std::mem::size_of::<libc::proc_bsdinfo>()).ok()?;
let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::zeroed();
let read = unsafe {
libc::proc_pidinfo(
pid,
libc::PROC_PIDTBSDINFO,
0,
info.as_mut_ptr().cast(),
size,
)
};
if read != size {
return None;
}
Some(unsafe { info.assume_init() })
}
#[cfg(any(target_os = "macos", test))]
const MACOS_STATES: [(u32, State); 4] = [
(1, State::Starting),
(2, State::Running),
(3, State::Sleeping),
(4, State::Stopped),
];
#[cfg(target_os = "macos")]
fn vitals(pid: Pid) -> Vitals {
let Some(info) = u32::try_from(pid.0).ok().and_then(bsd_info) else {
return Vitals::default();
};
macos_vitals(info.pbi_ppid, info.pbi_status, info.pbi_start_tvsec)
}
#[cfg(any(target_os = "macos", test))]
fn macos_vitals(parent: u32, status: u32, started: u64) -> Vitals {
Vitals {
parent: ParentPid::new(parent),
state: MACOS_STATES
.iter()
.find(|(value, _)| *value == status)
.map(|(_, named)| *named),
running_for: running_since_epoch(started),
}
}
#[cfg(any(target_os = "macos", test))]
fn running_since_epoch(started: u64) -> Option<Duration> {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.checked_sub(Duration::from_secs(started))
}
#[cfg(unix)]
fn signal_to(pid: Pid, signal: Signal) {
let signal = match signal {
Signal::Terminate => libc::SIGTERM,
Signal::Kill => libc::SIGKILL,
};
unsafe {
libc::kill(pid.0, signal);
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn live_pids() -> Vec<u32> {
Vec::new()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn working_dir(_pid: Pid) -> Option<PathBuf> {
None
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn parent_of(_pid: u32) -> Option<u32> {
None
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn vitals(_pid: Pid) -> Vitals {
Vitals::default()
}
#[cfg(not(unix))]
fn signal_to(_pid: Pid, _signal: Signal) {}
#[cfg(test)]
mod tests {
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::{macos_vitals, Holder, Pid, Vitals};
fn named(vitals: Vitals) -> String {
Holder {
pid: Pid::new(4242).expect("a pid a signal may name"),
cwd: std::path::PathBuf::from("/runs/one/worktree"),
vitals,
}
.to_string()
}
fn started(ago: Duration) -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("a host whose clock is after the epoch")
.checked_sub(ago)
.expect("a host that has been up since the epoch")
.as_secs()
}
#[test]
fn a_holder_this_host_could_not_answer_for_reads_as_unanswered_rather_than_as_a_value() {
assert_eq!(
named(Vitals::default()),
"pid 4242 in /runs/one/worktree, parent unanswered, state unanswered, running time \
unanswered"
);
}
#[test]
fn a_macos_holder_reads_as_an_orphan_or_as_a_live_dispatch_rather_than_as_either() {
assert_eq!(
named(macos_vitals(1, 2, started(Duration::from_secs(750)))),
"pid 4242 in /runs/one/worktree, parent pid 1, state running, running for 12 \
minute(s)",
);
assert_eq!(
named(macos_vitals(90210, 3, started(Duration::from_secs(3900)))),
"pid 4242 in /runs/one/worktree, parent pid 90210, state sleeping, running for 1 \
hour(s) 5 minute(s)",
);
assert_eq!(
named(macos_vitals(90210, 1, started(Duration::from_secs(3600)))),
"pid 4242 in /runs/one/worktree, parent pid 90210, state starting, running for 1 \
hour(s)",
);
}
#[test]
fn a_macos_field_this_host_declines_reads_as_unanswered_rather_than_as_a_value() {
let ahead = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("a host whose clock is after the epoch")
+ Duration::from_secs(3600);
assert_eq!(
named(macos_vitals(0, 5, ahead.as_secs())),
"pid 4242 in /runs/one/worktree, parent unanswered, state unanswered, running time \
unanswered",
);
}
#[cfg(target_os = "macos")]
#[test]
fn the_recorded_macos_state_values_are_this_hosts_own() {
assert_eq!(
super::MACOS_STATES.map(|(value, _)| value),
[libc::SIDL, libc::SRUN, libc::SSLEEP, libc::SSTOP],
);
assert!(
!super::MACOS_STATES
.iter()
.any(|(value, _)| *value == libc::SZOMB),
"a zombie holds no working directory, so it is no state a holder is found in",
);
}
}