1use 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#[derive(Debug)]
20pub enum SamplingError {
21 Io(io::Error),
24 InvalidData(&'static str),
26 Command(Box<ToolError>),
29 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub struct Process {
63 pub parent: u32,
65 pub resident_bytes: u64,
67}
68
69pub type Snapshot = BTreeMap<u32, Process>;
71
72#[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 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 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
204pub 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
254pub 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 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); 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}