1use std::fs;
13use std::io::Write;
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16
17pub const RUNTIME_DIR_ENV: &str = "ASC_RUNTIME_DIR";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct ProcessUsage {
23 pub pid: u32,
24 pub bytes: u64,
26 pub idle: Duration,
28 pub busy: Option<bool>,
31}
32
33pub struct ProcessRegistry {
35 dir: PathBuf,
36 pid: u32,
37 start: u64,
38}
39
40impl ProcessRegistry {
41 pub fn default_dir() -> Option<PathBuf> {
44 if let Some(d) = std::env::var_os(RUNTIME_DIR_ENV) {
45 return Some(PathBuf::from(d));
46 }
47 if let Some(d) = std::env::var_os("XDG_RUNTIME_DIR") {
48 return Some(PathBuf::from(d).join("asc"));
49 }
50 Some(std::env::temp_dir().join(format!("asc-{}", uid()?)).join("procs"))
51 }
52
53 pub fn open(dir: impl Into<PathBuf>) -> Option<ProcessRegistry> {
57 let dir = dir.into();
58 let pid = std::process::id();
59 let start = start_time(pid)?;
60 create_private_dir(&dir)?;
61 let reg = ProcessRegistry { dir, pid, start };
62 reg.update(0, false).ok()?;
63 Some(reg)
64 }
65
66 pub fn dir(&self) -> &Path {
67 &self.dir
68 }
69
70 pub fn update(&self, bytes: u64, busy: bool) -> std::io::Result<()> {
73 let path = self.dir.join(self.pid.to_string());
74 let tmp = self.dir.join(format!(".{}.tmp", self.pid));
75 fs::File::create(&tmp)?.write_all(format!("{} {bytes} {}\n", self.start, u8::from(busy)).as_bytes())?;
76 fs::rename(&tmp, path)
77 }
78
79 pub fn others(&self) -> Vec<ProcessUsage> {
81 Self::others_in(&self.dir)
82 }
83
84 pub fn others_in(dir: &Path) -> Vec<ProcessUsage> {
87 let mut out = Vec::new();
88 let Ok(rd) = fs::read_dir(dir) else { return out };
89 let me = std::process::id();
90 for f in rd.flatten() {
91 let Some(pid) = f.file_name().to_str().and_then(|n| n.parse::<u32>().ok()) else { continue };
92 if pid == me {
93 continue;
94 }
95 let Ok(text) = fs::read_to_string(f.path()) else { continue };
96 let mut fields = text.split_whitespace().map(|v| v.parse::<u64>().ok());
97 let (Some(Some(start)), Some(Some(bytes))) = (fields.next(), fields.next()) else { continue };
98 let busy = fields.next().flatten().map(|b| b != 0);
99 if start_time(pid) == Some(start) {
100 let idle = f.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).unwrap_or_default();
101 out.push(ProcessUsage { pid, bytes, idle, busy });
102 } else {
103 let _ = fs::remove_file(f.path());
104 }
105 }
106 out
107 }
108}
109
110impl Drop for ProcessRegistry {
111 fn drop(&mut self) {
112 let _ = fs::remove_file(self.dir.join(self.pid.to_string()));
113 }
114}
115
116#[cfg(target_os = "linux")]
119fn start_time(pid: u32) -> Option<u64> {
120 let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
121 stat.rsplit_once(')')?.1.split_whitespace().nth(19)?.parse().ok()
124}
125
126#[cfg(target_os = "macos")]
128fn start_time(pid: u32) -> Option<u64> {
129 let pid = libc::c_int::try_from(pid).ok()?;
130 let size = libc::c_int::try_from(std::mem::size_of::<libc::proc_bsdinfo>()).ok()?;
131 let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::zeroed();
132 let written = unsafe { libc::proc_pidinfo(pid, libc::PROC_PIDTBSDINFO, 0, info.as_mut_ptr().cast(), size) };
135 if written != size {
136 return None;
137 }
138 let info = unsafe { info.assume_init() };
141 Some(info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec)
142}
143
144#[cfg(not(any(target_os = "linux", target_os = "macos")))]
145fn start_time(_pid: u32) -> Option<u64> {
146 None
147}
148
149#[cfg(unix)]
150fn uid() -> Option<u32> {
151 Some(unsafe { libc::getuid() })
153}
154
155#[cfg(not(unix))]
156fn uid() -> Option<u32> {
157 None
158}
159
160#[cfg(unix)]
163fn create_private_dir(dir: &Path) -> Option<()> {
164 use std::os::unix::fs::{DirBuilderExt, MetadataExt};
165 let _ = fs::DirBuilder::new().recursive(true).mode(0o700).create(dir);
166 let meta = fs::symlink_metadata(dir).ok()?;
167 (meta.is_dir() && Some(meta.uid()) == uid()).then_some(())
168}
169
170#[cfg(not(unix))]
171fn create_private_dir(dir: &Path) -> Option<()> {
172 fs::create_dir_all(dir).ok()
173}
174
175#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
176mod tests {
177 use super::*;
178
179 #[cfg(target_os = "linux")]
181 #[test]
182 fn processes_see_each_other_and_stale_entries_go() {
183 let dir = tempfile::tempdir().unwrap();
184 let me = ProcessRegistry::open(dir.path()).unwrap();
185 me.update(123, true).unwrap();
186 assert!(me.others().is_empty(), "never lists itself");
187 let init_start = start_time(1).unwrap();
189 fs::write(dir.path().join("1"), format!("{init_start} 456 1\n")).unwrap();
190 fs::write(dir.path().join("2"), "1 789\n").unwrap();
191 fs::write(dir.path().join("notes"), "x").unwrap();
192 let others: Vec<_> = me.others().iter().map(|o| (o.pid, o.bytes, o.busy)).collect();
194 assert_eq!(others, vec![(1, 456, Some(true))]);
195 fs::write(dir.path().join("1"), format!("{init_start} 456\n")).unwrap();
197 assert_eq!(me.others()[0].busy, None);
198 assert_eq!(ProcessRegistry::others_in(dir.path()).len(), 1, "readable without registering");
199 assert!(me.others()[0].idle < Duration::from_secs(60), "just written");
200 assert!(!dir.path().join("2").exists(), "dead entry removed");
201 assert!(dir.path().join(std::process::id().to_string()).exists());
202 drop(me);
203 assert!(!dir.path().join(std::process::id().to_string()).exists(), "unregistered on drop");
204 }
205
206 #[test]
207 fn start_time_survives_odd_command_names() {
208 assert!(start_time(std::process::id()).is_some());
209 assert!(start_time(u32::MAX).is_none());
210 }
211}