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
6use crate::tool::{OutputLimits, ToolError};
7
8#[cfg(any(target_os = "macos", test))]
9use std::process::Command;
10#[cfg(target_os = "linux")]
11use std::{
12    fs::{self, File},
13    path::Path,
14};
15
16use std::{fmt, io};
17
18/// Failure to observe a native process table or sum its resident bytes.
19#[derive(Debug)]
20pub enum SamplingError {
21    /// Native I/O, stat-record storage/byte admission or stat UTF-8 failed.
22    /// Non-I/O stream refusals retain an artifact error as the I/O error's cause.
23    Io(io::Error),
24    /// A native record was malformed or its byte arithmetic overflowed.
25    InvalidData(&'static str),
26    /// Native table capture, exit or completeness admission failed, with bounded
27    /// output and independent cleanup evidence retained by the capture engine.
28    Command(Box<ToolError>),
29    /// This host has no supported process-table observation backend.
30    UnsupportedHost,
31}
32
33impl fmt::Display for SamplingError {
34    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            Self::Io(error) => error.fmt(formatter),
37            Self::InvalidData(message) => formatter.write_str(message),
38            Self::Command(source) => write!(formatter, "process table capture failed: {source}"),
39            Self::UnsupportedHost => formatter.write_str("native sampling requires Linux or macOS"),
40        }
41    }
42}
43
44impl std::error::Error for SamplingError {
45    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
46        match self {
47            Self::Io(error) => Some(error),
48            Self::Command(error) => Some(error.as_ref()),
49            _ => None,
50        }
51    }
52}
53
54impl From<io::Error> for SamplingError {
55    fn from(error: io::Error) -> Self {
56        Self::Io(error)
57    }
58}
59
60/// One process leader observed in a native table.
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub struct Process {
63    /// Observed parent process identifier.
64    pub parent: u32,
65    /// Native resident memory in bytes; shared pages may occur in multiple processes.
66    pub resident_bytes: u64,
67}
68
69/// Native observations indexed by process-leader identifier.
70pub type Snapshot = BTreeMap<u32, Process>;
71
72/// Measurement-source identity; consumers retain it with their comparisons.
73#[must_use]
74pub const fn source() -> &'static str {
75    if cfg!(target_os = "linux") {
76        "linux-proc-stat"
77    } else if cfg!(target_os = "macos") {
78        "macos-ps-rss"
79    } else {
80        "unsupported"
81    }
82}
83
84#[cfg(any(target_os = "linux", test))]
85fn parse_stat(stat: &str, page_bytes: u64) -> Result<Process, SamplingError> {
86    // comm can contain spaces and closing parentheses. Remaining fields start
87    // at state (3), then PPID (4), and RSS in pages (24).
88    let (_, fields) = stat.rsplit_once(')').ok_or(SamplingError::InvalidData(
89        "missing process command terminator",
90    ))?;
91    let mut fields = fields.split_whitespace();
92    let parent = fields
93        .nth(1)
94        .and_then(|field| field.parse().ok())
95        .ok_or(SamplingError::InvalidData("invalid process parent"))?;
96    let pages: u64 = fields
97        .nth(19)
98        .and_then(|field| field.parse().ok())
99        .ok_or(SamplingError::InvalidData("invalid process RSS pages"))?;
100    let resident_bytes = pages
101        .checked_mul(page_bytes)
102        .ok_or(SamplingError::InvalidData("RSS byte count overflow"))?;
103    Ok(Process {
104        parent,
105        resident_bytes,
106    })
107}
108
109#[cfg(target_os = "linux")]
110fn process_snapshot(
111    proc: &Path,
112    page_bytes: u64,
113    record_bytes: u64,
114) -> Result<Snapshot, SamplingError> {
115    let record_bytes = usize::try_from(record_bytes)
116        .map_err(|_| SamplingError::InvalidData("stat allowance exceeds the host address space"))?;
117    let mut processes = Snapshot::new();
118    for entry in fs::read_dir(proc)? {
119        let entry = entry?;
120        let Some(pid) = entry
121            .file_name()
122            .to_str()
123            .and_then(|name| name.parse::<u32>().ok())
124        else {
125            continue;
126        };
127        let stat = File::open(entry.path().join("stat")).and_then(|file| {
128            ic_host_artifacts::artifact::read_reader(file, record_bytes).map_err(io::Error::from)
129        });
130        match stat {
131            Ok(stat) => {
132                let stat = std::str::from_utf8(&stat)
133                    .map_err(|source| io::Error::new(io::ErrorKind::InvalidData, source))?;
134                processes.insert(pid, parse_stat(stat, page_bytes)?);
135            }
136            Err(error)
137                if matches!(
138                    error.kind(),
139                    io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied
140                ) => {}
141            Err(error) if error.raw_os_error() == Some(rustix::io::Errno::SRCH.raw_os_error()) => {}
142            Err(error) => return Err(error.into()),
143        }
144    }
145    Ok(processes)
146}
147
148#[cfg(any(target_os = "macos", test))]
149fn parse_ps(output: &str) -> Result<Snapshot, SamplingError> {
150    let mut processes = Snapshot::new();
151    for line in output.lines().filter(|line| !line.trim().is_empty()) {
152        let mut fields = line.split_whitespace();
153        let pid = fields
154            .next()
155            .and_then(|field| field.parse::<u32>().ok())
156            .ok_or(SamplingError::InvalidData("invalid ps PID"))?;
157        let parent = fields
158            .next()
159            .and_then(|field| field.parse().ok())
160            .ok_or(SamplingError::InvalidData("invalid ps parent"))?;
161        let rss: u64 = fields
162            .next()
163            .and_then(|field| field.parse().ok())
164            .ok_or(SamplingError::InvalidData("invalid ps RSS"))?;
165        if fields.next().is_some() {
166            return Err(SamplingError::InvalidData("unexpected ps fields"));
167        }
168        // Darwin ps documents RSS in 1024-byte units, not host memory pages.
169        let resident_bytes = rss
170            .checked_mul(1024)
171            .ok_or(SamplingError::InvalidData("ps RSS byte count overflow"))?;
172        if processes
173            .insert(
174                pid,
175                Process {
176                    parent,
177                    resident_bytes,
178                },
179            )
180            .is_some()
181        {
182            return Err(SamplingError::InvalidData(
183                "duplicate process leader in ps output",
184            ));
185        }
186    }
187    Ok(processes)
188}
189
190#[cfg(any(target_os = "macos", test))]
191fn command_snapshot(
192    command: &mut Command,
193    limits: OutputLimits,
194) -> Result<Snapshot, SamplingError> {
195    let output = crate::tool::capture_command(command, limits)
196        .and_then(crate::tool::CompletedExecution::require_success)
197        .and_then(crate::tool::CompletedExecution::require_complete)
198        .map_err(|source| SamplingError::Command(Box::new(source)))?;
199    let output = std::str::from_utf8(&output.stdout)
200        .map_err(|_| SamplingError::InvalidData("ps output is not UTF-8"))?;
201    parse_ps(output)
202}
203
204/// Observe process leaders using Linux /proc or the macOS native ps table.
205///
206/// `record_bytes` bounds each complete Linux stat record; oversized records refuse
207/// before parsing, even if a retained prefix contains plausible numeric fields.
208/// Consumers own sampling cadence,
209/// workload selection and total snapshot memory. `command_limits` selects macOS
210/// stdout/stderr retention and an optional capture deadline; `timeout: None`
211/// imposes no elapsed-time deadline. Linux does not use `command_limits`, and
212/// macOS does not use `record_bytes`.
213/// Processes that disappear or deny stat access during Linux enumeration are skipped.
214/// macOS ps RSS uses 1024-byte units, independently of native memory page size.
215/// The native command's stderr is captured rather than printed. Failed captures
216/// retain bounded output and cleanup evidence in [`SamplingError::Command`].
217/// Successful but truncated output refuses before parsing, even if the retained
218/// prefix contains complete rows. This is not a bound on total parsed table memory.
219///
220/// # Errors
221/// Returns native I/O, capture/exit/truncation, malformed-record or checked-arithmetic
222/// failures. macOS uses the direct-child capture engine; deadlines and cleanup
223/// have that engine's documented synchronous syscall and reaping limitations.
224pub fn snapshot(
225    record_bytes: u64,
226    command_limits: OutputLimits,
227) -> Result<Snapshot, SamplingError> {
228    #[cfg(not(target_os = "linux"))]
229    let _ = record_bytes;
230    #[cfg(not(target_os = "macos"))]
231    let _ = command_limits;
232    #[cfg(target_os = "linux")]
233    {
234        let page_bytes = rustix::param::page_size();
235        let page_bytes = u64::try_from(page_bytes)
236            .ok()
237            .filter(|value| *value > 0)
238            .ok_or(SamplingError::InvalidData("native page size unavailable"))?;
239        process_snapshot(Path::new("/proc"), page_bytes, record_bytes)
240    }
241    #[cfg(target_os = "macos")]
242    {
243        command_snapshot(
244            Command::new("/bin/ps")
245                .args(["-A", "-o", "pid=,ppid=,rss="])
246                .env("LC_ALL", "C"),
247            command_limits,
248        )
249    }
250    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
251    Err(SamplingError::UnsupportedHost)
252}
253
254/// Sum observed resident bytes for a root and its descendants, counting each leader once.
255///
256/// Missing roots yield zero; cycles cannot repeat an observation. This is a sampled
257/// sum of native RSS, not unique physical memory or proof that a process is still live.
258///
259/// # Errors
260/// Returns an arithmetic overflow rather than wrapping the resident byte total.
261pub fn tree_rss(root: u32, processes: &Snapshot) -> Result<(u64, usize), SamplingError> {
262    if !processes.contains_key(&root) {
263        return Ok((0, 0));
264    }
265    let mut children: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
266    for (pid, process) in processes {
267        children.entry(process.parent).or_default().push(*pid);
268    }
269    let mut pending = vec![root];
270    let mut seen = BTreeSet::new();
271    let mut resident = 0_u64;
272    while let Some(pid) = pending.pop() {
273        if !seen.insert(pid) {
274            continue;
275        }
276        resident = resident
277            .checked_add(processes[&pid].resident_bytes)
278            .ok_or(SamplingError::InvalidData("process-tree RSS overflow"))?;
279        if let Some(children) = children.get(&pid) {
280            pending.extend(children);
281        }
282    }
283    Ok((resident, seen.len()))
284}
285
286#[cfg(test)]
287mod tests {
288    use super::SamplingError;
289    use super::{Process, Snapshot, command_snapshot, parse_ps, parse_stat, snapshot, tree_rss};
290    use crate::tool::{ExecutionFailure, OutputLimit, OutputLimits, OutputStream};
291    use std::process::Command;
292
293    const COMMAND_LIMITS: OutputLimits = OutputLimits {
294        stdout: OutputLimit::Terminate(1024 * 1024),
295        stderr: OutputLimit::Terminate(4096),
296        timeout: None,
297    };
298
299    #[test]
300    fn command_capture_admits_complete_rows_without_a_deadline() {
301        let observed = command_snapshot(
302            Command::new("/bin/sh").args(["-c", "printf '10 1 4\\n20 10 8\\n'"]),
303            COMMAND_LIMITS,
304        )
305        .unwrap();
306        assert_eq!(tree_rss(10, &observed).unwrap(), (12 * 1024, 2));
307        assert!(matches!(
308            command_snapshot(
309                Command::new("/bin/sh").args(["-c", "printf '\\377'"]),
310                COMMAND_LIMITS
311            ),
312            Err(SamplingError::InvalidData(_))
313        ));
314    }
315
316    #[test]
317    fn failed_table_commands_preserve_evidence_without_printing_it() {
318        use std::error::Error as _;
319        let error = command_snapshot(
320            Command::new("/bin/sh").args([
321                "-c",
322                "printf '10 1 4\\n'; printf 'private diagnostic' >&2; exit 23",
323            ]),
324            COMMAND_LIMITS,
325        )
326        .unwrap_err();
327        assert!(!error.to_string().contains("private diagnostic"));
328        let SamplingError::Command(source) = &error else {
329            panic!("table command failure retains the capture error");
330        };
331        let capture = source.execution_error().unwrap();
332        assert!(matches!(capture.failure, ExecutionFailure::ExitStatus));
333        assert_eq!(capture.evidence.status.unwrap().code(), Some(23));
334        assert_eq!(capture.evidence.stdout, b"10 1 4\n");
335        assert_eq!(capture.evidence.stderr, b"private diagnostic");
336        assert!(error.source().unwrap().is::<crate::tool::ToolError>());
337    }
338
339    #[test]
340    fn overflowing_or_truncated_tables_never_yield_partial_snapshots() {
341        for stdout in [OutputLimit::Terminate(7), OutputLimit::Truncate(7)] {
342            let error = command_snapshot(
343                Command::new("/bin/sh").args(["-c", "printf '10 1 4\\n20 10 8\\n'"]),
344                OutputLimits {
345                    stdout,
346                    ..COMMAND_LIMITS
347                },
348            )
349            .unwrap_err();
350            let SamplingError::Command(source) = error else {
351                panic!("partial tables must fail capture admission");
352            };
353            let capture = source.execution_error().unwrap();
354            assert!(matches!(
355                capture.failure,
356                ExecutionFailure::OutputLimit {
357                    stream: OutputStream::Stdout
358                }
359            ));
360            assert!(capture.evidence.stdout.len() <= 7);
361            assert!(capture.evidence.stdout_truncated);
362        }
363    }
364
365    #[test]
366    fn tree_counts_runner_and_descendants_but_not_parent_or_sibling() {
367        let processes: Snapshot = [
368            (1, 0, 10000),
369            (10, 1, 100),
370            (11, 1, 20000),
371            (20, 10, 200),
372            (21, 20, 300),
373            (22, 10, 400),
374        ]
375        .into_iter()
376        .map(|(pid, parent, resident_bytes)| {
377            (
378                pid,
379                Process {
380                    parent,
381                    resident_bytes,
382                },
383            )
384        })
385        .collect();
386        assert_eq!(tree_rss(10, &processes).unwrap(), (1000, 4));
387        assert_eq!(tree_rss(999, &processes).unwrap(), (0, 0));
388        let cycles = [
389            (
390                1,
391                Process {
392                    parent: 2,
393                    resident_bytes: 1,
394                },
395            ),
396            (
397                2,
398                Process {
399                    parent: 1,
400                    resident_bytes: 2,
401                },
402            ),
403        ]
404        .into();
405        assert_eq!(tree_rss(1, &cycles).unwrap(), (3, 2));
406    }
407
408    #[test]
409    fn kernel_stat_with_parentheses_in_comm_preserves_parent_and_rss() {
410        let fields = ["S", "42"]
411            .into_iter()
412            .chain(["0"; 19])
413            .chain(["7"])
414            .collect::<Vec<_>>()
415            .join(" ");
416        assert_eq!(
417            parse_stat(&format!("123 (name with ) parentheses) {fields}"), 4096).unwrap(),
418            Process {
419                parent: 42,
420                resident_bytes: 7 * 4096
421            }
422        );
423        assert!(matches!(
424            parse_stat("123 (name) S bad", 4096),
425            Err(SamplingError::InvalidData(_))
426        ));
427    }
428
429    #[test]
430    #[cfg(target_os = "linux")]
431    fn linux_stat_records_must_be_complete_within_the_byte_allowance() {
432        use ic_host_artifacts::artifact::ArtifactError;
433        use std::{fs, io};
434
435        let fixture = crate::test_support::Fixture::new();
436        fs::create_dir(fixture.root.join("123")).unwrap();
437        let fields = ["S", "42"]
438            .into_iter()
439            .chain(["0"; 19])
440            .chain(["12345"])
441            .collect::<Vec<_>>()
442            .join(" ");
443        let record = format!("123 (name with ) parentheses) {fields}");
444        fs::write(fixture.root.join("123/stat"), &record).unwrap();
445        let exact = u64::try_from(record.len()).unwrap();
446
447        // Cutting within RSS must not turn 12345 pages into a valid 123-page
448        // observation. Zero and one-byte-short bounds must also detect overflow.
449        for allowance in [exact - 2, 0, exact - 1] {
450            let error = super::process_snapshot(&fixture.root, 4096, allowance)
451                .expect_err("oversized stat records cannot yield partial RSS");
452            let SamplingError::Io(source) = error else {
453                panic!("record allowance refusal retains its typed stream cause");
454            };
455            assert_eq!(source.kind(), io::ErrorKind::Other);
456            assert!(matches!(
457                source.get_ref().unwrap().downcast_ref::<ArtifactError>(),
458                Some(ArtifactError::LimitExceeded { limit }) if *limit == allowance
459            ));
460        }
461        for allowance in [exact, exact + 1] {
462            let observed = super::process_snapshot(&fixture.root, 4096, allowance).unwrap();
463            assert_eq!(observed[&123].parent, 42);
464            assert_eq!(observed[&123].resident_bytes, 12345 * 4096);
465        }
466
467        fs::create_dir(fixture.root.join("124")).unwrap();
468        let observed = super::process_snapshot(&fixture.root, 4096, exact).unwrap();
469        assert_eq!(observed.len(), 1); // A vanished stat entry still skips.
470
471        fs::write(fixture.root.join("124/stat"), [0xff]).unwrap();
472        assert!(matches!(
473            super::process_snapshot(&fixture.root, 4096, exact),
474            Err(SamplingError::Io(source)) if source.kind() == io::ErrorKind::InvalidData
475        ));
476    }
477
478    #[test]
479    fn darwin_ps_preserves_parents_and_kib_units_and_rejects_bad_rows() {
480        let processes = parse_ps(" 10 1 4\n 20 10 8\n 21 20 0\n").unwrap();
481        assert_eq!(tree_rss(10, &processes).unwrap(), (12 * 1024, 3));
482        for malformed in [
483            "10 1",
484            "10 1 -1",
485            "10 1 18446744073709551615",
486            "10 1 1 extra",
487            "10 1 1\n10 1 2",
488        ] {
489            assert!(matches!(
490                parse_ps(malformed),
491                Err(SamplingError::InvalidData(_))
492            ));
493        }
494    }
495
496    #[test]
497    #[cfg(any(target_os = "linux", target_os = "macos"))]
498    fn native_snapshot_includes_this_process_once() {
499        let processes = snapshot(64 * 1024, COMMAND_LIMITS).unwrap();
500        let own = processes
501            .get(&std::process::id())
502            .expect("native sampler observes test process");
503        assert!(own.parent > 0);
504        assert!(own.resident_bytes > 0);
505        assert!(tree_rss(std::process::id(), &processes).unwrap().0 >= own.resident_bytes);
506    }
507}