Skip to main content

ic_host_process/sampling/
mod.rs

1//! Native process-leader RSS observations, independent of workload policy.
2//! Linux retains page-based /proc measurements; Darwin reports native ps RSS.
3
4use 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/// Failure to observe a native process table or sum its resident bytes.
18#[derive(Debug)]
19pub enum SamplingError {
20    /// Native filesystem or process invocation failed.
21    Io(io::Error),
22    /// A native record was malformed or its byte arithmetic overflowed.
23    InvalidData(&'static str),
24    /// The native process-table command failed.
25    CommandFailed {
26        /// Executed native program.
27        program: PathBuf,
28        /// Original exit status.
29        status: ExitStatus,
30    },
31    /// This host has no supported process-table observation backend.
32    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/// One process leader observed in a native table.
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65pub struct Process {
66    /// Observed parent process identifier.
67    pub parent: u32,
68    /// Native resident memory in bytes; shared pages may occur in multiple processes.
69    pub resident_bytes: u64,
70}
71
72/// Native observations indexed by process-leader identifier.
73pub type Snapshot = BTreeMap<u32, Process>;
74
75/// Measurement-source identity; consumers retain it with their comparisons.
76#[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    // comm can contain spaces and closing parentheses. Remaining fields start
90    // at state (3), then PPID (4), and RSS in pages (24).
91    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        // Darwin ps documents RSS in 1024-byte units, not host memory pages.
172        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
193/// Observe process leaders using Linux /proc or the macOS native ps table.
194///
195/// `record_bytes` bounds each Linux stat record. Consumers own sampling cadence,
196/// workload selection and total snapshot memory; no elapsed-time deadline is set.
197/// Processes that disappear or deny stat access during Linux enumeration are skipped.
198/// macOS ps RSS uses 1024-byte units, independently of native memory page size.
199///
200/// # Errors
201/// Returns native I/O, command, malformed-record or checked-arithmetic failures.
202pub 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
235/// Sum observed resident bytes for a root and its descendants, counting each leader once.
236///
237/// Missing roots yield zero; cycles cannot repeat an observation. This is a sampled
238/// sum of native RSS, not unique physical memory or proof that a process is still live.
239///
240/// # Errors
241/// Returns an arithmetic overflow rather than wrapping the resident byte total.
242pub 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}