pub fn agent_running(pid: u32) -> bool {
let Ok(signed) = libc::pid_t::try_from(pid) else {
return false;
};
if signed <= 0 || unsafe { libc::kill(signed, 0) } != 0 {
return false;
}
!is_zombie(pid)
}
fn is_zombie(pid: u32) -> bool {
let Ok(status) = std::fs::read_to_string(format!("/proc/{pid}/status")) else {
return false;
};
status
.lines()
.find(|line| line.starts_with("State:"))
.and_then(|line| line.split_whitespace().nth(1))
.is_some_and(|state| state == "Z")
}
pub fn terminate(pid: u32) -> bool {
let Ok(pid) = libc::pid_t::try_from(pid) else {
return false;
};
pid > 0 && unsafe { libc::kill(pid, libc::SIGTERM) == 0 }
}
pub const TERMINATE_VERIFY_WAIT: std::time::Duration = std::time::Duration::from_secs(3);
pub const TERMINATE_VERIFY_POLL: std::time::Duration = std::time::Duration::from_millis(50);
pub fn terminate_and_verify(
pid: u32,
wait: std::time::Duration,
poll: std::time::Duration,
) -> bool {
let Ok(signed) = libc::pid_t::try_from(pid) else {
return false;
};
if signed <= 0 {
return false;
}
if !terminate(pid) {
return !agent_running(pid);
}
let term_deadline = std::time::Instant::now() + wait;
while std::time::Instant::now() < term_deadline {
if !agent_running(pid) {
return true;
}
std::thread::sleep(poll);
}
unsafe {
libc::kill(signed, libc::SIGKILL);
}
let kill_deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
while std::time::Instant::now() < kill_deadline {
if !agent_running(pid) {
return true;
}
std::thread::sleep(poll);
}
!agent_running(pid)
}
pub fn process_start_time(pid: u32) -> Option<u64> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let rest = &stat[stat.rfind(')')? + 1..];
rest.split_whitespace().nth(19)?.parse::<u64>().ok()
}
pub fn is_same_process(pid: u32, expected_start: u64) -> bool {
process_start_time(pid) == Some(expected_start)
}
fn clock_ticks_per_second() -> Option<i64> {
let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
(ticks > 0).then_some(ticks)
}
pub fn process_age(pid: u32) -> Option<std::time::Duration> {
let uptime_raw = std::fs::read_to_string("/proc/uptime").ok()?;
let uptime_secs: f64 = uptime_raw.split_whitespace().next()?.parse().ok()?;
let ticks_per_sec = clock_ticks_per_second()?;
let start_ticks = process_start_time(pid)?;
let start_secs = start_ticks as f64 / ticks_per_sec as f64;
let age_secs = (uptime_secs - start_secs).max(0.0);
Some(std::time::Duration::from_secs_f64(age_secs))
}
pub const STRAY_MIN_AGE: std::time::Duration = std::time::Duration::from_secs(2);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StrayLayer {
MonitorWrapper,
AdvanceChild,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StrayProcess {
pub pid: u32,
pub start_time: u64,
pub layer: StrayLayer,
}
const MONITOR_WRAPPER_MARKER: &str = "trap cleanup TERM INT";
const DEVFLOW_BINARY_NAME: &str = "devflow";
const ADVANCE_SUBCOMMAND: &str = "advance";
pub fn discover_stray_devflow_processes() -> Vec<StrayProcess> {
let Ok(entries) = std::fs::read_dir("/proc") else {
return Vec::new();
};
let my_uid = unsafe { libc::geteuid() };
let mut found = Vec::new();
for entry in entries.flatten() {
let Some(pid) = entry
.file_name()
.to_str()
.and_then(|name| name.parse::<u32>().ok())
else {
continue;
};
let Ok(owner_metadata) = std::fs::metadata(entry.path()) else {
continue;
};
if std::os::unix::fs::MetadataExt::uid(&owner_metadata) != my_uid {
continue;
}
let Ok(raw_cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else {
continue;
};
let args: Vec<String> = raw_cmdline
.split(|&byte| byte == 0)
.filter(|arg| !arg.is_empty())
.map(|arg| String::from_utf8_lossy(arg).into_owned())
.collect();
let Some(layer) = classify_stray_layer(&args) else {
continue;
};
let Some(start_time) = process_start_time(pid) else {
continue; };
found.push(StrayProcess {
pid,
start_time,
layer,
});
}
found
}
pub(crate) fn argv_basename(arg: &str) -> Option<&str> {
std::path::Path::new(arg)
.file_name()
.and_then(|n| n.to_str())
}
fn classify_stray_layer(args: &[String]) -> Option<StrayLayer> {
let is_monitor_wrapper = args.len() >= 3
&& argv_basename(&args[0]) == Some("sh")
&& args[1] == "-c"
&& args[2].contains(MONITOR_WRAPPER_MARKER);
if is_monitor_wrapper {
return Some(StrayLayer::MonitorWrapper);
}
let is_advance_child = args
.first()
.and_then(|argv0| argv_basename(argv0))
.is_some_and(|name| name == DEVFLOW_BINARY_NAME)
&& args.get(1).map(String::as_str) == Some(ADVANCE_SUBCOMMAND);
if is_advance_child {
return Some(StrayLayer::AdvanceChild);
}
None
}
#[deprecated(note = "unsound alone (999.47) -- use is_same_process with a recorded start time")]
pub fn looks_like_devflow_process(pid: u32) -> bool {
let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else {
return false;
};
cmdline
.split(|&byte| byte == 0)
.filter(|arg| !arg.is_empty())
.any(|arg| {
let arg = String::from_utf8_lossy(arg);
std::path::Path::new(arg.as_ref())
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("devflow"))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_running_detects_self() {
assert!(agent_running(std::process::id()));
}
#[test]
fn agent_running_is_false_for_an_unreaped_zombie() {
let mut child = std::process::Command::new("true")
.spawn()
.expect("spawn true");
let pid = child.id();
let mut became_zombie = false;
for _ in 0..200 {
if super::is_zombie(pid) {
became_zombie = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(became_zombie, "child never became an unreaped zombie");
assert_eq!(
unsafe { libc::kill(pid as libc::pid_t, 0) },
0,
"kill(pid, 0) is expected to still succeed on a zombie — if this \
fails the test is no longer exercising the case it was written for"
);
assert!(
!agent_running(pid),
"a zombie has exited and must not be reported as running"
);
let _ = child.wait();
}
#[test]
fn agent_running_false_for_dead_pid() {
assert!(!agent_running(0x7FFF_FFFE));
}
#[test]
fn agent_running_rejects_corrupt_pid_values() {
assert!(!agent_running(0));
assert!(!agent_running(u32::MAX));
assert!(!agent_running(i32::MAX as u32 + 1));
}
#[test]
fn terminate_rejects_pid_zero() {
assert!(!terminate(0));
}
#[test]
fn terminate_rejects_pid_above_i32_max() {
assert!(!terminate(u32::MAX));
assert!(!terminate(i32::MAX as u32 + 1));
}
#[test]
fn terminate_signals_a_live_child_and_it_exits() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn sleep");
let pid = child.id();
assert!(terminate(pid), "terminate must report the signal delivered");
let status = child.wait().expect("wait on the terminated child");
assert!(
!status.success(),
"a SIGTERM'd child must not report a successful exit, got {status:?}"
);
}
#[test]
fn terminate_and_verify_rejects_pid_zero_and_out_of_range_without_signalling() {
assert!(!terminate_and_verify(
0,
std::time::Duration::from_millis(50),
std::time::Duration::from_millis(10)
));
assert!(!terminate_and_verify(
u32::MAX,
std::time::Duration::from_millis(50),
std::time::Duration::from_millis(10)
));
assert!(!terminate_and_verify(
i32::MAX as u32 + 1,
std::time::Duration::from_millis(50),
std::time::Duration::from_millis(10)
));
}
#[test]
fn terminate_and_verify_returns_true_immediately_for_a_dead_pid() {
let start = std::time::Instant::now();
let cleared = terminate_and_verify(
0x7FFF_FFFE,
std::time::Duration::from_secs(5),
std::time::Duration::from_millis(20),
);
let elapsed = start.elapsed();
assert!(
cleared,
"a pid that cannot be signalled at all must count as already cleared"
);
assert!(
elapsed < std::time::Duration::from_secs(1),
"must not wait out the full ceiling when the signal itself fails, took {elapsed:?}"
);
}
#[test]
fn terminate_and_verify_clears_a_normal_child_before_the_wait_elapses() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn sleep");
let pid = child.id();
let start = std::time::Instant::now();
let cleared = terminate_and_verify(
pid,
std::time::Duration::from_secs(5),
std::time::Duration::from_millis(20),
);
let elapsed = start.elapsed();
assert!(cleared, "a TERM-honouring child must be cleared");
assert!(
elapsed < std::time::Duration::from_secs(2),
"clearing an ordinary child must complete well before the 5s wait \
ceiling, took {elapsed:?} (SIGKILL escalation should not have \
been needed)"
);
let _ = child.wait();
}
#[test]
fn terminate_and_verify_escalates_to_kill_for_a_term_ignoring_child() {
let mut child = std::process::Command::new("sh")
.arg("-c")
.arg("trap '' TERM; sleep 30")
.spawn()
.expect("spawn TERM-ignoring child");
let pid = child.id();
std::thread::sleep(std::time::Duration::from_millis(100));
let cleared = terminate_and_verify(
pid,
std::time::Duration::from_millis(500),
std::time::Duration::from_millis(20),
);
assert!(
cleared,
"a TERM-ignoring child must still be cleared via SIGKILL escalation"
);
assert!(
!agent_running(pid),
"child must be verified dead after escalation, not merely assumed"
);
let _ = child.wait();
}
#[test]
fn discover_stray_devflow_processes_finds_a_monitor_wrapper() {
let mut child = std::process::Command::new("sh")
.arg("-c")
.arg("trap cleanup TERM INT; sleep 30")
.spawn()
.expect("spawn monitor-wrapper-shaped fixture");
let pid = child.id();
assert!(
crate::test_support::wait_for_exec_visibility(
pid,
"sh",
crate::test_support::EXEC_VISIBILITY_WAIT,
crate::test_support::EXEC_VISIBILITY_POLL,
),
"pid {pid}: exec visibility timed out before the fixture became discoverable"
);
let found = discover_stray_devflow_processes();
let candidate = found.iter().find(|p| p.pid == pid);
let candidate = candidate.expect("monitor wrapper fixture must be discovered");
assert_eq!(candidate.layer, StrayLayer::MonitorWrapper);
assert!(
is_same_process(pid, candidate.start_time),
"the recorded start time must re-confirm identity while the process is alive"
);
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn discover_stray_devflow_processes_rejects_the_999_47_false_positive_shape() {
let mut child = std::process::Command::new("sh")
.arg("-c")
.arg("sleep 30")
.arg("/tmp/devflow-scratch/looks-like-devflow")
.spawn()
.expect("spawn 999.47-shaped fixture");
let pid = child.id();
assert!(
crate::test_support::wait_for_exec_visibility(
pid,
"sh",
crate::test_support::EXEC_VISIBILITY_WAIT,
crate::test_support::EXEC_VISIBILITY_POLL,
),
"pid {pid}: exec visibility timed out before the fixture became discoverable"
);
let found = discover_stray_devflow_processes();
let _ = child.kill();
let _ = child.wait();
assert!(
!found.iter().any(|p| p.pid == pid),
"a process merely mentioning a devflow-looking path must not be discovered"
);
}
#[test]
fn discover_stray_devflow_processes_rejects_devflow_named_argv0_with_wrong_argv1() {
let mut child = std::process::Command::new("sleep");
std::os::unix::process::CommandExt::arg0(&mut child, "devflow");
let mut child = child
.arg("30")
.spawn()
.expect("spawn devflow-argv0 fixture");
let pid = child.id();
assert!(
crate::test_support::wait_for_exec_visibility(
pid,
"devflow",
crate::test_support::EXEC_VISIBILITY_WAIT,
crate::test_support::EXEC_VISIBILITY_POLL,
),
"pid {pid}: exec visibility timed out before the fixture became discoverable"
);
let found = discover_stray_devflow_processes();
let _ = child.kill();
let _ = child.wait();
assert!(
!found.iter().any(|p| p.pid == pid),
"argv[0]==devflow with argv[1] != advance must not be discovered as Layer 2"
);
}
#[test]
fn discover_stray_devflow_processes_excludes_an_unrelated_process() {
let self_pid = std::process::id();
let found = discover_stray_devflow_processes();
assert!(
!found.iter().any(|p| p.pid == self_pid),
"the test binary itself must never be discovered as a stray process"
);
}
#[test]
#[allow(deprecated)] fn looks_like_devflow_process_is_true_for_the_current_process() {
assert!(looks_like_devflow_process(std::process::id()));
}
#[test]
fn looks_like_devflow_process_is_false_for_a_non_devflow_process() {
let self_pid = std::process::id();
let real_start = process_start_time(self_pid)
.expect("must be able to read this process's own recorded start time");
assert!(
is_same_process(self_pid, real_start),
"the current process must match its own recorded start time"
);
let perturbed_start = real_start.wrapping_add(1);
assert!(
!is_same_process(self_pid, perturbed_start),
"a deliberately wrong start time must not be treated as a match"
);
}
#[test]
#[allow(deprecated)] fn looks_like_devflow_process_is_false_when_proc_cannot_be_read() {
assert!(!looks_like_devflow_process(0x7FFF_FFFE));
}
#[test]
fn process_age_returns_some_for_the_current_process() {
std::thread::sleep(std::time::Duration::from_millis(20));
let age = process_age(std::process::id()).expect("this process's own age must resolve");
assert!(
age > std::time::Duration::ZERO,
"a running process must report nonzero age once at least one tick has elapsed"
);
assert!(
age < std::time::Duration::from_secs(3600),
"the test binary has not been running for an hour"
);
}
#[test]
fn process_age_returns_none_for_a_dead_pid() {
assert_eq!(process_age(0x7FFF_FFFE), None);
}
#[test]
fn process_age_is_below_the_floor_for_a_fresh_child_and_grows_monotonically_for_self() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn fixture");
let pid = child.id();
let child_age = process_age(pid).expect("a freshly spawned child's age must resolve");
assert!(
child_age < STRAY_MIN_AGE,
"a process spawned microseconds ago must be younger than the floor"
);
let _ = child.kill();
let _ = child.wait();
let self_pid = std::process::id();
let first = process_age(self_pid).expect("this process's own age must resolve");
std::thread::sleep(std::time::Duration::from_millis(50));
let second =
process_age(self_pid).expect("this process's own age must resolve after the sleep too");
assert!(
second >= first,
"age must grow monotonically across a sleep, never shrink"
);
}
}