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(target_os = "linux")]
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(target_vendor = "apple")]
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, on a system with no way to ask that is worth linking.
209#[cfg(not(any(target_os = "linux", target_vendor = "apple")))]
210fn host_memory() -> Option<u64> {
211    None
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    /// A cgroup tree under a temporary directory, so the walk can be tested on
219    /// a machine that has never heard of cgroups.
220    struct Tree(PathBuf);
221
222    impl Tree {
223        fn new(name: &str) -> Tree {
224            let dir = std::env::temp_dir().join(format!("yo-cap-{name}-{}", std::process::id()));
225            let _ = std::fs::remove_dir_all(&dir);
226            std::fs::create_dir_all(&dir).expect("could not make a temporary directory");
227            Tree(dir)
228        }
229
230        fn write(&self, rel: &str, text: &str) -> PathBuf {
231            let at = self.0.join(rel);
232            std::fs::create_dir_all(at.parent().expect("a file has a parent"))
233                .expect("could not make a directory");
234            std::fs::write(&at, text).expect("could not write");
235            at
236        }
237
238        fn root(&self) -> PathBuf {
239            self.0.join("cgroup")
240        }
241    }
242
243    impl Drop for Tree {
244        fn drop(&mut self) {
245            let _ = std::fs::remove_dir_all(&self.0);
246        }
247    }
248
249    #[test]
250    fn max_means_there_is_no_limit() {
251        assert_eq!(parse_limit("max\n"), None);
252        assert_eq!(parse_limit("  max  "), None);
253    }
254
255    #[test]
256    fn a_number_near_the_top_is_cgroup_v1_saying_no_limit() {
257        assert_eq!(parse_limit("9223372036854771712"), None);
258        assert_eq!(parse_limit(&u64::MAX.to_string()), None);
259    }
260
261    #[test]
262    fn a_real_number_is_bytes() {
263        assert_eq!(parse_limit("2147483648\n"), Some(2 * 1024 * 1024 * 1024));
264    }
265
266    #[test]
267    fn nonsense_is_no_limit_rather_than_a_panic() {
268        assert_eq!(parse_limit(""), None);
269        assert_eq!(parse_limit("-1"), None);
270        assert_eq!(parse_limit("2gb"), None);
271    }
272
273    #[test]
274    fn the_v2_line_is_the_one_with_no_controllers() {
275        let text = "12:pids:/user.slice\n1:name=systemd:/user.slice\n0::/user.slice/app.scope\n";
276        assert_eq!(parse_self_cgroup(text), Some("/user.slice/app.scope"));
277    }
278
279    #[test]
280    fn inside_a_cgroup_namespace_the_path_is_just_the_root() {
281        assert_eq!(parse_self_cgroup("0::/\n"), Some("/"));
282    }
283
284    #[test]
285    fn a_v1_only_machine_has_no_v2_line() {
286        assert_eq!(parse_self_cgroup("6:memory:/\n3:cpu:/\n"), None);
287    }
288
289    #[test]
290    fn the_limit_is_read_from_the_leaf() {
291        let t = Tree::new("leaf");
292        let me = t.write("proc", "0::/a/b\n");
293        t.write("cgroup/a/b/memory.max", "1073741824\n");
294        assert_eq!(cgroup_limit(&t.root(), &me), Some(1024 * 1024 * 1024));
295    }
296
297    #[test]
298    fn an_ancestor_with_a_tighter_limit_wins() {
299        // This is the case worth having a test for. A pod gets a generous
300        // limit and the namespace it lives in gets a mean one, and the process
301        // is held to the mean one even though nothing in its own directory
302        // says so.
303        let t = Tree::new("ancestor");
304        let me = t.write("proc", "0::/pods/one\n");
305        t.write("cgroup/memory.max", "max\n");
306        t.write("cgroup/pods/memory.max", "536870912\n");
307        t.write("cgroup/pods/one/memory.max", "4294967296\n");
308        assert_eq!(cgroup_limit(&t.root(), &me), Some(512 * 1024 * 1024));
309    }
310
311    #[test]
312    fn a_tree_that_says_max_all_the_way_up_has_no_limit() {
313        let t = Tree::new("nolimit");
314        let me = t.write("proc", "0::/a\n");
315        t.write("cgroup/memory.max", "max\n");
316        t.write("cgroup/a/memory.max", "max\n");
317        assert_eq!(cgroup_limit(&t.root(), &me), None);
318    }
319
320    #[test]
321    fn cgroup_v1_is_read_when_v2_has_nothing_to_say() {
322        let t = Tree::new("v1");
323        let me = t.write("proc", "6:memory:/\n");
324        t.write("cgroup/memory/memory.limit_in_bytes", "268435456\n");
325        assert_eq!(cgroup_limit(&t.root(), &me), Some(256 * 1024 * 1024));
326    }
327
328    #[test]
329    fn a_machine_with_no_cgroups_at_all_reports_none() {
330        let t = Tree::new("nothing");
331        assert_eq!(
332            cgroup_limit(&t.root(), &t.0.join("not-here")),
333            None,
334            "a missing file is a machine without cgroups, not an error"
335        );
336    }
337
338    #[test]
339    fn the_tighter_of_the_two_is_the_one_that_counts() {
340        let big = 64 * 1024 * 1024 * 1024;
341        let small = 2 * 1024 * 1024 * 1024;
342        assert_eq!(
343            Cap {
344                cgroup: Some(small),
345                host: Some(big)
346            }
347            .limit(),
348            Some(small)
349        );
350        // A container told it may have more than the machine holds has not
351        // been given more than the machine holds.
352        assert_eq!(
353            Cap {
354                cgroup: Some(big),
355                host: Some(small)
356            }
357            .limit(),
358            Some(small)
359        );
360    }
361
362    #[test]
363    fn the_budget_is_a_quarter_and_zero_when_there_is_nothing_to_take_a_quarter_of() {
364        let cap = Cap {
365            cgroup: Some(4 * 1024 * 1024 * 1024),
366            host: None,
367        };
368        assert_eq!(cap.budget(), 1024 * 1024 * 1024);
369        assert_eq!(Cap::default().budget(), 0);
370    }
371
372    #[test]
373    fn asking_the_real_machine_answers_something_sensible() {
374        let cap = Cap::read();
375        // Not an assertion about this machine's size, only that a number that
376        // came back is a number a machine could have.
377        if let Some(h) = cap.host {
378            assert!(h >= 64 * 1024 * 1024, "a host with {h} bytes is not real");
379        }
380        if let Some(l) = cap.limit() {
381            assert_eq!(cap.budget(), l / 4);
382        }
383    }
384}