process_memory/
linux.rs

1use std::{
2    fs::{self, File},
3    io::{self, Read},
4    mem::MaybeUninit,
5};
6
7const SMAPS_ROLLUP_PATH: &str = "/proc/self/smaps_rollup";
8const SMAPS_PATH: &str = "/proc/self/smaps";
9const STATM: &str = "/proc/self/statm";
10const RSS_LINE_PREFIX: &[u8] = b"Rss: ";
11
12enum StatSource {
13    SmapsRollup(Scanner<File>),
14    Smaps(Scanner<File>),
15    Statm(Option<usize>),
16}
17
18/// A memory usage querier.
19pub struct Querier {
20    source: StatSource,
21}
22
23impl Querier {
24    /// Gets the resident set size of this process, in bytes.
25    ///
26    /// If the resident set size can't be determined, `None` is returned. This could be for a number of underlying
27    /// reasons, but should generally be considered an incredibly rare/unlikely event.
28    pub fn resident_set_size(&mut self) -> Option<usize> {
29        match &mut self.source {
30            StatSource::SmapsRollup(scanner) => {
31                // As smaps_rollup is a pre-aggregated version of smaps, there's only one "Rss:" line that we need to find, so use
32                // the same scanner-based approach as we do for smaps, but just take the first matching line we find.
33                scanner.reset_with_path(SMAPS_ROLLUP_PATH).ok()?;
34                first_rss_value(scanner)
35            }
36            StatSource::Smaps(scanner) => {
37                // Scan all lines in smaps, looking for lines that start with "Rss:". Each of these lines will contain the resident
38                // set size of a particular memory mapping. We simply need to find all of these lines and aggregate their value to
39                // get the RSS for the process.
40                scanner.reset_with_path(SMAPS_PATH).ok()?;
41                sum_rss_values(scanner)
42            }
43            StatSource::Statm(maybe_page_size) => {
44                let page_size = maybe_page_size.as_ref().copied()?;
45
46                // Unlike smaps/smaps_rollup, statm is a drastically simpler format that is written as a single line with
47                // space-delimited fields. Since we have no lines to scan, it's much simpler to just read the entire file into a
48                // stack-allocated buffer.
49                //
50                // With seven integer fields, we can napkin math this to wanting to hold 20 bytes per field, plus the separators and
51                // newline, which is 153... but power-of-two numbers somehow feel better, so we'll go to 256.
52                let mut buf = [0; 256];
53                let mut file = File::open(STATM).ok()?;
54                let n = file.read(&mut buf).ok()?;
55                if n == 0 || n == buf.len() {
56                    // If we read no bytes, or filled the entire buffer, something is very wrong.
57                    return None;
58                }
59
60                parse_statm_rss_bytes(&buf, page_size)
61            }
62        }
63    }
64}
65
66/// Returns the first `Rss:` value (converted to bytes) found by the scanner, or `None` if there is none.
67///
68/// This is the `smaps_rollup` strategy: the file is pre-aggregated, so the first matching line is the total RSS.
69fn first_rss_value<T: Read>(scanner: &mut Scanner<T>) -> Option<usize> {
70    while let Ok(Some(raw_rss_line)) = scanner.next_matching_line(RSS_LINE_PREFIX) {
71        let raw_rss_value = skip_to_line_value(raw_rss_line)?;
72        if let Some(rss_bytes) = parse_kb_value_as_bytes(raw_rss_value) {
73            return Some(rss_bytes);
74        }
75    }
76
77    None
78}
79
80/// Returns the sum of every `Rss:` value (converted to bytes) found by the scanner, or `None` if the total is zero.
81///
82/// This is the `smaps` strategy: each memory mapping contributes its own `Rss:` line, and the process RSS is their sum.
83fn sum_rss_values<T: Read>(scanner: &mut Scanner<T>) -> Option<usize> {
84    let mut total_rss_bytes = 0;
85    while let Ok(Some(raw_rss_line)) = scanner.next_matching_line(RSS_LINE_PREFIX) {
86        let raw_rss_value = skip_to_line_value(raw_rss_line)?;
87        if let Some(rss_bytes) = parse_kb_value_as_bytes(raw_rss_value) {
88            total_rss_bytes += rss_bytes;
89        }
90    }
91
92    if total_rss_bytes > 0 {
93        Some(total_rss_bytes)
94    } else {
95        None
96    }
97}
98
99/// Parses the resident set size (in bytes) out of the contents of a `statm` file.
100///
101/// The resident set size is the second whitespace-delimited field, expressed in pages, so it is multiplied by the page
102/// size to get a byte count. `None` is returned if the field is missing or non-numeric.
103fn parse_statm_rss_bytes(raw: &[u8], page_size: usize) -> Option<usize> {
104    // Resident set size is the second field, so we need to skip to it.
105    let raw_rss_field = raw.split(|b| *b == b' ').nth(1)?;
106
107    // We need to parse the field as an integer, and then multiply it by the page size to get the value in bytes.
108    let rss_pages = simdutf8::basic::from_utf8(raw_rss_field).ok()?.parse::<usize>().ok()?;
109    Some(rss_pages * page_size)
110}
111
112impl Default for Querier {
113    fn default() -> Self {
114        Self {
115            source: determine_stat_source(),
116        }
117    }
118}
119
120fn determine_stat_source() -> StatSource {
121    select_stat_source(
122        fs::metadata(SMAPS_ROLLUP_PATH).is_ok(),
123        fs::metadata(SMAPS_PATH).is_ok(),
124        page_size(),
125    )
126}
127
128/// Selects the RSS data source based on which procfs files are available.
129///
130/// The preference order (documented at the crate level) is `smaps_rollup`, then `smaps`, then `statm`. Splitting the
131/// availability checks from the selection logic keeps the fallback order testable without touching the real filesystem.
132fn select_stat_source(has_smaps_rollup: bool, has_smaps: bool, page_size: Option<usize>) -> StatSource {
133    if has_smaps_rollup {
134        StatSource::SmapsRollup(Scanner::new())
135    } else if has_smaps {
136        StatSource::Smaps(Scanner::new())
137    } else {
138        StatSource::Statm(page_size)
139    }
140}
141
142fn page_size() -> Option<usize> {
143    let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
144    if page_size <= 0 {
145        None
146    } else {
147        Some(page_size as usize)
148    }
149}
150
151fn parse_kb_value_as_bytes(raw_rss_value: &[u8]) -> Option<usize> {
152    // The raw value here will be in the form of `XXXXXX kB`, so we want to find the first whitespace character, and
153    // take everything before that.
154    match raw_rss_value.iter().position(|&b| b == b' ') {
155        Some(space_idx) => {
156            let raw_value = &raw_rss_value[..space_idx];
157            simdutf8::basic::from_utf8(raw_value)
158                .ok()?
159                .parse::<usize>()
160                .ok()
161                .map(|value| value * 1024)
162        }
163        None => None,
164    }
165}
166
167struct Scanner<T> {
168    io: Option<T>,
169    eof: bool,
170    buf: Vec<u8>,
171    pending_consume: Option<usize>,
172}
173
174impl<T> Scanner<T>
175where
176    T: Read,
177{
178    fn new() -> Self {
179        Self {
180            io: None,
181            eof: false,
182            buf: Vec::with_capacity(8192),
183            pending_consume: None,
184        }
185    }
186
187    fn reset(&mut self, io: T) {
188        self.buf.clear();
189        self.eof = false;
190        self.pending_consume = None;
191        self.io = Some(io);
192    }
193
194    fn get_io_mut(&mut self) -> io::Result<&mut T> {
195        match self.io.as_mut() {
196            Some(io) => Ok(io),
197            None => Err(io::Error::other("no file set in scanner")),
198        }
199    }
200
201    fn fill_buf(&mut self) -> io::Result<()> {
202        if self.eof {
203            return Ok(());
204        }
205
206        // If our buffer isn't entirely filled, try and fill the remainder.
207        if self.buf.len() < self.buf.capacity() {
208            // If we have any spare capacity, try to read as many bytes as we can hold in it.
209            //
210            // SAFETY: There's no invalid bit patterns for `u8`.
211            let read_buf = unsafe { &mut *(self.buf.spare_capacity_mut() as *mut [MaybeUninit<u8>] as *mut [u8]) };
212            let n = self.get_io_mut()?.read(read_buf)?;
213            if n == 0 {
214                self.eof = true;
215            }
216
217            // SAFETY: We've just read `n` bytes into `buf`, based on the spare capacity, so incrementing our length by
218            // `n` will only cover initialized bytes, and can't result in a length greater than the buffer capacity.
219            unsafe {
220                self.buf.set_len(self.buf.len() + n);
221            }
222        }
223
224        Ok(())
225    }
226
227    fn next_matching_line(&mut self, prefix: &[u8]) -> io::Result<Option<&[u8]>> {
228        loop {
229            // We've reached EOF and have processed the entire file.
230            if self.eof && self.buf.is_empty() {
231                return Ok(None);
232            }
233
234            // If we have a pending consume, take that many bytes from the front of the buffer and shift the rest of the
235            // data forward.
236            if let Some(consume) = self.pending_consume {
237                self.buf.drain(..consume);
238                self.pending_consume = None;
239            }
240
241            // Ensure our buffer is as filled as it can be.
242            self.fill_buf()?;
243
244            // Get the entire buffer that we currently have, and see if it starts with our prefix.
245            //
246            // If it does, we then need to also find a newline character to know where to chop it off.
247            if self.buf.starts_with(prefix) {
248                let maybe_newline_idx = self.buf.iter().position(|&b| b == b'\n');
249                if let Some(newline_idx) = maybe_newline_idx {
250                    // Consume up to and including the newline character, but only hand back the bytes up to the newline.
251                    self.pending_consume = Some(newline_idx + 1);
252
253                    return Ok(Some(&self.buf[..newline_idx]));
254                }
255            } else {
256                // Our buffer doesn't start with the prefix, so we need to find the next newline character and consume
257                // up to that point to reset ourselves.
258                //
259                // We're essentially resetting ourselves to start at the next line in the file.
260                let maybe_newline_idx = self.buf.iter().position(|&b| b == b'\n');
261                if let Some(newline_idx) = maybe_newline_idx {
262                    self.pending_consume = Some(newline_idx + 1);
263                } else {
264                    // We couldn't find a newline character, so we need to clear the entire buffer and just keep reading.
265                    self.buf.clear();
266                }
267            }
268        }
269    }
270}
271
272impl Scanner<File> {
273    fn reset_with_path(&mut self, path: &str) -> io::Result<()> {
274        let file = File::open(path)?;
275        self.reset(file);
276
277        Ok(())
278    }
279}
280
281fn skip_to_line_value(raw_line: &[u8]) -> Option<&[u8]> {
282    // We skip over all non-numeric characters and then return what's left.
283    //
284    // If the line doesn't contain any numeric characters, `None` is returned.
285    raw_line
286        .iter()
287        .position(|b| b.is_ascii_digit())
288        .map(|idx| &raw_line[idx..])
289}
290
291#[cfg(test)]
292mod tests {
293    use super::Querier;
294
295    #[test]
296    fn basic() {
297        let mut querier = Querier::default();
298        assert!(querier.resident_set_size().is_some());
299    }
300
301    #[test]
302    fn skip_to_line_value() {
303        let passing_lines = [
304            "1234 kB".as_bytes(),
305            "    1234 kB".as_bytes(),
306            "\t1234 kB".as_bytes(),
307            "Rss:1234 kB".as_bytes(),
308            "Rss:   1234 kB".as_bytes(),
309        ];
310        for line in &passing_lines {
311            assert_eq!(super::skip_to_line_value(line), Some("1234 kB".as_bytes()));
312        }
313
314        let failing_lines = [
315            "Rss: ".as_bytes(),
316            "Rss: \n".as_bytes(),
317            "Rss:  kB".as_bytes(),
318            "Rss: kB\n".as_bytes(),
319        ];
320        for line in &failing_lines {
321            assert_eq!(super::skip_to_line_value(line), None);
322        }
323    }
324
325    #[test]
326    fn scanner_basic() {
327        let prefix = "Rss: ".as_bytes();
328
329        let mut scanner = super::Scanner::new();
330        let mut buf = Vec::new();
331        buf.extend_from_slice(b"Rss: 1234 kB\nRss: 5678 kB\nRss: 91011 kB\n");
332        scanner.reset(buf.as_slice());
333
334        assert_eq!(
335            scanner.next_matching_line(prefix).unwrap(),
336            Some(b"Rss: 1234 kB".as_ref())
337        );
338        assert_eq!(
339            scanner.next_matching_line(prefix).unwrap(),
340            Some(b"Rss: 5678 kB".as_ref())
341        );
342        assert_eq!(
343            scanner.next_matching_line(prefix).unwrap(),
344            Some(b"Rss: 91011 kB".as_ref())
345        );
346        assert_eq!(scanner.next_matching_line(prefix).unwrap(), None);
347    }
348
349    #[test]
350    fn scanner_skip_non_matching_lines() {
351        let prefix = "Rss: ".as_bytes();
352
353        let mut scanner = super::Scanner::new();
354        let mut buf = Vec::new();
355        buf.extend_from_slice(b"Rss: 1234 kB\nPss: 5678 kB\nHugepages:    42069 kB\nRss: 91011 kB\n");
356        scanner.reset(buf.as_slice());
357
358        assert_eq!(
359            scanner.next_matching_line(prefix).unwrap(),
360            Some(b"Rss: 1234 kB".as_ref())
361        );
362        assert_eq!(
363            scanner.next_matching_line(prefix).unwrap(),
364            Some(b"Rss: 91011 kB".as_ref())
365        );
366        assert_eq!(scanner.next_matching_line(prefix).unwrap(), None);
367    }
368
369    #[test]
370    fn scanner_skips_lines_larger_than_buffer() {
371        let prefix = "Rss: ".as_bytes();
372
373        let mut scanner = super::Scanner::new();
374
375        // We construct a non-matching line that's longer than our internal buffer (8192 bytes) to ensure that we can
376        // still skip over it and find the next matching line without losing data.
377        let mut buf = Vec::new();
378        buf.resize(9000, b'@');
379        buf.extend_from_slice(b"\nRss: 1234 kB\n");
380        scanner.reset(buf.as_slice());
381
382        assert_eq!(
383            scanner.next_matching_line(prefix).unwrap(),
384            Some(b"Rss: 1234 kB".as_ref())
385        );
386        assert_eq!(scanner.next_matching_line(prefix).unwrap(), None);
387    }
388
389    #[test]
390    fn parse_kb_value_as_bytes_converts_kilobytes_to_bytes() {
391        // The procfs values are in kibibytes, so they are multiplied by 1024 to get bytes.
392        assert_eq!(super::parse_kb_value_as_bytes(b"1234 kB"), Some(1234 * 1024));
393        assert_eq!(super::parse_kb_value_as_bytes(b"0 kB"), Some(0));
394
395        // Without a trailing space there is no delimiter, so no value can be extracted.
396        assert_eq!(super::parse_kb_value_as_bytes(b"1234"), None);
397
398        // A non-numeric value fails to parse.
399        assert_eq!(super::parse_kb_value_as_bytes(b"abc kB"), None);
400    }
401
402    #[test]
403    fn smaps_rollup_uses_the_first_rss_line() {
404        // smaps_rollup is pre-aggregated: the first "Rss:" line is the whole-process RSS, converted from kB to bytes.
405        let mut scanner = super::Scanner::new();
406        scanner.reset(b"Pss: 10 kB\nRss: 512 kB\nRss: 999 kB\n".as_slice());
407        assert_eq!(super::first_rss_value(&mut scanner), Some(512 * 1024));
408    }
409
410    #[test]
411    fn smaps_sums_every_rss_line() {
412        // smaps has one "Rss:" line per mapping, so the process RSS is the sum of them all (in bytes).
413        let mut scanner = super::Scanner::new();
414        scanner.reset(b"Rss: 4 kB\nPss: 100 kB\nRss: 8 kB\nRss: 16 kB\n".as_slice());
415        assert_eq!(super::sum_rss_values(&mut scanner), Some((4 + 8 + 16) * 1024));
416    }
417
418    #[test]
419    fn smaps_strategies_return_none_when_no_rss_lines_present() {
420        // The documented `None`-return path: nothing matched, so neither strategy can report a value.
421        let mut scanner = super::Scanner::new();
422        scanner.reset(b"Pss: 4 kB\nShared_Clean: 8 kB\n".as_slice());
423        assert_eq!(super::first_rss_value(&mut scanner), None);
424
425        let mut scanner = super::Scanner::new();
426        scanner.reset(b"Pss: 4 kB\nShared_Clean: 8 kB\n".as_slice());
427        assert_eq!(super::sum_rss_values(&mut scanner), None);
428    }
429
430    #[test]
431    fn parse_statm_rss_bytes_multiplies_resident_pages_by_page_size() {
432        // statm fields are "size resident shared text lib data dt"; resident (the second field) is in pages.
433        assert_eq!(
434            super::parse_statm_rss_bytes(b"1000 42 5 1 0 3 0", 4096),
435            Some(42 * 4096)
436        );
437
438        // A missing resident field, or a non-numeric one, yields `None`.
439        assert_eq!(super::parse_statm_rss_bytes(b"1000", 4096), None);
440        assert_eq!(super::parse_statm_rss_bytes(b"1000 xyz", 4096), None);
441    }
442
443    #[test]
444    fn stat_source_selection_follows_documented_fallback_order() {
445        use super::{select_stat_source, StatSource};
446
447        // smaps_rollup is preferred whenever it is available, regardless of the others.
448        assert!(matches!(
449            select_stat_source(true, true, Some(4096)),
450            StatSource::SmapsRollup(_)
451        ));
452        assert!(matches!(
453            select_stat_source(true, false, None),
454            StatSource::SmapsRollup(_)
455        ));
456
457        // smaps is used only when smaps_rollup is unavailable.
458        assert!(matches!(
459            select_stat_source(false, true, Some(4096)),
460            StatSource::Smaps(_)
461        ));
462
463        // statm is the final fallback, carrying whatever page size was resolved (including `None`).
464        assert!(matches!(
465            select_stat_source(false, false, Some(4096)),
466            StatSource::Statm(Some(4096))
467        ));
468        assert!(matches!(
469            select_stat_source(false, false, None),
470            StatSource::Statm(None)
471        ));
472    }
473}