Skip to main content

fdu_core/counters/
process.rs

1//! Kernel-reported process counters sampled at phase boundaries.
2//!
3//! One shared snapshot/delta/rendering model serves every platform, while collectors
4//! populate only facts their kernel exposes. Linux uses `/proc/self/io` and
5//! `/proc/self/stat`; macOS uses `proc_pidinfo(PROC_PIDTASKINFO)`. Missing metrics remain
6//! absent and are never rendered as zero.
7
8#![cfg_attr(target_os = "macos", allow(unsafe_code))]
9
10use std::fmt::Write as _;
11
12/// Kernel source that populated a process snapshot.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Source {
15    /// Linux procfs (`/proc/self/io` and `/proc/self/stat`).
16    LinuxProc,
17    /// macOS `proc_pidinfo(PROC_PIDTASKINFO)`.
18    MacOsProcTaskInfo,
19}
20
21impl Source {
22    const fn group(self) -> &'static str {
23        match self {
24            Self::LinuxProc => "process (Linux procfs)",
25            Self::MacOsProcTaskInfo => "process (macOS proc_taskinfo)",
26        }
27    }
28}
29
30#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
31struct MacRaw {
32    unix_syscalls: u32,
33    mach_syscalls: u32,
34    faults: u32,
35    pageins: u32,
36}
37
38#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
39enum Raw {
40    #[default]
41    None,
42    MacOs(MacRaw),
43}
44
45/// Kernel-reported counters at one instant or across one delta.
46#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
47pub struct Snapshot {
48    source: Option<Source>,
49    raw: Raw,
50    /// `read`-family syscalls, available from Linux procfs.
51    pub read_syscalls: Option<u64>,
52    /// `write`-family syscalls, available from Linux procfs.
53    pub write_syscalls: Option<u64>,
54    /// Bytes read through the syscall layer, including page-cache hits (Linux).
55    pub bytes_through_read: Option<u64>,
56    /// Bytes written through the syscall layer (Linux).
57    pub bytes_through_write: Option<u64>,
58    /// Bytes actually fetched from a block device (Linux).
59    pub bytes_from_device: Option<u64>,
60    /// Minor page faults reported by Linux procfs.
61    pub minor_faults: Option<u64>,
62    /// Major page faults reported by Linux procfs.
63    pub major_faults: Option<u64>,
64    /// Total page faults reported by macOS.
65    pub page_faults: Option<u64>,
66    /// Page-ins reported by macOS.
67    pub pageins: Option<u64>,
68    /// Total Mach and Unix syscalls reported by macOS.
69    pub total_syscalls: Option<u64>,
70}
71
72impl Snapshot {
73    /// Read the current platform's supported process counters.
74    ///
75    /// Sampling never fails the observed operation. An unavailable kernel interface
76    /// yields an empty snapshot whose renderer states that no source was available.
77    #[must_use]
78    pub fn now() -> Self {
79        #[cfg(target_os = "linux")]
80        {
81            linux_snapshot()
82        }
83        #[cfg(target_os = "macos")]
84        {
85            macos_snapshot()
86        }
87        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
88        {
89            Self::default()
90        }
91    }
92
93    /// Which kernel interface supplied this snapshot.
94    #[must_use]
95    pub const fn source(&self) -> Option<Source> {
96        self.source
97    }
98
99    /// Whether at least one supported kernel source was available.
100    #[must_use]
101    pub const fn is_available(&self) -> bool {
102        self.source.is_some()
103    }
104
105    /// This snapshot minus an earlier one: work performed between the samples.
106    #[must_use]
107    pub fn since(&self, earlier: &Self) -> Self {
108        let Some(source) = self.source.filter(|source| Some(*source) == earlier.source) else {
109            return Self::default();
110        };
111
112        if let (Raw::MacOs(later), Raw::MacOs(before)) = (self.raw, earlier.raw) {
113            let unix = later.unix_syscalls.wrapping_sub(before.unix_syscalls);
114            let mach = later.mach_syscalls.wrapping_sub(before.mach_syscalls);
115            let faults = later.faults.wrapping_sub(before.faults);
116            let pageins = later.pageins.wrapping_sub(before.pageins);
117            return Self {
118                source: Some(source),
119                raw: Raw::MacOs(MacRaw {
120                    unix_syscalls: unix,
121                    mach_syscalls: mach,
122                    faults,
123                    pageins,
124                }),
125                total_syscalls: Some(u64::from(unix) + u64::from(mach)),
126                page_faults: Some(u64::from(faults)),
127                pageins: Some(u64::from(pageins)),
128                ..Self::default()
129            };
130        }
131
132        Self {
133            source: Some(source),
134            read_syscalls: delta(self.read_syscalls, earlier.read_syscalls),
135            write_syscalls: delta(self.write_syscalls, earlier.write_syscalls),
136            bytes_through_read: delta(self.bytes_through_read, earlier.bytes_through_read),
137            bytes_through_write: delta(self.bytes_through_write, earlier.bytes_through_write),
138            bytes_from_device: delta(self.bytes_from_device, earlier.bytes_from_device),
139            minor_faults: delta(self.minor_faults, earlier.minor_faults),
140            major_faults: delta(self.major_faults, earlier.major_faults),
141            page_faults: delta(self.page_faults, earlier.page_faults),
142            pageins: delta(self.pageins, earlier.pageins),
143            total_syscalls: delta(self.total_syscalls, earlier.total_syscalls),
144            raw: Raw::None,
145        }
146    }
147
148    /// Share of syscall-layer read bytes served without block-device reads.
149    #[must_use]
150    pub fn cache_hit_ratio(&self) -> Option<f64> {
151        let through = self.bytes_through_read?;
152        if through == 0 {
153            return None;
154        }
155        let device = self.bytes_from_device?;
156        Some(super::ratio(through.saturating_sub(device), through))
157    }
158
159    /// Render only the metrics supported by this snapshot's source.
160    #[must_use]
161    pub fn render(&self) -> String {
162        let Some(source) = self.source else {
163            return "process counters: not available on this platform\n".to_string();
164        };
165        let group = source.group();
166        let mut rows = Vec::new();
167        push_metric(&mut rows, group, "read syscalls", self.read_syscalls);
168        push_metric(&mut rows, group, "write syscalls", self.write_syscalls);
169        push_metric(&mut rows, group, "bytes through read", self.bytes_through_read);
170        push_metric(&mut rows, group, "bytes through write", self.bytes_through_write);
171        push_metric(&mut rows, group, "bytes from device", self.bytes_from_device);
172        push_metric(&mut rows, group, "minor page faults", self.minor_faults);
173        push_metric(&mut rows, group, "major page faults", self.major_faults);
174        push_metric(&mut rows, group, "page faults", self.page_faults);
175        push_metric(&mut rows, group, "page-ins", self.pageins);
176        push_metric(&mut rows, group, "syscalls (total)", self.total_syscalls);
177
178        if rows.is_empty() {
179            return format!("process counters: {} supplied no supported metrics\n", source.group());
180        }
181        let mut output = super::render_rows(&rows);
182        if let Some(ratio) = self.cache_hit_ratio() {
183            let _ = writeln!(output, "  {:<24}  {:>13.1}%", "page cache served", ratio * 100.0);
184        }
185        output
186    }
187}
188
189fn delta(later: Option<u64>, earlier: Option<u64>) -> Option<u64> {
190    Some(later?.saturating_sub(earlier?))
191}
192
193fn push_metric(
194    rows: &mut Vec<(&'static str, &'static str, u64)>,
195    group: &'static str,
196    label: &'static str,
197    value: Option<u64>,
198) {
199    if let Some(value) = value {
200        rows.push((group, label, value));
201    }
202}
203
204#[cfg(target_os = "linux")]
205fn linux_snapshot() -> Snapshot {
206    let mut snapshot = Snapshot::default();
207    if let Ok(io) = std::fs::read_to_string("/proc/self/io") {
208        for line in io.lines() {
209            let Some((key, value)) = line.split_once(':') else { continue };
210            let Ok(value) = value.trim().parse::<u64>() else { continue };
211            match key {
212                "syscr" => snapshot.read_syscalls = Some(value),
213                "syscw" => snapshot.write_syscalls = Some(value),
214                "rchar" => snapshot.bytes_through_read = Some(value),
215                "wchar" => snapshot.bytes_through_write = Some(value),
216                "read_bytes" => snapshot.bytes_from_device = Some(value),
217                _ => {}
218            }
219        }
220    }
221    if let Ok(stat) = std::fs::read_to_string("/proc/self/stat") {
222        if let Some(rest) = stat.rsplit_once(')').map(|(_, rest)| rest) {
223            let fields: Vec<&str> = rest.split_whitespace().collect();
224            snapshot.minor_faults = fields.get(7).and_then(|value| value.parse().ok());
225            snapshot.major_faults = fields.get(9).and_then(|value| value.parse().ok());
226        }
227    }
228    if snapshot.read_syscalls.is_some()
229        || snapshot.write_syscalls.is_some()
230        || snapshot.minor_faults.is_some()
231        || snapshot.major_faults.is_some()
232    {
233        snapshot.source = Some(Source::LinuxProc);
234    }
235    snapshot
236}
237
238#[cfg(target_os = "macos")]
239fn macos_snapshot() -> Snapshot {
240    let mut info = libc::proc_taskinfo {
241        pti_virtual_size: 0,
242        pti_resident_size: 0,
243        pti_total_user: 0,
244        pti_total_system: 0,
245        pti_threads_user: 0,
246        pti_threads_system: 0,
247        pti_policy: 0,
248        pti_faults: 0,
249        pti_pageins: 0,
250        pti_cow_faults: 0,
251        pti_messages_sent: 0,
252        pti_messages_received: 0,
253        pti_syscalls_mach: 0,
254        pti_syscalls_unix: 0,
255        pti_csw: 0,
256        pti_threadnum: 0,
257        pti_numrunning: 0,
258        pti_priority: 0,
259    };
260    let Ok(size) = i32::try_from(size_of::<libc::proc_taskinfo>()) else {
261        return Snapshot::default();
262    };
263    // SAFETY: `info` is initialized and paired with its exact size. The result must equal
264    // that size before any field is used; querying the current process needs no privilege.
265    let filled = unsafe {
266        libc::proc_pidinfo(
267            i32::try_from(std::process::id()).unwrap_or(-1),
268            libc::PROC_PIDTASKINFO,
269            0,
270            (&raw mut info).cast(),
271            size,
272        )
273    };
274    if filled != size {
275        return Snapshot::default();
276    }
277
278    let raw = MacRaw {
279        unix_syscalls: signed_counter_bits(info.pti_syscalls_unix),
280        mach_syscalls: signed_counter_bits(info.pti_syscalls_mach),
281        faults: signed_counter_bits(info.pti_faults),
282        pageins: signed_counter_bits(info.pti_pageins),
283    };
284    Snapshot {
285        source: Some(Source::MacOsProcTaskInfo),
286        raw: Raw::MacOs(raw),
287        total_syscalls: Some(u64::from(raw.unix_syscalls) + u64::from(raw.mach_syscalls)),
288        page_faults: Some(u64::from(raw.faults)),
289        pageins: Some(u64::from(raw.pageins)),
290        ..Snapshot::default()
291    }
292}
293
294#[cfg(target_os = "macos")]
295fn signed_counter_bits(value: i32) -> u32 {
296    u32::from_ne_bytes(value.to_ne_bytes())
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn a_snapshot_is_readable_and_differences_are_sane() {
305        let before = Snapshot::now();
306        if !before.is_available() {
307            assert!(before.render().contains("not available"));
308            return;
309        }
310
311        let scratch = tempfile::NamedTempFile::new().expect("scratch file");
312        std::fs::write(scratch.path(), vec![b'x'; 4096]).expect("write scratch file");
313        let mut total = 0_usize;
314        for _ in 0..64 {
315            total += std::fs::read(scratch.path()).map_or(0, |bytes| bytes.len());
316        }
317        assert!(total > 0);
318
319        #[cfg(any(target_os = "linux", target_os = "macos"))]
320        let difference = Snapshot::now().since(&before);
321        #[cfg(target_os = "linux")]
322        {
323            assert!(difference.read_syscalls.is_some_and(|value| value > 0));
324            assert!(difference.bytes_through_read.is_some_and(|value| value > 0));
325            assert_eq!(difference.total_syscalls, None);
326        }
327        #[cfg(target_os = "macos")]
328        {
329            assert!(difference.total_syscalls.is_some_and(|value| value > 0));
330            assert_eq!(difference.read_syscalls, None);
331            assert_eq!(difference.bytes_through_read, None);
332        }
333    }
334
335    #[test]
336    fn render_omits_metrics_the_source_does_not_supply() {
337        let mac = Snapshot {
338            source: Some(Source::MacOsProcTaskInfo),
339            total_syscalls: Some(12),
340            page_faults: Some(3),
341            pageins: Some(1),
342            ..Snapshot::default()
343        };
344
345        let rendered = mac.render();
346        assert!(rendered.contains("syscalls (total)"));
347        assert!(rendered.contains("page-ins"));
348        assert!(!rendered.contains("read syscalls"));
349        assert!(!rendered.contains("bytes from device"));
350    }
351
352    #[test]
353    fn macos_source_width_wraps_without_becoming_zero() {
354        let before_raw = MacRaw {
355            unix_syscalls: 0x7fff_fffe,
356            mach_syscalls: 0xffff_fffe,
357            faults: 0x7fff_ffff,
358            pageins: 0xffff_ffff,
359        };
360        let later_raw = MacRaw {
361            unix_syscalls: 0x8000_0001,
362            mach_syscalls: 1,
363            faults: 0x8000_0001,
364            pageins: 2,
365        };
366        let snapshot = |raw| Snapshot {
367            source: Some(Source::MacOsProcTaskInfo),
368            raw: Raw::MacOs(raw),
369            ..Snapshot::default()
370        };
371
372        let difference = snapshot(later_raw).since(&snapshot(before_raw));
373        assert_eq!(difference.total_syscalls, Some(6));
374        assert_eq!(difference.page_faults, Some(2));
375        assert_eq!(difference.pageins, Some(3));
376    }
377
378    #[test]
379    fn linux_differences_saturate_instead_of_wrapping() {
380        let later = Snapshot {
381            source: Some(Source::LinuxProc),
382            read_syscalls: Some(5),
383            ..Snapshot::default()
384        };
385        let earlier = Snapshot {
386            source: Some(Source::LinuxProc),
387            read_syscalls: Some(9),
388            ..Snapshot::default()
389        };
390        assert_eq!(later.since(&earlier).read_syscalls, Some(0));
391    }
392}