Skip to main content

aft/
process_io.rs

1//! Process-wide I/O counters and snapshot payloads.
2//!
3//! Exposes cumulative disk and logical I/O counters measured from kernel
4//! facilities (`proc_pid_rusage` on Darwin, `/proc/self/io` on Linux).
5
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
7pub struct Bytes {
8    pub written: u64,
9    pub logical: u64,
10    pub read: u64,
11}
12
13impl Bytes {
14    pub fn capture() -> Option<Self> {
15        #[cfg(target_os = "macos")]
16        {
17            let mut usage = std::mem::MaybeUninit::<libc::rusage_info_v4>::zeroed();
18            // A successful kernel call initializes the versioned buffer.
19            let rc = unsafe {
20                libc::proc_pid_rusage(
21                    libc::getpid(),
22                    libc::RUSAGE_INFO_V4,
23                    usage.as_mut_ptr().cast(),
24                )
25            };
26            if rc != 0 {
27                return None;
28            }
29            let usage = unsafe { usage.assume_init() };
30            Some(Self {
31                written: usage.ri_diskio_byteswritten,
32                logical: usage.ri_logical_writes,
33                read: usage.ri_diskio_bytesread,
34            })
35        }
36        #[cfg(target_os = "linux")]
37        {
38            read_proc_self_io()
39        }
40        #[cfg(target_os = "windows")]
41        {
42            // On Windows, process I/O counters can be queried via `GetProcessIoCounters`
43            // (IO_COUNTERS: ReadOperationCount, WriteOperationCount, ReadTransferCount, WriteTransferCount).
44            // Until wired, Windows returns None (`available: false`).
45            None
46        }
47        #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
48        {
49            None
50        }
51    }
52
53    pub fn delta(self, before: Self) -> Option<Self> {
54        Some(Self {
55            written: self.written.checked_sub(before.written)?,
56            logical: self.logical.checked_sub(before.logical)?,
57            read: self.read.checked_sub(before.read)?,
58        })
59    }
60
61    pub fn add(self, other: Self) -> Self {
62        Self {
63            written: self.written + other.written,
64            logical: self.logical + other.logical,
65            read: self.read + other.read,
66        }
67    }
68
69    pub fn fields(value: Option<Self>, prefix: &str) -> String {
70        match value {
71            Some(v) => format!(
72                "{prefix}_physical_bytes_written={} {prefix}_logical_bytes_written={} {prefix}_bytes_read={}",
73                v.written, v.logical, v.read
74            ),
75            None => format!(
76                "{prefix}_physical_bytes_written=unknown {prefix}_logical_bytes_written=unknown {prefix}_bytes_read=unknown"
77            ),
78        }
79    }
80}
81
82#[cfg(target_os = "linux")]
83fn read_proc_self_io() -> Option<Bytes> {
84    let content = std::fs::read_to_string("/proc/self/io").ok()?;
85    parse_proc_self_io(&content)
86}
87
88/// Parse `/proc/[pid]/io` format into process I/O counters:
89/// - `read_bytes` -> diskio_bytes_read (`read`)
90/// - `write_bytes` -> diskio_bytes_written (`written`)
91/// - `wchar` -> logical_bytes_written (`logical`)
92pub fn parse_proc_self_io(content: &str) -> Option<Bytes> {
93    let mut read_bytes = None;
94    let mut write_bytes = None;
95    let mut wchar = None;
96
97    for line in content.lines() {
98        if let Some((key, value)) = line.split_once(':') {
99            let key = key.trim();
100            let value = value.trim().parse::<u64>().ok();
101            match key {
102                "read_bytes" => read_bytes = value,
103                "write_bytes" => write_bytes = value,
104                "wchar" => wchar = value,
105                _ => {}
106            }
107        }
108    }
109
110    Some(Bytes {
111        read: read_bytes?,
112        written: write_bytes?,
113        logical: wchar?,
114    })
115}
116
117#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
118pub struct ProcessIoSnapshot {
119    pub available: bool,
120    pub sampled_at_ms: u64,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub diskio_bytes_read: Option<u64>,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub diskio_bytes_written: Option<u64>,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub logical_bytes_written: Option<u64>,
127}
128
129impl ProcessIoSnapshot {
130    pub fn from_sample(sample: Option<Bytes>, sampled_at_ms: u64) -> Self {
131        match sample {
132            Some(bytes) => Self {
133                available: true,
134                sampled_at_ms,
135                diskio_bytes_read: Some(bytes.read),
136                diskio_bytes_written: Some(bytes.written),
137                logical_bytes_written: Some(bytes.logical),
138            },
139            None => Self {
140                available: false,
141                sampled_at_ms,
142                diskio_bytes_read: None,
143                diskio_bytes_written: None,
144                logical_bytes_written: None,
145            },
146        }
147    }
148
149    pub fn capture() -> Self {
150        let sampled_at_ms = std::time::SystemTime::now()
151            .duration_since(std::time::UNIX_EPOCH)
152            .map(|d| d.as_millis().min(u128::from(u64::MAX)) as u64)
153            .unwrap_or(0);
154        Self::from_sample(Bytes::capture(), sampled_at_ms)
155    }
156
157    pub fn to_value(&self) -> serde_json::Value {
158        serde_json::to_value(self).expect("process_io serializes")
159    }
160}
161
162#[cfg(test)]
163#[cfg(target_os = "macos")]
164pub fn assert_darwin_logical_bytes_observe_file_write() {
165    use std::io::Write;
166    let dir = tempfile::tempdir().unwrap();
167    let before = Bytes::capture().unwrap();
168    let mut file = std::fs::File::create(dir.path().join("bytes")).unwrap();
169    file.write_all(&vec![0x5a; 8 * 1024 * 1024]).unwrap();
170    file.sync_all().unwrap();
171    let delta = Bytes::capture().unwrap().delta(before).unwrap();
172    assert!(
173        delta.logical >= 8 * 1024 * 1024,
174        "observed logical delta: {delta:?}"
175    );
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use serde_json::Value;
182
183    #[test]
184    fn process_io_snapshot_with_sample_includes_all_fields() {
185        let sample = Some(Bytes {
186            read: 1024,
187            written: 2048,
188            logical: 4096,
189        });
190        let snapshot = ProcessIoSnapshot::from_sample(sample, 42_000);
191        let value = snapshot.to_value();
192        assert_eq!(value["available"], Value::Bool(true));
193        assert_eq!(value["sampled_at_ms"], Value::from(42_000u64));
194        assert_eq!(value["diskio_bytes_read"], Value::from(1024u64));
195        assert_eq!(value["diskio_bytes_written"], Value::from(2048u64));
196        assert_eq!(value["logical_bytes_written"], Value::from(4096u64));
197    }
198
199    #[test]
200    fn process_io_snapshot_unavailable_omits_numbers() {
201        let snapshot = ProcessIoSnapshot::from_sample(None, 42_000);
202        let value = snapshot.to_value();
203        assert_eq!(value["available"], Value::Bool(false));
204        assert_eq!(value["sampled_at_ms"], Value::from(42_000u64));
205        assert!(
206            value.get("diskio_bytes_read").is_none(),
207            "diskio_bytes_read must be omitted when unavailable: {value}"
208        );
209        assert!(
210            value.get("diskio_bytes_written").is_none(),
211            "diskio_bytes_written must be omitted when unavailable: {value}"
212        );
213        assert!(
214            value.get("logical_bytes_written").is_none(),
215            "logical_bytes_written must be omitted when unavailable: {value}"
216        );
217    }
218
219    #[test]
220    fn linux_proc_self_io_parser_maps_counters() {
221        let sample = "rchar: 123456\n\
222                      wchar: 987654\n\
223                      syscr: 10\n\
224                      syscw: 20\n\
225                      read_bytes: 111111\n\
226                      write_bytes: 222222\n\
227                      cancelled_write_bytes: 0\n";
228        let bytes = parse_proc_self_io(sample).expect("parse proc self io");
229        assert_eq!(bytes.read, 111111);
230        assert_eq!(bytes.written, 222222);
231        assert_eq!(bytes.logical, 987654);
232    }
233
234    #[test]
235    fn linux_proc_self_io_parser_incomplete_returns_none() {
236        let sample = "rchar: 123456\n\
237                      wchar: 987654\n\
238                      syscr: 10\n\
239                      syscw: 20\n\
240                      read_bytes: 111111\n";
241        assert!(parse_proc_self_io(sample).is_none());
242    }
243
244    #[cfg(target_os = "macos")]
245    #[test]
246    fn darwin_logical_bytes_observe_file_write() {
247        assert_darwin_logical_bytes_observe_file_write();
248    }
249}