use std::collections::{HashMap, HashSet};
use std::process::Command;
use std::sync::{Mutex, Once};
use std::time::{Duration, Instant};
pub const SHARED_BACKSTOP_SECONDS: &str = "600";
pub const NO_PROGRESS_WINDOW: Duration = Duration::from_secs(5);
pub const CPU_BUDGET: Duration = Duration::from_secs(10);
pub const POLL: Duration = Duration::from_millis(100);
pub const UNCLAIMED_GRACE: Duration = Duration::from_secs(3);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Limits {
pub no_progress: Duration,
pub cpu_budget: Duration,
pub backstop: Duration,
}
pub const SHARED: Limits = Limits { no_progress: NO_PROGRESS_WINDOW, cpu_budget: CPU_BUDGET, backstop: Duration::from_secs(600) };
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Sample {
pub cpu_ticks: u64,
pub activity: u64,
pub processes: Vec<i32>,
pub runnable: bool,
pub disk_wait: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Reason {
NoProgress(Duration),
CpuBudget(Duration),
Backstop(Duration),
}
impl std::fmt::Display for Reason {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return match self {
Reason::NoProgress(idle) => write!(
formatter,
"no progress for {:.1} s: no task was runnable or waiting for the disk, and processor time, read and write counters and the set of processes stood still (a deadlock or a wait on something that never happens)",
idle.as_secs_f64()
),
Reason::CpuBudget(used) => write!(formatter, "it used {:.1} s of processor time (a loop)", used.as_secs_f64()),
Reason::Backstop(elapsed) => write!(formatter, "it was still running after the {:.0} s wall-clock backstop", elapsed.as_secs_f64()),
};
}
}
#[derive(Clone, Debug)]
pub struct Judge {
limits: Limits,
last_change: Duration,
previous: Option<(u64, u64, Vec<i32>)>,
cpu_peak_ticks: u64,
tick: Duration,
}
impl Judge {
pub fn new(limits: Limits, tick: Duration) -> Judge {
return Judge { limits, last_change: Duration::ZERO, previous: None, cpu_peak_ticks: 0, tick };
}
pub fn observe(&mut self, elapsed: Duration, sample: &Sample) -> Option<Reason> {
self.cpu_peak_ticks = self.cpu_peak_ticks.max(sample.cpu_ticks);
let used: Duration = self.tick.saturating_mul(u32::try_from(self.cpu_peak_ticks).unwrap_or(u32::MAX));
if used >= self.limits.cpu_budget {
return Some(Reason::CpuBudget(used));
}
if elapsed >= self.limits.backstop {
return Some(Reason::Backstop(elapsed));
}
let moved: bool = self
.previous
.as_ref()
.is_none_or(|(cpu, activity, processes)| { return *cpu != sample.cpu_ticks || *activity != sample.activity || *processes != sample.processes; });
if moved || sample.runnable || sample.disk_wait {
self.last_change = elapsed;
}
self.previous = Some((sample.cpu_ticks, sample.activity, sample.processes.clone()));
let idle: Duration = elapsed.saturating_sub(self.last_change);
if idle >= self.limits.no_progress {
return Some(Reason::NoProgress(idle));
}
return None;
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Stat {
pub state: char,
pub cpu_ticks: u64,
pub started: u64,
}
pub fn parse_stat(text: &str) -> Option<Stat> {
let after: &str = text.get(text.rfind(')')? + 1..)?;
let fields: Vec<&str> = after.split_whitespace().collect();
let number = |index: usize| -> Option<u64> { return fields.get(index)?.parse::<u64>().ok(); };
let cpu_ticks: u64 = number(11)?.saturating_add(number(12)?).saturating_add(number(13)?).saturating_add(number(14)?);
return Some(Stat { state: fields.first()?.chars().next()?, cpu_ticks, started: number(19)? });
}
pub fn parse_io(text: &str) -> u64 {
return text
.lines()
.filter_map(|line| { return line.split_once(": "); })
.filter(|(name, _)| { return matches!(*name, "rchar" | "wchar" | "syscr" | "syscw" | "read_bytes" | "write_bytes"); })
.filter_map(|(_, value)| { return value.trim().parse::<u64>().ok(); })
.fold(0u64, |total, value| { return total.saturating_add(value); });
}
fn children_of(pid: i32) -> Option<Vec<i32>> {
let mut found: Vec<i32> = Vec::new();
let Ok(tasks) = std::fs::read_dir(format!("/proc/{pid}/task")) else {
return Some(found);
};
for task in tasks.flatten() {
match std::fs::read_to_string(task.path().join("children")) {
Ok(text) => found.extend(text.split_whitespace().filter_map(|word| { return word.parse::<i32>().ok(); })),
Err(error) if error.kind() == std::io::ErrorKind::NotFound && !task.path().exists() => {}
Err(_) => return None,
}
}
return Some(found);
}
fn parent_of(pid: i32) -> Option<i32> {
let text: String = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let after: &str = text.get(text.rfind(')')? + 1..)?;
return after.split_whitespace().nth(1)?.parse::<i32>().ok();
}
fn children_by_scan(pid: i32) -> Vec<i32> {
let Ok(entries) = std::fs::read_dir("/proc") else {
return Vec::new();
};
return entries
.flatten()
.filter_map(|entry| { return entry.file_name().to_str()?.parse::<i32>().ok(); })
.filter(|candidate| { return parent_of(*candidate) == Some(pid); })
.collect();
}
pub fn children(pid: i32) -> Vec<i32> {
return children_of(pid).unwrap_or_else(|| { return children_by_scan(pid); });
}
pub fn tree_of(root: i32) -> Vec<i32> {
let mut found: Vec<i32> = vec![root];
let mut index: usize = 0;
while index < found.len() {
for child in children(found[index]) {
if !found.contains(&child) {
found.push(child);
}
}
index += 1;
}
return found;
}
pub fn tick() -> Duration {
let per_second: libc::c_long = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
return Duration::from_nanos(1_000_000_000 / u64::try_from(per_second).unwrap_or(100).max(1));
}
pub fn sample_tree(root: i32) -> Option<Sample> {
let mut sample: Sample = Sample::default();
let mut root_alive: bool = false;
for pid in tree_of(root) {
let Some(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok().and_then(|text| { return parse_stat(text.as_str()); }) else {
continue;
};
root_alive |= pid == root;
if stat.state == 'Z' {
sample.cpu_ticks = sample.cpu_ticks.saturating_add(stat.cpu_ticks);
continue;
}
sample.cpu_ticks = sample.cpu_ticks.saturating_add(stat.cpu_ticks);
sample.runnable |= stat.state == 'R';
sample.disk_wait |= stat.state == 'D';
sample.activity = sample.activity.saturating_add(std::fs::read_to_string(format!("/proc/{pid}/io")).map_or(0, |text| { return parse_io(text.as_str()); }));
sample.processes.push(pid);
}
sample.processes.sort_unstable();
return if root_alive { Some(sample) } else { None };
}
pub fn describe(root: i32) -> String {
let mut lines: Vec<String> = Vec::new();
for pid in tree_of(root) {
let name: String = std::fs::read_to_string(format!("/proc/{pid}/comm")).map_or_else(|_| { return String::from("?"); }, |text| { return text.trim().to_string(); });
let state: String = std::fs::read_to_string(format!("/proc/{pid}/stat"))
.ok()
.and_then(|text| { return parse_stat(text.as_str()); })
.map_or_else(|| { return String::from("gone"); }, |stat| { return stat.state.to_string(); });
let waiting: String = std::fs::read_to_string(format!("/proc/{pid}/wchan")).map_or_else(|_| { return String::from("?"); }, |text| { return text.trim().to_string(); });
let command_line: String = std::fs::read(format!("/proc/{pid}/cmdline"))
.map(|bytes| { return String::from_utf8_lossy(bytes.as_slice()).replace('\0', " "); })
.unwrap_or_default();
lines.push(format!(" pid {pid} ({name}) state {state} waiting in {waiting}: {}", command_line.trim().chars().take(160).collect::<String>()));
}
return lines.join("\n");
}
#[derive(Clone, Debug)]
struct Kill {
message: String,
at: Instant,
claimed: bool,
}
struct Watched {
judge: Judge,
began: Instant,
started: u64,
}
#[derive(Default)]
struct Registry {
watched: HashMap<i32, Watched>,
explicit: HashMap<i32, Limits>,
kills: HashMap<i32, Kill>,
disk_wait_looks: HashMap<i32, u32>,
}
static REGISTRY: Mutex<Option<Registry>> = Mutex::new(None);
fn with_registry<T>(action: impl FnOnce(&mut Registry) -> T) -> T {
let mut guard = REGISTRY.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
return action(guard.get_or_insert_with(Registry::default));
}
pub fn is_shared_command_line(arguments: &[&str]) -> bool {
return arguments.len() > 3
&& arguments[0].rsplit('/').next() == Some("timeout")
&& arguments[1] == "--signal=KILL"
&& arguments[2] == SHARED_BACKSTOP_SECONDS;
}
pub fn is_shared(command: &Command) -> bool {
let program: String = command.get_program().to_string_lossy().into_owned();
let arguments: Vec<String> = command.get_args().map(|argument| { return argument.to_string_lossy().into_owned(); }).collect();
let mut line: Vec<&str> = vec![program.as_str()];
line.extend(arguments.iter().map(String::as_str));
return is_shared_command_line(line.as_slice());
}
pub fn watch(pid: u32, limits: Limits) {
let watched: i32 = i32::try_from(pid).expect("a process ID fits");
ensure_monitor();
with_registry(|registry| { registry.explicit.insert(watched, limits); });
}
pub fn take_kill(pid: u32) -> Option<String> {
let ended: i32 = i32::try_from(pid).ok()?;
return with_registry(|registry| {
let kill: &mut Kill = registry.kills.get_mut(&ended)?;
kill.claimed = true;
return Some(kill.message.clone());
});
}
pub fn disk_wait_looks(pid: u32) -> u32 {
let Ok(watched) = i32::try_from(pid) else { return 0 };
return with_registry(|registry| { return registry.disk_wait_looks.get(&watched).copied().unwrap_or(0); });
}
pub fn ensure_monitor() {
static STARTED: Once = Once::new();
STARTED.call_once(|| {
std::thread::Builder::new()
.name(String::from("binary-supervisor"))
.spawn(monitor)
.expect("the supervisor thread starts");
});
}
fn command_line_of(pid: i32) -> Vec<String> {
return std::fs::read(format!("/proc/{pid}/cmdline"))
.map(|bytes| { return bytes.split(|byte| { return *byte == 0; }).filter(|word| { return !word.is_empty(); }).map(|word| { return String::from_utf8_lossy(word).into_owned(); }).collect(); })
.unwrap_or_default();
}
fn directory_of(pid: i32) -> String {
return std::fs::read_link(format!("/proc/{pid}/cwd")).map_or_else(|_| { return String::from("?"); }, |path| { return path.display().to_string(); });
}
pub fn end_tree(pid: i32, tree: &[i32]) {
unsafe { libc::kill(-pid, libc::SIGKILL) };
for member in tree {
unsafe { libc::kill(*member, libc::SIGKILL) };
}
}
fn monitor() {
let own: i32 = i32::try_from(std::process::id()).expect("a process ID fits");
let tick: Duration = tick();
let mut not_shared: HashSet<(i32, u64)> = HashSet::new();
loop {
std::thread::sleep(POLL);
let present: Vec<i32> = children(own);
discover(present.as_slice(), &mut not_shared, tick);
let now: Instant = Instant::now();
let ids: Vec<i32> = with_registry(|registry| { return registry.watched.keys().copied().collect(); });
for pid in ids {
judge_one(pid, now);
}
with_registry(|registry| {
registry.watched.retain(|pid, _| { return present.contains(pid); });
registry.explicit.retain(|pid, _| { return present.contains(pid); });
registry.kills.retain(|pid, kill| { return !kill.claimed || present.contains(pid); });
});
abort_on_unclaimed(now);
}
}
fn discover(present: &[i32], not_shared: &mut HashSet<(i32, u64)>, tick: Duration) {
for pid in present.iter().copied() {
let Some(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok().and_then(|text| { return parse_stat(text.as_str()); }) else {
continue;
};
if not_shared.contains(&(pid, stat.started)) || with_registry(|registry| { return registry.watched.get(&pid).is_some_and(|watched| { return watched.started == stat.started; }); }) {
continue;
}
if with_registry(|registry| { return registry.kills.contains_key(&pid); }) {
continue;
}
let explicit: Option<Limits> = with_registry(|registry| { return registry.explicit.get(&pid).copied(); });
let line: Vec<String> = command_line_of(pid);
let words: Vec<&str> = line.iter().map(String::as_str).collect();
let found: Option<Limits> = explicit.or_else(|| { return is_shared_command_line(words.as_slice()).then_some(SHARED); });
match found {
Some(limits) => with_registry(|registry| {
registry.watched.insert(pid, Watched { judge: Judge::new(limits, tick), began: Instant::now(), started: stat.started });
}),
None => {
not_shared.insert((pid, stat.started));
}
}
}
not_shared.retain(|(pid, _)| { return present.contains(pid); });
}
fn judge_one(pid: i32, now: Instant) {
let Some(sample) = sample_tree(pid) else { return };
if sample.disk_wait {
with_registry(|registry| { *registry.disk_wait_looks.entry(pid).or_default() += 1; });
}
let verdict: Option<(Reason, Instant)> = with_registry(|registry| {
let watched: &mut Watched = registry.watched.get_mut(&pid)?;
let began: Instant = watched.began;
return watched.judge.observe(now.saturating_duration_since(began), &sample).map(|reason| { return (reason, began); });
});
let Some((reason, _began)) = verdict else { return };
let tree: Vec<i32> = tree_of(pid);
let line: String = command_line_of(pid).join(" ");
let message: String = format!(
"the harness supervisor ended the command `{line}` in {}: {reason}\nthe tasks of its tree when it was ended:\n{}",
directory_of(pid),
describe(pid)
);
end_tree(pid, tree.as_slice());
eprintln!("{message}");
with_registry(|registry| {
registry.watched.remove(&pid);
registry.kills.insert(pid, Kill { message, at: Instant::now(), claimed: false });
});
}
pub fn unclaimed_kill(now: Instant, grace: Duration) -> Option<String> {
return with_registry(|registry| {
return registry.kills.values().find(|kill| { return !kill.claimed && now.saturating_duration_since(kill.at) >= grace; }).map(|kill| { return kill.message.clone(); });
});
}
pub fn plant_kill(pid: i32, message: &str, at: Instant) {
with_registry(|registry| { registry.kills.insert(pid, Kill { message: message.to_string(), at, claimed: false }); });
}
fn abort_on_unclaimed(now: Instant) {
if let Some(message) = unclaimed_kill(now, UNCLAIMED_GRACE) {
eprintln!("a command the supervisor ended was never reported by its test, so the process aborts:\n{message}");
std::process::abort();
}
}