ic_host_process/sampling/
mod.rs1use std::collections::{BTreeMap, BTreeSet};
5
6#[cfg(target_os = "macos")]
7use std::process::{Command, Stdio};
8#[cfg(target_os = "linux")]
9use std::{
10 fs::{self, File},
11 io::Read as _,
12 path::Path,
13};
14
15use std::{fmt, io, path::PathBuf, process::ExitStatus};
16
17#[derive(Debug)]
19pub enum SamplingError {
20 Io(io::Error),
22 InvalidData(&'static str),
24 CommandFailed {
26 program: PathBuf,
28 status: ExitStatus,
30 },
31 UnsupportedHost,
33}
34
35impl fmt::Display for SamplingError {
36 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37 match self {
38 Self::Io(error) => error.fmt(formatter),
39 Self::InvalidData(message) => formatter.write_str(message),
40 Self::CommandFailed { program, status } => {
41 write!(formatter, "{} exited with {status}", program.display())
42 }
43 Self::UnsupportedHost => formatter.write_str("native sampling requires Linux or macOS"),
44 }
45 }
46}
47
48impl std::error::Error for SamplingError {
49 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
50 match self {
51 Self::Io(error) => Some(error),
52 _ => None,
53 }
54 }
55}
56
57impl From<io::Error> for SamplingError {
58 fn from(error: io::Error) -> Self {
59 Self::Io(error)
60 }
61}
62
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65pub struct Process {
66 pub parent: u32,
68 pub resident_bytes: u64,
70}
71
72pub type Snapshot = BTreeMap<u32, Process>;
74
75#[must_use]
77pub const fn source() -> &'static str {
78 if cfg!(target_os = "linux") {
79 "linux-proc-stat"
80 } else if cfg!(target_os = "macos") {
81 "macos-ps-rss"
82 } else {
83 "unsupported"
84 }
85}
86
87#[cfg(any(target_os = "linux", test))]
88fn parse_stat(stat: &str, page_bytes: u64) -> Result<Process, SamplingError> {
89 let (_, fields) = stat.rsplit_once(')').ok_or(SamplingError::InvalidData(
92 "missing process command terminator",
93 ))?;
94 let mut fields = fields.split_whitespace();
95 let parent = fields
96 .nth(1)
97 .and_then(|field| field.parse().ok())
98 .ok_or(SamplingError::InvalidData("invalid process parent"))?;
99 let pages: u64 = fields
100 .nth(19)
101 .and_then(|field| field.parse().ok())
102 .ok_or(SamplingError::InvalidData("invalid process RSS pages"))?;
103 let resident_bytes = pages
104 .checked_mul(page_bytes)
105 .ok_or(SamplingError::InvalidData("RSS byte count overflow"))?;
106 Ok(Process {
107 parent,
108 resident_bytes,
109 })
110}
111
112#[cfg(target_os = "linux")]
113fn process_snapshot(
114 proc: &Path,
115 page_bytes: u64,
116 record_bytes: u64,
117) -> Result<Snapshot, SamplingError> {
118 let mut processes = Snapshot::new();
119 for entry in fs::read_dir(proc)? {
120 let entry = entry?;
121 let Some(pid) = entry
122 .file_name()
123 .to_str()
124 .and_then(|name| name.parse::<u32>().ok())
125 else {
126 continue;
127 };
128 let stat = (|| -> io::Result<String> {
129 let mut stat = String::new();
130 File::open(entry.path().join("stat"))?
131 .take(record_bytes)
132 .read_to_string(&mut stat)?;
133 Ok(stat)
134 })();
135 match stat {
136 Ok(stat) => {
137 processes.insert(pid, parse_stat(&stat, page_bytes)?);
138 }
139 Err(error)
140 if matches!(
141 error.kind(),
142 io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied
143 ) => {}
144 Err(error) if error.raw_os_error() == Some(rustix::io::Errno::SRCH.raw_os_error()) => {}
145 Err(error) => return Err(error.into()),
146 }
147 }
148 Ok(processes)
149}
150
151#[cfg(any(target_os = "macos", test))]
152fn parse_ps(output: &str) -> Result<Snapshot, SamplingError> {
153 let mut processes = Snapshot::new();
154 for line in output.lines().filter(|line| !line.trim().is_empty()) {
155 let mut fields = line.split_whitespace();
156 let pid = fields
157 .next()
158 .and_then(|field| field.parse::<u32>().ok())
159 .ok_or(SamplingError::InvalidData("invalid ps PID"))?;
160 let parent = fields
161 .next()
162 .and_then(|field| field.parse().ok())
163 .ok_or(SamplingError::InvalidData("invalid ps parent"))?;
164 let rss: u64 = fields
165 .next()
166 .and_then(|field| field.parse().ok())
167 .ok_or(SamplingError::InvalidData("invalid ps RSS"))?;
168 if fields.next().is_some() {
169 return Err(SamplingError::InvalidData("unexpected ps fields"));
170 }
171 let resident_bytes = rss
173 .checked_mul(1024)
174 .ok_or(SamplingError::InvalidData("ps RSS byte count overflow"))?;
175 if processes
176 .insert(
177 pid,
178 Process {
179 parent,
180 resident_bytes,
181 },
182 )
183 .is_some()
184 {
185 return Err(SamplingError::InvalidData(
186 "duplicate process leader in ps output",
187 ));
188 }
189 }
190 Ok(processes)
191}
192
193pub fn snapshot(record_bytes: u64) -> Result<Snapshot, SamplingError> {
203 #[cfg(not(target_os = "linux"))]
204 let _ = record_bytes;
205 #[cfg(target_os = "linux")]
206 {
207 let page_bytes = rustix::param::page_size();
208 let page_bytes = u64::try_from(page_bytes)
209 .ok()
210 .filter(|value| *value > 0)
211 .ok_or(SamplingError::InvalidData("native page size unavailable"))?;
212 process_snapshot(Path::new("/proc"), page_bytes, record_bytes)
213 }
214 #[cfg(target_os = "macos")]
215 {
216 let output = Command::new("/bin/ps")
217 .args(["-A", "-o", "pid=,ppid=,rss="])
218 .env("LC_ALL", "C")
219 .stderr(Stdio::inherit())
220 .output()?;
221 if !output.status.success() {
222 return Err(SamplingError::CommandFailed {
223 program: "/bin/ps".into(),
224 status: output.status,
225 });
226 }
227 let output = std::str::from_utf8(&output.stdout)
228 .map_err(|_| SamplingError::InvalidData("ps output is not UTF-8"))?;
229 parse_ps(output)
230 }
231 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
232 Err(SamplingError::UnsupportedHost)
233}
234
235pub fn tree_rss(root: u32, processes: &Snapshot) -> Result<(u64, usize), SamplingError> {
243 if !processes.contains_key(&root) {
244 return Ok((0, 0));
245 }
246 let mut children: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
247 for (pid, process) in processes {
248 children.entry(process.parent).or_default().push(*pid);
249 }
250 let mut pending = vec![root];
251 let mut seen = BTreeSet::new();
252 let mut resident = 0_u64;
253 while let Some(pid) = pending.pop() {
254 if !seen.insert(pid) {
255 continue;
256 }
257 resident = resident
258 .checked_add(processes[&pid].resident_bytes)
259 .ok_or(SamplingError::InvalidData("process-tree RSS overflow"))?;
260 if let Some(children) = children.get(&pid) {
261 pending.extend(children);
262 }
263 }
264 Ok((resident, seen.len()))
265}
266
267#[cfg(test)]
268mod tests {
269 use super::SamplingError;
270 use super::{Process, Snapshot, parse_ps, parse_stat, snapshot, tree_rss};
271
272 #[test]
273 fn tree_counts_runner_and_descendants_but_not_parent_or_sibling() {
274 let processes: Snapshot = [
275 (1, 0, 10000),
276 (10, 1, 100),
277 (11, 1, 20000),
278 (20, 10, 200),
279 (21, 20, 300),
280 (22, 10, 400),
281 ]
282 .into_iter()
283 .map(|(pid, parent, resident_bytes)| {
284 (
285 pid,
286 Process {
287 parent,
288 resident_bytes,
289 },
290 )
291 })
292 .collect();
293 assert_eq!(tree_rss(10, &processes).unwrap(), (1000, 4));
294 assert_eq!(tree_rss(999, &processes).unwrap(), (0, 0));
295 let cycles = [
296 (
297 1,
298 Process {
299 parent: 2,
300 resident_bytes: 1,
301 },
302 ),
303 (
304 2,
305 Process {
306 parent: 1,
307 resident_bytes: 2,
308 },
309 ),
310 ]
311 .into();
312 assert_eq!(tree_rss(1, &cycles).unwrap(), (3, 2));
313 }
314
315 #[test]
316 fn kernel_stat_with_parentheses_in_comm_preserves_parent_and_rss() {
317 let fields = ["S", "42"]
318 .into_iter()
319 .chain(["0"; 19])
320 .chain(["7"])
321 .collect::<Vec<_>>()
322 .join(" ");
323 assert_eq!(
324 parse_stat(&format!("123 (name with ) parentheses) {fields}"), 4096).unwrap(),
325 Process {
326 parent: 42,
327 resident_bytes: 7 * 4096
328 }
329 );
330 assert!(matches!(
331 parse_stat("123 (name) S bad", 4096),
332 Err(SamplingError::InvalidData(_))
333 ));
334 }
335
336 #[test]
337 fn darwin_ps_preserves_parents_and_kib_units_and_rejects_bad_rows() {
338 let processes = parse_ps(" 10 1 4\n 20 10 8\n 21 20 0\n").unwrap();
339 assert_eq!(tree_rss(10, &processes).unwrap(), (12 * 1024, 3));
340 for malformed in [
341 "10 1",
342 "10 1 -1",
343 "10 1 18446744073709551615",
344 "10 1 1 extra",
345 "10 1 1\n10 1 2",
346 ] {
347 assert!(matches!(
348 parse_ps(malformed),
349 Err(SamplingError::InvalidData(_))
350 ));
351 }
352 }
353
354 #[test]
355 #[cfg(any(target_os = "linux", target_os = "macos"))]
356 fn native_snapshot_includes_this_process_once() {
357 let processes = snapshot(64 * 1024).unwrap();
358 let own = processes
359 .get(&std::process::id())
360 .expect("native sampler observes test process");
361 assert!(own.parent > 0);
362 assert!(own.resident_bytes > 0);
363 assert!(tree_rss(std::process::id(), &processes).unwrap().0 >= own.resident_bytes);
364 }
365}