use std::num::NonZeroU32;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::ledger::{self, RunPaths, Skipped};
use crate::sys;
pub const WATCHER_SCHEMA_VERSION: u32 = 1;
fn this_version<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<u32, D::Error> {
let found = u32::deserialize(reader)?;
if found != WATCHER_SCHEMA_VERSION {
return Err(serde::de::Error::custom(format!(
"watcher schema_version {found}, and this build reads {WATCHER_SCHEMA_VERSION}"
)));
}
Ok(found)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WatcherRecord {
#[serde(deserialize_with = "this_version")]
pub schema_version: u32,
pub run_id: String,
pub pid: NonZeroU32,
pub host: String,
pub started: String,
#[serde(deserialize_with = "an_instant")]
pub began_at: String,
}
fn an_instant<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
let found = String::deserialize(reader)?;
if !is_rfc3339(&found) {
return Err(serde::de::Error::custom(format!(
"began_at is '{found}', which is not an RFC 3339 instant"
)));
}
Ok(found)
}
fn is_rfc3339(text: &str) -> bool {
let Some((date, rest)) = text.split_once(['T', 't']) else {
return false;
};
let [year, month, day] = date.split('-').collect::<Vec<_>>()[..] else {
return false;
};
let (Some(year), Some(month), Some(day)) = (
number(year, 4, 0..=9_999),
number(month, 2, 1..=12),
number(day, 2, 1..=31),
) else {
return false;
};
if day > days_in(month, year) {
return false;
}
let (clock, zone) = match rest.find(['Z', 'z', '+']) {
Some(at) => rest.split_at(at),
None => match rest.rfind('-') {
Some(at) => rest.split_at(at),
None => return false,
},
};
if !matches!(zone, "Z" | "z") {
let Some((hours, minutes)) = zone
.strip_prefix('+')
.or_else(|| zone.strip_prefix('-'))
.and_then(|offset| offset.split_once(':'))
else {
return false;
};
if number(hours, 2, 0..=23).is_none() || number(minutes, 2, 0..=59).is_none() {
return false;
}
}
let (clock, fraction) = clock.split_once('.').unwrap_or((clock, "0"));
let [hour, minute, second] = clock.split(':').collect::<Vec<_>>()[..] else {
return false;
};
number(hour, 2, 0..=23).is_some()
&& number(minute, 2, 0..=59).is_some()
&& number(second, 2, 0..=60).is_some()
&& !fraction.is_empty()
&& fraction.chars().all(|c| c.is_ascii_digit())
}
fn number(text: &str, width: usize, allowed: std::ops::RangeInclusive<u32>) -> Option<u32> {
if text.len() != width || !text.chars().all(|c| c.is_ascii_digit()) {
return None;
}
text.parse().ok().filter(|value| allowed.contains(value))
}
fn days_in(month: u32, year: u32) -> u32 {
match month {
2 if year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)) => {
29
}
2 => 28,
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum WatchStanding {
Live,
AnotherRun,
AnotherHost,
ProcessGone,
AwaitingItsParent,
NotThatProcess,
Unproven,
}
impl WatchStanding {
pub fn is_live(self) -> bool {
matches!(self, Self::Live)
}
pub fn as_str(self) -> &'static str {
match self {
Self::Live => "watching",
Self::AnotherRun => "its record names another run",
Self::AnotherHost => "its record names another host",
Self::ProcessGone => "its process is gone",
Self::AwaitingItsParent => "its process ended and is waiting to be reaped",
Self::NotThatProcess => "its pid is not the process that recorded it",
Self::Unproven => "nothing can say whether it is the process that recorded it",
}
}
fn proved_gone(self) -> bool {
matches!(
self,
Self::ProcessGone | Self::AwaitingItsParent | Self::NotThatProcess
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Watch {
pub record: WatcherRecord,
pub standing: WatchStanding,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Watchers {
pub watches: Vec<Watch>,
pub refused: Vec<Skipped>,
}
impl Watchers {
pub fn of(paths: &RunPaths) -> Self {
let mut held = Self::default();
for (path, read) in records(paths) {
match read {
Ok(record) => {
let standing = standing_of(&record, &paths.run);
held.watches.push(Watch { record, standing });
}
Err(reason) => held.refused.push(Skipped { path, reason }),
}
}
held
}
pub fn any_live(&self) -> bool {
self.watches.iter().any(|watch| watch.standing.is_live())
}
pub fn why_not_watched(&self) -> String {
if self.watches.is_empty() && self.refused.is_empty() {
return "nothing has recorded a watch on it".to_string();
}
let mut said: Vec<String> = self
.watches
.iter()
.map(|watch| format!("pid {}: {}", watch.record.pid, watch.standing.as_str()))
.collect();
said.extend(
self.refused
.iter()
.map(|refused| format!("a record that cannot be read: {}", refused.reason)),
);
said.join("; ")
}
}
fn records(paths: &RunPaths) -> Vec<(PathBuf, Result<WatcherRecord, String>)> {
let dir = paths.watchers();
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(error) => {
return vec![(
dir,
Err(format!("the watcher directory cannot be read: {error}")),
)]
}
};
let mut read: Vec<(PathBuf, Result<WatcherRecord, String>)> = entries
.map(|entry| match entry {
Ok(entry) => {
let path = entry.path();
let record = read_record(&path);
(path, record)
}
Err(error) => (
dir.clone(),
Err(format!(
"an entry under the watcher directory cannot be read: {error}"
)),
),
})
.collect();
read.sort_by(|a, b| a.0.cmp(&b.0));
read
}
fn read_record(path: &std::path::Path) -> Result<WatcherRecord, String> {
let text = std::fs::read_to_string(path).map_err(|error| format!("{error}"))?;
serde_json::from_str(&text).map_err(|error| format!("{error}"))
}
fn standing_of(record: &WatcherRecord, run: &str) -> WatchStanding {
if record.run_id != run {
return WatchStanding::AnotherRun;
}
if record.host != sys::hostname() {
return WatchStanding::AnotherHost;
}
let pid = record.pid.get();
if !sys::process_may_be_live(pid) {
return WatchStanding::ProcessGone;
}
if sys::process_terminated_awaiting_parent(pid) {
return WatchStanding::AwaitingItsParent;
}
token_standing(sys::process_start_token(pid).as_ref(), &record.started)
}
fn token_standing(read: Option<&sys::StartToken>, recorded: &str) -> WatchStanding {
match read {
Some(token) if token.matches(recorded) => WatchStanding::Live,
Some(_) if !recorded.is_empty() => WatchStanding::NotThatProcess,
_ => WatchStanding::Unproven,
}
}
pub(crate) struct Armed {
path: Option<PathBuf>,
}
impl Armed {
pub(crate) fn arm(paths: &RunPaths) -> Self {
Self::sweep(paths);
let pid = sys::pid();
let record = WatcherRecord {
schema_version: WATCHER_SCHEMA_VERSION,
run_id: paths.run.clone(),
pid: NonZeroU32::new(pid).unwrap_or(NonZeroU32::MIN),
host: sys::hostname(),
started: sys::process_start_token(pid)
.map(|token| token.recorded().to_string())
.unwrap_or_default(),
began_at: sys::now_rfc3339(),
};
let path = paths.watcher(pid, &nonce());
Self {
path: ledger::write_json(&path, &record).ok().map(|()| path),
}
}
fn sweep(paths: &RunPaths) {
for (path, read) in records(paths) {
if read.is_ok_and(|record| standing_of(&record, &paths.run).proved_gone()) {
let _ = std::fs::remove_file(path);
}
}
}
}
impl Drop for Armed {
fn drop(&mut self) {
if let Some(path) = self.path.take() {
let _ = std::fs::remove_file(path);
}
}
}
fn nonce() -> String {
static MINTED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = MINTED.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
minted(
seq,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|since| since.as_nanos()),
)
}
fn minted(seq: u64, clock: Option<u128>) -> String {
use std::hash::{BuildHasher, Hasher};
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
hasher.write_u64(seq);
if let Some(nanos) = clock {
hasher.write_u128(nanos);
}
let seeded = hasher.finish();
format!("{seeded:016x}{seq:04x}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_token_this_host_would_not_give_is_not_a_mismatch() {
let mine = sys::process_start_token(sys::pid())
.expect("this host reports the start of the process asking");
let recorded = mine.recorded().to_string();
assert_eq!(
token_standing(Some(&mine), &recorded),
WatchStanding::Live,
"a token that matches the record is the process that wrote it"
);
let standing = token_standing(Some(&mine), "linux-proc-stat:1");
assert_eq!(standing, WatchStanding::NotThatProcess);
assert!(
standing.proved_gone(),
"a mismatch is what a sweep is for: the pid is held by something else"
);
let standing = token_standing(None, &recorded);
assert_eq!(
standing,
WatchStanding::Unproven,
"a token this host would not give was read as a mismatch, which is a live \
watcher's record erased on a reading nobody took"
);
assert!(
!standing.proved_gone(),
"a sweep would remove the record of a watcher this host merely could not \
judge, and nothing could ever put it back"
);
assert!(!standing.is_live(), "and it is still not a live watch");
for read in [Some(&mine), None] {
let standing = token_standing(read, "");
assert_eq!(standing, WatchStanding::Unproven);
assert!(
!standing.proved_gone(),
"a record with nothing to judge was swept"
);
}
}
#[test]
fn the_divergence_entry_names_the_file_this_build_writes() {
let block = crate::unwatched::tests::block();
let paths = RunPaths::under(std::path::Path::new("/runs"), "gated");
let minted = nonce();
let path = paths.watcher(4_242, &minted);
assert_eq!(
path.parent().and_then(|dir| dir.file_name()),
block["record_directory"].as_str().map(std::ffi::OsStr::new),
"entry 68 names a different directory than this build writes into"
);
let named = path
.file_name()
.and_then(|name| name.to_str())
.expect("the record has a name");
let shape = block["record_name"]
.as_str()
.expect("entry 68 names the file it writes");
let (before, after) = shape
.split_once("<nonce>")
.expect("the entry's shape names the nonce");
let before = before.replace("<pid>", "4242");
let held = named
.strip_prefix(&before)
.and_then(|rest| rest.strip_suffix(after))
.unwrap_or_else(|| {
panic!("`{named}` is not the `{shape}` entry 68 states, for pid 4242")
});
assert_eq!(
held, minted,
"the name carries something other than the nonce"
);
let least = usize::try_from(
block["nonce_hex_at_least"]
.as_u64()
.expect("entry 68 says how long the nonce is at least"),
)
.expect("a length");
assert!(
held.len() >= least && held.chars().all(|c| c.is_ascii_hexdigit()),
"`{held}` is not the at-least-{least} hexadecimal characters entry 68 asks for"
);
assert_ne!(nonce(), nonce());
}
#[test]
fn two_watches_that_read_no_clock_at_all_are_still_two_names() {
assert_ne!(
minted(7, None),
minted(7, None),
"a clock this host would not read left the name a function of the counter, and a \
counter restarts with the process"
);
assert_ne!(
minted(0, Some(1_757_000_000_000_000_000)),
minted(0, Some(1_757_000_000_000_000_000)),
"two watches that read one instant composed one name"
);
for name in [minted(0, None), minted(u64::MAX, Some(u128::MAX))] {
assert!(
name.len() >= 8 && name.chars().all(|c| c.is_ascii_hexdigit()),
"`{name}` is not the hexadecimal name entry 68 asks for"
);
}
}
#[test]
fn an_instant_is_one_in_every_shape_the_grammar_allows() {
for shape in [
"2026-09-09T18:21:04.123Z",
"2026-09-09T18:21:04Z",
"2026-09-09t18:21:04z",
"2026-09-09T18:21:04+02:00",
"2026-09-09T18:21:04-06:30",
"2026-09-09T18:21:04.000000001-06:30",
"2026-12-31T23:59:60Z",
"2024-02-29T18:21:04Z",
"2000-02-29T18:21:04Z",
] {
assert!(is_rfc3339(shape), "`{shape}` is an RFC 3339 instant");
}
assert!(
is_rfc3339(&sys::now_rfc3339()),
"this crate's own writer does not produce one: {}",
sys::now_rfc3339()
);
}
#[test]
fn what_is_not_an_instant_is_refused() {
for shape in [
"",
"now",
"2026-09-09",
"18:21:04Z",
"2026-9-9T18:21:04Z",
"2026-09-09T18:21Z",
"2026-09-09T18:21:04",
"2026-09-09T18:21:04.Z",
"2026-09-09T18:21:04+2:00",
"2026-09-09Txx:21:04Z",
"../../etc/passwd",
"2026-99-09T18:21:04Z",
"2026-09-99T18:21:04Z",
"2026-09-09T99:21:04Z",
"2026-09-09T18:99:04Z",
"2026-09-09T18:21:61Z",
"2026-09-09T18:21:04+99:00",
"2026-09-09T18:21:04+02:99",
"2026-02-30T18:21:04Z",
"2025-02-29T18:21:04Z",
"2100-02-29T18:21:04Z",
] {
assert!(!is_rfc3339(shape), "`{shape}` is not an RFC 3339 instant");
}
}
#[test]
fn a_record_whose_stamp_is_not_an_instant_is_not_a_record() {
let document = |began_at: &str| {
serde_json::json!({
"schema_version": WATCHER_SCHEMA_VERSION,
"run_id": "gated",
"pid": 4_242,
"host": "a-host",
"started": "linux-proc-stat:1",
"began_at": began_at,
})
};
serde_json::from_value::<WatcherRecord>(document("2026-09-09T18:21:04.123Z"))
.expect("a record this build's own writer would have written");
let refusal = serde_json::from_value::<WatcherRecord>(document("some time yesterday"))
.expect_err("a stamp that is not an instant is refused");
assert!(
refusal.to_string().contains("began_at"),
"the refusal does not say what it refused: {refusal}"
);
}
}