use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
pub const RUNTIME_DIR_ENV: &str = "ASC_RUNTIME_DIR";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProcessUsage {
pub pid: u32,
pub bytes: u64,
pub idle: Duration,
pub busy: Option<bool>,
}
pub struct ProcessRegistry {
dir: PathBuf,
pid: u32,
start: u64,
}
impl ProcessRegistry {
pub fn default_dir() -> Option<PathBuf> {
if let Some(d) = std::env::var_os(RUNTIME_DIR_ENV) {
return Some(PathBuf::from(d));
}
if let Some(d) = std::env::var_os("XDG_RUNTIME_DIR") {
return Some(PathBuf::from(d).join("asc"));
}
Some(std::env::temp_dir().join(format!("asc-{}", uid()?)).join("procs"))
}
pub fn open(dir: impl Into<PathBuf>) -> Option<ProcessRegistry> {
let dir = dir.into();
let pid = std::process::id();
let start = start_time(pid)?;
create_private_dir(&dir)?;
let reg = ProcessRegistry { dir, pid, start };
reg.update(0, false).ok()?;
Some(reg)
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn update(&self, bytes: u64, busy: bool) -> std::io::Result<()> {
let path = self.dir.join(self.pid.to_string());
let tmp = self.dir.join(format!(".{}.tmp", self.pid));
fs::File::create(&tmp)?.write_all(format!("{} {bytes} {}\n", self.start, u8::from(busy)).as_bytes())?;
fs::rename(&tmp, path)
}
pub fn others(&self) -> Vec<ProcessUsage> {
Self::others_in(&self.dir)
}
pub fn others_in(dir: &Path) -> Vec<ProcessUsage> {
let mut out = Vec::new();
let Ok(rd) = fs::read_dir(dir) else { return out };
let me = std::process::id();
for f in rd.flatten() {
let Some(pid) = f.file_name().to_str().and_then(|n| n.parse::<u32>().ok()) else { continue };
if pid == me {
continue;
}
let Ok(text) = fs::read_to_string(f.path()) else { continue };
let mut fields = text.split_whitespace().map(|v| v.parse::<u64>().ok());
let (Some(Some(start)), Some(Some(bytes))) = (fields.next(), fields.next()) else { continue };
let busy = fields.next().flatten().map(|b| b != 0);
if start_time(pid) == Some(start) {
let idle = f.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).unwrap_or_default();
out.push(ProcessUsage { pid, bytes, idle, busy });
} else {
let _ = fs::remove_file(f.path());
}
}
out
}
}
impl Drop for ProcessRegistry {
fn drop(&mut self) {
let _ = fs::remove_file(self.dir.join(self.pid.to_string()));
}
}
#[cfg(target_os = "linux")]
fn start_time(pid: u32) -> Option<u64> {
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
stat.rsplit_once(')')?.1.split_whitespace().nth(19)?.parse().ok()
}
#[cfg(target_os = "macos")]
fn start_time(pid: u32) -> Option<u64> {
let pid = libc::c_int::try_from(pid).ok()?;
let size = libc::c_int::try_from(std::mem::size_of::<libc::proc_bsdinfo>()).ok()?;
let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::zeroed();
let written = unsafe { libc::proc_pidinfo(pid, libc::PROC_PIDTBSDINFO, 0, info.as_mut_ptr().cast(), size) };
if written != size {
return None;
}
let info = unsafe { info.assume_init() };
Some(info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn start_time(_pid: u32) -> Option<u64> {
None
}
#[cfg(unix)]
fn uid() -> Option<u32> {
Some(unsafe { libc::getuid() })
}
#[cfg(not(unix))]
fn uid() -> Option<u32> {
None
}
#[cfg(unix)]
fn create_private_dir(dir: &Path) -> Option<()> {
use std::os::unix::fs::{DirBuilderExt, MetadataExt};
let _ = fs::DirBuilder::new().recursive(true).mode(0o700).create(dir);
let meta = fs::symlink_metadata(dir).ok()?;
(meta.is_dir() && Some(meta.uid()) == uid()).then_some(())
}
#[cfg(not(unix))]
fn create_private_dir(dir: &Path) -> Option<()> {
fs::create_dir_all(dir).ok()
}
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn processes_see_each_other_and_stale_entries_go() {
let dir = tempfile::tempdir().unwrap();
let me = ProcessRegistry::open(dir.path()).unwrap();
me.update(123, true).unwrap();
assert!(me.others().is_empty(), "never lists itself");
let init_start = start_time(1).unwrap();
fs::write(dir.path().join("1"), format!("{init_start} 456 1\n")).unwrap();
fs::write(dir.path().join("2"), "1 789\n").unwrap();
fs::write(dir.path().join("notes"), "x").unwrap();
let others: Vec<_> = me.others().iter().map(|o| (o.pid, o.bytes, o.busy)).collect();
assert_eq!(others, vec![(1, 456, Some(true))]);
fs::write(dir.path().join("1"), format!("{init_start} 456\n")).unwrap();
assert_eq!(me.others()[0].busy, None);
assert_eq!(ProcessRegistry::others_in(dir.path()).len(), 1, "readable without registering");
assert!(me.others()[0].idle < Duration::from_secs(60), "just written");
assert!(!dir.path().join("2").exists(), "dead entry removed");
assert!(dir.path().join(std::process::id().to_string()).exists());
drop(me);
assert!(!dir.path().join(std::process::id().to_string()).exists(), "unregistered on drop");
}
#[test]
fn start_time_survives_odd_command_names() {
assert!(start_time(std::process::id()).is_some());
assert!(start_time(u32::MAX).is_none());
}
}