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}