use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct Watcher {
pub pid: i32,
pub age_secs: u64,
pub command: String,
pub proxy: &'static str,
pub advice: &'static str,
}
pub fn classify(command: &str) -> Option<(&'static str, &'static str)> {
let lower = command.to_ascii_lowercase();
let sleeps = lower.contains("sleep");
let loops = lower.contains("while ") || lower.contains("until ") || lower.contains("for ");
if !(sleeps && loops) {
return None;
}
let reads_processes = lower.contains("pgrep")
|| lower.contains("pidof")
|| lower.contains("ps -ef")
|| lower.contains("ps aux")
|| lower.contains("ps -a");
let counts = lower.contains("grep -c") || lower.contains("wc -l") || lower.contains("--count");
if reads_processes && counts {
return Some((
"a count of the processes that match, which another user holds above zero",
"This monitor waits until NOTHING matches, and it has no pattern fault. On a \
machine that two agents share, the work of the other agent keeps the count above \
zero for ever, and your own work is already complete. Wait for YOUR job instead: \
`qex wait <id>` reads one process, and that process belongs to you.",
));
}
if reads_processes && lower.contains("ssh") {
return Some((
"a pattern in the process list of another machine",
"That pattern also matches the work of the other users of that machine, and each \
test opens a connection. Start the task with `qex submit` on that machine, and \
wait for its id.",
));
}
if reads_processes {
return Some((
"a pattern in the process list",
"This monitor can match its own command line. Use `qex submit` and \
`qex status <id> --wait`.",
));
}
if lower.contains("grep") || lower.contains("tail ") {
return Some((
"a line in a log file",
"The line never arrives when something stops the task that writes it. \
Use `qex submit` and `qex status <id> --wait`.",
));
}
if lower.contains("test -f") || lower.contains("[ -f") || lower.contains("[ -e") {
return Some((
"a file that appears",
"The file never appears when something stops the task that writes it. \
Use `qex submit` and `qex status <id> --wait`.",
));
}
Some((
"an unknown condition",
"This loop sleeps and tests a condition. Use `qex submit` and \
`qex status <id> --wait`, which waits for the process itself.",
))
}
#[cfg(target_os = "linux")]
pub fn find() -> Vec<Watcher> {
let mut out = Vec::new();
let me = std::process::id() as i32;
let ancestors = ancestors_of(me);
let Ok(entries) = std::fs::read_dir("/proc") else {
return out;
};
let uptime = read_uptime();
let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) } as f64;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Ok(pid) = name.parse::<i32>() else {
continue;
};
if pid == me || ancestors.contains(&pid) {
continue;
}
let Ok(raw) = std::fs::read(entry.path().join("cmdline")) else {
continue;
};
if raw.is_empty() {
continue;
}
let command = String::from_utf8_lossy(&raw)
.replace('\0', " ")
.trim()
.to_string();
if command.contains("qex watchers") {
continue;
}
let Some((proxy, advice)) = classify(&command) else {
continue;
};
out.push(Watcher {
pid,
age_secs: process_age(&entry.path(), uptime, ticks),
command,
proxy,
advice,
});
}
out.sort_by_key(|w| std::cmp::Reverse(w.age_secs));
out
}
#[cfg(not(target_os = "linux"))]
pub fn find() -> Vec<Watcher> {
let mut out = Vec::new();
let me = std::process::id() as i32;
let my_group = unsafe { libc::getpgid(0) };
let Ok(result) = std::process::Command::new("ps")
.args(["-A", "-o", "pid=,pgid=,etime=,command="])
.output()
else {
return out;
};
for line in String::from_utf8_lossy(&result.stdout).lines() {
let mut parts = line.trim().splitn(4, char::is_whitespace);
let (Some(pid), Some(group), Some(elapsed), Some(command)) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
continue;
};
let Ok(pid) = pid.parse::<i32>() else {
continue;
};
if pid == me || group.parse::<i32>() == Ok(my_group) {
continue;
}
let Some((proxy, advice)) = classify(command) else {
continue;
};
out.push(Watcher {
pid,
age_secs: parse_elapsed(elapsed),
command: command.to_string(),
proxy,
advice,
});
}
out.sort_by_key(|w| std::cmp::Reverse(w.age_secs));
out
}
#[cfg(not(target_os = "linux"))]
fn parse_elapsed(text: &str) -> u64 {
let (days, rest) = match text.split_once('-') {
Some((d, r)) => (d.parse::<u64>().unwrap_or(0), r),
None => (0, text),
};
let mut seconds = 0u64;
for part in rest.split(':') {
seconds = seconds * 60 + part.parse::<u64>().unwrap_or(0);
}
days * 86400 + seconds
}
#[cfg(target_os = "linux")]
fn ancestors_of(mut pid: i32) -> Vec<i32> {
let mut out = Vec::new();
for _ in 0..64 {
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
break;
};
let Some(rest) = stat.rsplit_once(") ") else {
break;
};
let fields: Vec<&str> = rest.1.split_whitespace().collect();
if fields.len() < 2 {
break;
}
let Ok(parent) = fields[1].parse::<i32>() else {
break;
};
if parent <= 1 {
break;
}
out.push(parent);
pid = parent;
}
out
}
#[cfg(target_os = "linux")]
fn read_uptime() -> f64 {
std::fs::read_to_string("/proc/uptime")
.ok()
.and_then(|t| t.split_whitespace().next().map(|s| s.to_string()))
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0)
}
#[cfg(target_os = "linux")]
fn process_age(dir: &std::path::Path, uptime: f64, ticks: f64) -> u64 {
let Ok(stat) = std::fs::read_to_string(dir.join("stat")) else {
return 0;
};
let Some(rest) = stat.rsplit_once(") ") else {
return 0;
};
let fields: Vec<&str> = rest.1.split_whitespace().collect();
if fields.len() < 20 {
return 0;
}
let started: f64 = fields[19].parse().unwrap_or(0.0);
(uptime - started / ticks).max(0.0) as u64
}
pub fn report(json: bool) -> anyhow::Result<i32> {
let found = find();
if json {
println!("{}", serde_json::to_string_pretty(&found)?);
return Ok(if found.is_empty() { 0 } else { 1 });
}
if found.is_empty() {
println!("no monitor script waits for a proxy on this machine.");
return Ok(0);
}
let total: u64 = found.iter().map(|w| w.age_secs).sum();
println!(
"{} monitor script(s) wait for a proxy. Together they have waited {}.",
found.len(),
crate::units::format_duration(std::time::Duration::from_secs(total))
);
println!();
for w in &found {
println!(
"{} pid {} waiting {}",
crate::style::warning("MONITOR"),
w.pid,
crate::units::format_duration(std::time::Duration::from_secs(w.age_secs))
);
println!(" waits for: {}", w.proxy);
println!(" command: {:.120}", w.command);
println!(" {}", w.advice);
println!();
}
println!(
"{}",
crate::style::faint(
"This command removes its own process and the processes that started it \
before it reports anything, so it never finds itself.\nStop one with \
`kill <pid>`. A kill of the shell does not stop the processes that the shell \
started; those go to the init process and continue.\nA monitor INSIDE A SCRIPT \
FILE gives the name of the script in its command line, and not the loop, so this \
command cannot see it. Read the script."
)
);
Ok(1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_real_monitors_are_recognised() {
let (proxy, _) = classify("bash -c while pgrep -f solve.py; do sleep 60; done").unwrap();
assert_eq!(proxy, "a pattern in the process list");
let (proxy, _) =
classify("bash -c until grep -q 'DONE' run.log; do sleep 60; done").unwrap();
assert_eq!(proxy, "a line in a log file");
let (proxy, _) =
classify("sh -c until [ -f /tmp/done.marker ]; do sleep 30; done").unwrap();
assert_eq!(proxy, "a file that appears");
}
#[test]
fn a_count_that_another_user_holds_above_zero_is_recognised() {
let (proxy, advice) = classify(
"bash -c while true; do M=$(ps -Ao args | grep -c solver); \
K=$(ssh host ps -Ao args | grep -c solver); sleep 300; done",
)
.unwrap();
assert!(
proxy.contains("count"),
"a count of the matches is its own fault: {proxy}"
);
assert!(
advice.contains("two agents share"),
"the advice must name the cause: {advice}"
);
let (proxy, _) =
classify("sh -c until ssh host pgrep -f train.py; do sleep 60; done").unwrap();
assert!(proxy.contains("another machine"), "got: {proxy}");
let (proxy, _) = classify("bash -c while pgrep -f solve.py; do sleep 60; done").unwrap();
assert_eq!(proxy, "a pattern in the process list");
}
#[test]
fn ordinary_commands_are_not_monitors() {
assert!(classify("cargo test --release").is_none());
assert!(
classify("grep -r pattern src/").is_none(),
"a search is not a loop"
);
assert!(classify("sleep 60").is_none(), "one sleep is not a loop");
assert!(
classify("pgrep -f something").is_none(),
"one search of the process list is not a monitor"
);
assert!(
classify("python3 train.py --epochs 50").is_none(),
"a long task is not a monitor"
);
}
#[test]
fn an_unknown_condition_is_still_a_monitor() {
let (proxy, advice) =
classify("bash -c while ! curl -sf localhost:8080; do sleep 5; done").unwrap();
assert_eq!(proxy, "an unknown condition");
assert!(advice.contains("qex"));
}
#[test]
fn the_search_never_finds_itself() {
let me = std::process::id() as i32;
let found = find();
assert!(
!found.iter().any(|w| w.pid == me),
"the search reported itself"
);
#[cfg(target_os = "linux")]
for parent in ancestors_of(me) {
assert!(
!found.iter().any(|w| w.pid == parent),
"the search reported a process that started it"
);
}
}
}