use std::collections::HashMap;
use std::fs;
use std::io::{self, Write as _};
use std::path::Path;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use openmoat_hosts::Host;
use crate::home::{self, Home};
const MAX_BYTES: u64 = 64 * 1024;
const WRITE_WAIT: Duration = Duration::from_millis(200);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Phase {
Stdin,
Relay,
Lock,
Context,
Compile,
Taint,
Cache,
Record,
Answer,
}
const PHASES: [Phase; 9] = [
Phase::Stdin,
Phase::Relay,
Phase::Lock,
Phase::Context,
Phase::Compile,
Phase::Taint,
Phase::Cache,
Phase::Record,
Phase::Answer,
];
impl Phase {
pub fn name(self) -> &'static str {
match self {
Self::Stdin => "stdin",
Self::Relay => "relay",
Self::Lock => "lock",
Self::Context => "context",
Self::Compile => "compile",
Self::Taint => "taint",
Self::Cache => "cache",
Self::Record => "record",
Self::Answer => "answer",
}
}
pub fn doing(self) -> &'static str {
match self {
Self::Stdin => "reading the tool call",
Self::Relay => "waiting for `openmoat run` to decide",
Self::Lock => "checking the policy lock",
Self::Context => "loading the policy",
Self::Compile => "deciding",
Self::Taint => "reading the session's earlier calls",
Self::Cache => "writing the session cache",
Self::Record => "writing the audit log",
Self::Answer => "answering the host",
}
}
pub fn from_name(name: &str) -> Option<Self> {
PHASES.into_iter().find(|p| p.name() == name)
}
}
static CURRENT: AtomicU8 = AtomicU8::new(0);
pub fn enter(phase: Phase) {
let index = PHASES.iter().position(|p| *p == phase).unwrap_or(0);
CURRENT.store(u8::try_from(index).unwrap_or(0), Ordering::Relaxed);
}
pub fn current() -> Phase {
PHASES
.get(usize::from(CURRENT.load(Ordering::Relaxed)))
.copied()
.unwrap_or(Phase::Stdin)
}
pub fn record(home: &Home, host: Host, now_ms: i64, phase: Phase, elapsed_ms: u128) {
let (done, written) = mpsc::channel();
let path = home.hangs_path();
let line = line(now_ms, host, phase, elapsed_ms);
let writer = thread::Builder::new().spawn(move || {
let _ = append(&path, &line);
let _ = done.send(());
});
if writer.is_ok() {
let _ = written.recv_timeout(WRITE_WAIT);
}
}
fn line(now_ms: i64, host: Host, phase: Phase, elapsed_ms: u128) -> String {
format!("{now_ms} {} {} {elapsed_ms}\n", host.id(), phase.name())
}
fn append(path: &Path, line: &str) -> io::Result<()> {
if let Ok(meta) = fs::symlink_metadata(path)
&& (!meta.is_file() || meta.len() >= MAX_BYTES)
{
return Err(io::ErrorKind::InvalidInput.into());
}
home::private_append_options()
.open(path)?
.write_all(line.as_bytes())
}
#[derive(Debug, PartialEq, Eq)]
pub struct Hangs {
pub count: usize,
pub newest_ms: i64,
pub common_phase: Option<Phase>,
}
pub fn since(path: &Path, since_ms: i64) -> Option<Hangs> {
if !fs::symlink_metadata(path).ok()?.is_file() {
return None;
}
let text = fs::read_to_string(path).ok()?;
let lines: Vec<(i64, Option<Phase>)> = text
.lines()
.filter_map(parse)
.filter(|(ms, _)| *ms >= since_ms)
.collect();
let newest_ms = lines.iter().map(|(ms, _)| *ms).max()?;
let mut counts: HashMap<Phase, usize> = HashMap::new();
for phase in lines.iter().filter_map(|(_, p)| *p) {
*counts.entry(phase).or_default() += 1;
}
let common_phase = PHASES
.into_iter()
.filter_map(|p| Some((p, *counts.get(&p)?)))
.fold(None::<(Phase, usize)>, |best, (p, n)| match best {
Some((_, m)) if m >= n => best,
_ => Some((p, n)),
})
.map(|(p, _)| p);
Some(Hangs {
count: lines.len(),
newest_ms,
common_phase,
})
}
fn parse(line: &str) -> Option<(i64, Option<Phase>)> {
let mut fields = line.split(' ');
let ms = fields.next()?.parse().ok()?;
let phase = fields.nth(1).and_then(Phase::from_name);
Some((ms, phase))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lines_are_counted_from_a_time_on() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("guard-hangs.log");
assert_eq!(since(&path, 0), None);
for line in ["100 codex\n", "not a line\n", "300 claude-code\n"] {
append(&path, line).unwrap();
}
let all = since(&path, 0).unwrap();
assert_eq!((all.count, all.newest_ms, all.common_phase), (2, 300, None));
assert_eq!(since(&path, 200).unwrap().count, 1);
assert_eq!(since(&path, 301), None);
}
#[test]
fn a_full_file_or_a_directory_is_left_alone() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("guard-hangs.log");
fs::write(&path, vec![b'\n'; usize::try_from(MAX_BYTES).unwrap()]).unwrap();
assert!(append(&path, "1 codex\n").is_err());
assert_eq!(fs::metadata(&path).unwrap().len(), MAX_BYTES);
assert!(append(dir.path(), "1 codex\n").is_err());
assert_eq!(since(dir.path(), 0), None);
}
#[test]
fn every_phase_has_a_distinct_name_that_reads_back() {
let mut names: Vec<&str> = PHASES.iter().map(|p| p.name()).collect();
for phase in PHASES {
assert_eq!(Phase::from_name(phase.name()), Some(phase));
enter(phase);
assert_eq!(current(), phase);
}
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), PHASES.len());
assert_eq!(Phase::from_name("nap"), None);
}
#[test]
fn a_line_names_the_time_host_phase_and_elapsed_ms() {
let line = line(1_700, Host::Codex, Phase::Record, 30_012);
assert_eq!(line, "1700 codex record 30012\n");
assert_eq!(parse(line.trim_end()), Some((1_700, Some(Phase::Record))));
assert_eq!(parse("1700 codex"), Some((1_700, None)), "an older line");
assert_eq!(parse("1700 codex later 5"), Some((1_700, None)));
}
#[test]
fn the_most_common_phase_is_reported_and_older_lines_still_count() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("guard-hangs.log");
let text = "10 codex\n20 codex taint 30001\n30 cursor record 30002\n\
40 claude-code record 30500\n";
fs::write(&path, text).unwrap();
let hangs = since(&path, 0).unwrap();
assert_eq!(hangs.count, 4);
assert_eq!(hangs.newest_ms, 40);
assert_eq!(hangs.common_phase, Some(Phase::Record));
fs::write(&path, "20 codex taint 1\n30 codex record 1\n").unwrap();
assert_eq!(since(&path, 0).unwrap().common_phase, Some(Phase::Taint));
}
}