Skip to main content

yo_resp/
cap.rs

1//! How much memory this process actually gets, and what to do with that number.
2//!
3//! A server that sizes itself from the machine is wrong on every machine where
4//! it is not the only thing running, and in 2026 that is most of them. Inside a
5//! container the kernel will happily tell you the host has 512 GB while the
6//! cgroup this process lives in will kill it at 2. So the question is not what
7//! the machine has, it is what this process is allowed to use, and the answer
8//! is the smaller of the two.
9//!
10//! # The over 4 rule
11//!
12//! [`Cap::budget`] is a quarter of the limit, not the whole of it, and that
13//! quarter is not a hedge. `16-implementation-plan.md` M5 records what happened
14//! in aki when the pools were sized straight from `memory.max`: the same bytes
15//! were charged twice, once to the pool and once to the pages the pool was
16//! sitting on, and a result that should have been 1.58x came out at 5.14x and
17//! 5.77x. Dividing by four fixed it. That is an empirical number rather than a
18//! derived one, which is exactly why both the limit and the budget are reported
19//! rather than only the one that gets used. The next person to be surprised by
20//! this should be able to see both numbers without reading the source.
21//!
22//! # Where the numbers come from
23//!
24//! Under cgroup v2 the limit is in `memory.max`, in the directory named by the
25//! `0::` line of `/proc/self/cgroup` under the cgroup mount. A limit can be set
26//! on any ancestor and the tightest one wins, so this walks up to the mount
27//! point taking the smallest number it finds. Under cgroup v1 the same idea
28//! lives in `memory/memory.limit_in_bytes`, where "no limit" is a very large
29//! number rather than a word. Neither file exists anywhere but Linux, and on a
30//! machine without them there is no limit to find, which is the honest answer
31//! and not an error.
32//!
33//! The parsing and the walk are separate from the paths they normally read, so
34//! the tests build a directory tree and check the walk against it on every
35//! platform rather than only on the one where it matters.
36
37use std::path::{Path, PathBuf};
38
39/// Where cgroup v2 is mounted on any system that has it.
40const CGROUP_ROOT: &str = "/sys/fs/cgroup";
41
42/// Anything at or above this in `memory.limit_in_bytes` means no limit.
43///
44/// cgroup v1 has no word for unlimited, so it writes `PAGE_COUNTER_MAX` scaled
45/// by the page size, which comes out as a number near `i64::MAX` that differs
46/// between kernels and page sizes. Treating anything within a factor of two of
47/// the top as no limit is what every other reader of this file does.
48const V1_UNLIMITED: u64 = u64::MAX / 4;
49
50/// What this process is allowed to use.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub struct Cap {
53    /// The cgroup limit in bytes, or `None` where there is no cgroup or no
54    /// limit set on it.
55    pub cgroup: Option<u64>,
56    /// What the machine has in bytes, or `None` where it could not be asked.
57    pub host: Option<u64>,
58}
59
60/// The reading, taken once and kept.
61///
62/// None of it changes while the process runs, and the files behind it are on a
63/// virtual filesystem that is cheap to read but not free, so everything that
64/// wants the numbers asks here rather than going back to `/sys` for each one.
65#[must_use]
66pub fn cap() -> Cap {
67    static ONCE: std::sync::OnceLock<Cap> = std::sync::OnceLock::new();
68    *ONCE.get_or_init(Cap::read)
69}
70
71impl Cap {
72    /// Ask the running system.
73    #[must_use]
74    pub fn read() -> Cap {
75        Cap {
76            cgroup: cgroup_limit(Path::new(CGROUP_ROOT), Path::new("/proc/self/cgroup")),
77            host: host_memory(),
78        }
79    }
80
81    /// The smaller of the two, which is what this process actually gets.
82    ///
83    /// A cgroup limit larger than the machine is not a limit, so the host
84    /// number wins there, and a machine with no cgroup limit is capped only by
85    /// itself.
86    #[must_use]
87    pub fn limit(&self) -> Option<u64> {
88        match (self.cgroup, self.host) {
89            (Some(c), Some(h)) => Some(c.min(h)),
90            (Some(c), None) => Some(c),
91            (None, h) => h,
92        }
93    }
94
95    /// A quarter of the limit, which is what a pool should be sized from.
96    ///
97    /// Zero when there is no limit to take a quarter of, which is the same
98    /// thing `maxmemory` means by zero and is why it is zero and not `None`.
99    #[must_use]
100    pub fn budget(&self) -> u64 {
101        self.limit().map_or(0, |b| b / 4)
102    }
103}
104
105/// Read a `memory.max` or `memory.limit_in_bytes` file.
106///
107/// `max` is cgroup v2's word for no limit. A number near the top of the range
108/// is cgroup v1's. Anything else is bytes.
109fn parse_limit(text: &str) -> Option<u64> {
110    let text = text.trim();
111    if text == "max" {
112        return None;
113    }
114    let n: u64 = text.parse().ok()?;
115    if n >= V1_UNLIMITED { None } else { Some(n) }
116}
117
118/// Pull the cgroup v2 path out of `/proc/self/cgroup`.
119///
120/// The v2 line is the one with an empty hierarchy id and an empty controller
121/// list, which is written `0::`. What follows is a path relative to the mount
122/// point, and inside a cgroup namespace it is just `/`.
123fn parse_self_cgroup(text: &str) -> Option<&str> {
124    text.lines()
125        .find_map(|line| line.strip_prefix("0::"))
126        .map(str::trim)
127}
128
129/// The tightest limit on this cgroup or any of its ancestors.
130///
131/// Split out from [`Cap::read`] so the tests can point it at a directory tree
132/// they built, which is the only way to check the walk on a machine that has no
133/// cgroups.
134fn cgroup_limit(root: &Path, self_cgroup: &Path) -> Option<u64> {
135    // Only if v2 said nothing at all, because a machine running both has the v2
136    // answer as the real one.
137    v2_limit(root, self_cgroup).or_else(|| {
138        let text = std::fs::read_to_string(root.join("memory/memory.limit_in_bytes")).ok()?;
139        parse_limit(&text)
140    })
141}
142
143/// The v2 half of [`cgroup_limit`]: walk from this process's directory up to
144/// the mount point and keep the smallest limit written down anywhere on the way.
145fn v2_limit(root: &Path, self_cgroup: &Path) -> Option<u64> {
146    let rel = std::fs::read_to_string(self_cgroup).ok()?;
147    let rel = parse_self_cgroup(&rel)?.trim_start_matches('/');
148
149    let mut dir: PathBuf = root.join(rel);
150    let mut best: Option<u64> = None;
151    loop {
152        if let Ok(text) = std::fs::read_to_string(dir.join("memory.max"))
153            && let Some(n) = parse_limit(&text)
154        {
155            best = Some(best.map_or(n, |b: u64| b.min(n)));
156        }
157        if dir == root {
158            break;
159        }
160        // Stop at the mount point rather than walking off the top of it, and
161        // stop anyway if the path was not under the root to begin with.
162        match dir.parent() {
163            Some(p) if p.starts_with(root) || p == root => dir = p.to_path_buf(),
164            _ => break,
165        }
166    }
167    best
168}
169
170/// What the machine has.
171#[cfg(all(target_os = "linux", not(miri)))]
172fn host_memory() -> Option<u64> {
173    // SAFETY: two reads of process independent configuration, neither of which
174    // takes a pointer or leaves anything behind.
175    let (pages, size) = unsafe {
176        (
177            libc::sysconf(libc::_SC_PHYS_PAGES),
178            libc::sysconf(libc::_SC_PAGESIZE),
179        )
180    };
181    if pages > 0 && size > 0 {
182        Some(pages as u64 * size as u64)
183    } else {
184        None
185    }
186}
187
188/// What the machine has.
189#[cfg(all(target_vendor = "apple", not(miri)))]
190fn host_memory() -> Option<u64> {
191    let mut out: u64 = 0;
192    let mut len = size_of::<u64>();
193    // SAFETY: the name is a C string literal, the buffer is one `u64` and `len`
194    // says so, and the two null pointers are the documented way to say there is
195    // no new value to set.
196    let rc = unsafe {
197        libc::sysctlbyname(
198            c"hw.memsize".as_ptr(),
199            (&raw mut out).cast(),
200            &raw mut len,
201            std::ptr::null_mut(),
202            0,
203        )
204    };
205    if rc == 0 && out > 0 { Some(out) } else { None }
206}
207
208/// What the machine has, where there is no machine to ask.
209///
210/// That is either a system with no way to ask that is worth linking, or Miri,
211/// which is an interpreter and not a computer. Both of the calls above are
212/// foreign functions it does not carry out: on Linux it knows a handful of
213/// `sysconf` names and stops the program on the rest, so `_SC_PHYS_PAGES` ends
214/// the run with `unimplemented sysconf name: 85`, and on macOS it refuses
215/// `sysctlbyname` outright. It is not only the one test that reads the size
216/// that runs into this, either. `INFO` reports the budget, so every dispatch
217/// test that asks for `INFO` comes through here as well.
218///
219/// `None` is a reading this code already handles, because a container with no
220/// limit set and no way to read the machine gives the same answer, and
221/// everything that follows from a number is covered by the tests that hand
222/// `Cap` its numbers rather than asking for them.
223#[cfg(any(miri, not(any(target_os = "linux", target_vendor = "apple"))))]
224fn host_memory() -> Option<u64> {
225    None
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    /// A cgroup tree under a temporary directory, so the walk can be tested on
233    /// a machine that has never heard of cgroups.
234    struct Tree(PathBuf);
235
236    impl Tree {
237        fn new(name: &str) -> Tree {
238            let dir = std::env::temp_dir().join(format!("yo-cap-{name}-{}", std::process::id()));
239            let _ = std::fs::remove_dir_all(&dir);
240            std::fs::create_dir_all(&dir).expect("could not make a temporary directory");
241            Tree(dir)
242        }
243
244        fn write(&self, rel: &str, text: &str) -> PathBuf {
245            let at = self.0.join(rel);
246            std::fs::create_dir_all(at.parent().expect("a file has a parent"))
247                .expect("could not make a directory");
248            std::fs::write(&at, text).expect("could not write");
249            at
250        }
251
252        fn root(&self) -> PathBuf {
253            self.0.join("cgroup")
254        }
255    }
256
257    impl Drop for Tree {
258        fn drop(&mut self) {
259            let _ = std::fs::remove_dir_all(&self.0);
260        }
261    }
262
263    #[test]
264    fn max_means_there_is_no_limit() {
265        assert_eq!(parse_limit("max\n"), None);
266        assert_eq!(parse_limit("  max  "), None);
267    }
268
269    #[test]
270    fn a_number_near_the_top_is_cgroup_v1_saying_no_limit() {
271        assert_eq!(parse_limit("9223372036854771712"), None);
272        assert_eq!(parse_limit(&u64::MAX.to_string()), None);
273    }
274
275    #[test]
276    fn a_real_number_is_bytes() {
277        assert_eq!(parse_limit("2147483648\n"), Some(2 * 1024 * 1024 * 1024));
278    }
279
280    #[test]
281    fn nonsense_is_no_limit_rather_than_a_panic() {
282        assert_eq!(parse_limit(""), None);
283        assert_eq!(parse_limit("-1"), None);
284        assert_eq!(parse_limit("2gb"), None);
285    }
286
287    #[test]
288    fn the_v2_line_is_the_one_with_no_controllers() {
289        let text = "12:pids:/user.slice\n1:name=systemd:/user.slice\n0::/user.slice/app.scope\n";
290        assert_eq!(parse_self_cgroup(text), Some("/user.slice/app.scope"));
291    }
292
293    #[test]
294    fn inside_a_cgroup_namespace_the_path_is_just_the_root() {
295        assert_eq!(parse_self_cgroup("0::/\n"), Some("/"));
296    }
297
298    #[test]
299    fn a_v1_only_machine_has_no_v2_line() {
300        assert_eq!(parse_self_cgroup("6:memory:/\n3:cpu:/\n"), None);
301    }
302
303    #[test]
304    fn the_limit_is_read_from_the_leaf() {
305        let t = Tree::new("leaf");
306        let me = t.write("proc", "0::/a/b\n");
307        t.write("cgroup/a/b/memory.max", "1073741824\n");
308        assert_eq!(cgroup_limit(&t.root(), &me), Some(1024 * 1024 * 1024));
309    }
310
311    #[test]
312    fn an_ancestor_with_a_tighter_limit_wins() {
313        // This is the case worth having a test for. A pod gets a generous
314        // limit and the namespace it lives in gets a mean one, and the process
315        // is held to the mean one even though nothing in its own directory
316        // says so.
317        let t = Tree::new("ancestor");
318        let me = t.write("proc", "0::/pods/one\n");
319        t.write("cgroup/memory.max", "max\n");
320        t.write("cgroup/pods/memory.max", "536870912\n");
321        t.write("cgroup/pods/one/memory.max", "4294967296\n");
322        assert_eq!(cgroup_limit(&t.root(), &me), Some(512 * 1024 * 1024));
323    }
324
325    #[test]
326    fn a_tree_that_says_max_all_the_way_up_has_no_limit() {
327        let t = Tree::new("nolimit");
328        let me = t.write("proc", "0::/a\n");
329        t.write("cgroup/memory.max", "max\n");
330        t.write("cgroup/a/memory.max", "max\n");
331        assert_eq!(cgroup_limit(&t.root(), &me), None);
332    }
333
334    #[test]
335    fn cgroup_v1_is_read_when_v2_has_nothing_to_say() {
336        let t = Tree::new("v1");
337        let me = t.write("proc", "6:memory:/\n");
338        t.write("cgroup/memory/memory.limit_in_bytes", "268435456\n");
339        assert_eq!(cgroup_limit(&t.root(), &me), Some(256 * 1024 * 1024));
340    }
341
342    #[test]
343    fn a_machine_with_no_cgroups_at_all_reports_none() {
344        let t = Tree::new("nothing");
345        assert_eq!(
346            cgroup_limit(&t.root(), &t.0.join("not-here")),
347            None,
348            "a missing file is a machine without cgroups, not an error"
349        );
350    }
351
352    #[test]
353    fn the_tighter_of_the_two_is_the_one_that_counts() {
354        let big = 64 * 1024 * 1024 * 1024;
355        let small = 2 * 1024 * 1024 * 1024;
356        assert_eq!(
357            Cap {
358                cgroup: Some(small),
359                host: Some(big)
360            }
361            .limit(),
362            Some(small)
363        );
364        // A container told it may have more than the machine holds has not
365        // been given more than the machine holds.
366        assert_eq!(
367            Cap {
368                cgroup: Some(big),
369                host: Some(small)
370            }
371            .limit(),
372            Some(small)
373        );
374    }
375
376    #[test]
377    fn the_budget_is_a_quarter_and_zero_when_there_is_nothing_to_take_a_quarter_of() {
378        let cap = Cap {
379            cgroup: Some(4 * 1024 * 1024 * 1024),
380            host: None,
381        };
382        assert_eq!(cap.budget(), 1024 * 1024 * 1024);
383        assert_eq!(Cap::default().budget(), 0);
384    }
385
386    /// Under Miri this reads the `host_memory` that answers `None`, and the
387    /// check below is written to take that answer, so the test still runs
388    /// there and still says that whatever came back is usable.
389    #[test]
390    fn asking_the_real_machine_answers_something_sensible() {
391        let cap = Cap::read();
392        // Not an assertion about this machine's size, only that a number that
393        // came back is a number a machine could have.
394        if let Some(h) = cap.host {
395            assert!(h >= 64 * 1024 * 1024, "a host with {h} bytes is not real");
396        }
397        if let Some(l) = cap.limit() {
398            assert_eq!(cap.budget(), l / 4);
399        }
400    }
401}