runtime/backlog/cgroup.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Each Linux run's child cgroup: the weight every run holds, and the
7//! memory limit when the arm asked for one (`crates/runtime/spec/Tools/Exec.lean` D29, D33).
8//!
9//! cgroup v2 is a filesystem, so this module is plain file reads and
10//! writes: the harness moves itself into `core` under its own cgroup —
11//! a cgroup that holds processes may not hand out its own resources —
12//! opens `cpu` and `memory` in the parent's `cgroup.subtree_control`,
13//! and gives each run a child named `run-<RunId>`. The child holds
14//! `cpu.weight` 100, the same for every run, and `memory.max` set to the
15//! requested limit or `max` to clear a limit in a reused directory; each command of the run writes its pid into
16//! the child's `cgroup.procs`.
17//!
18//! The parent is a parameter rather than a constant, so the tests run
19//! a whole fake `/sys/fs/cgroup` under the temp directory on any
20//! platform, and this file says nothing about what a real machine's
21//! cgroup happens to be. [`platform_shares`] is the one place the
22//! doctor and the wiring read whether this machine's cgroup is
23//! delegated, and a machine that has none, or may not write it, sets
24//! nothing and says so instead.
25
26#[cfg(any(not(windows), test))]
27use std::collections::BTreeMap;
28use std::path::{Path, PathBuf};
29#[cfg(any(not(windows), test))]
30use std::sync::OnceLock;
31
32#[cfg(any(not(windows), test))]
33use kernel::RunId;
34
35#[cfg(any(not(windows), test))]
36use super::Shares;
37#[cfg(any(not(windows), test))]
38use super::ceiling::{Mark, Unapplied};
39
40/// The weight every run's child cgroup holds, from 1 to 10000: the same
41/// for every run, so the runs share the processors evenly among
42/// themselves (`crates/runtime/spec/Tools/Exec.lean` D29).
43#[cfg(any(not(windows), test))]
44const RUN_CPU_WEIGHT: u64 = 100;
45
46/// Which halves of a run's shares this machine can set (D29).
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum PlatformShares {
49 /// Nothing can be set here.
50 None,
51 /// The CPU half alone: macOS's `taskpolicy`.
52 Cpu,
53 /// Both halves: a Windows job, or a Linux cgroup this process may
54 /// write.
55 CpuAndMemory,
56}
57
58/// What this machine can give a run of what an arm asks for (D29, D33).
59///
60/// Windows sets both halves on the run's job, macOS the CPU half
61/// through `taskpolicy`, and Linux sets both where the harness's own
62/// cgroup may be written and nothing where it may not. The doctor and
63/// the backlog read this one answer, so the two cannot tell a machine
64/// different things.
65#[must_use]
66pub fn platform_shares() -> PlatformShares {
67 if cfg!(windows) {
68 PlatformShares::CpuAndMemory
69 } else if cfg!(target_os = "macos") {
70 PlatformShares::Cpu
71 } else {
72 match own_cgroup() {
73 Some(parent) if writable(&parent) => PlatformShares::CpuAndMemory,
74 _ => PlatformShares::None,
75 }
76 }
77}
78
79/// Each run's child cgroup, made under the harness's own (D29, D33).
80///
81/// No Windows run ever holds one — [`own_cgroup`] answers `None` there —
82/// but the tests drive the same type against a fake root on every
83/// platform, so it exists wherever a test build compiles.
84#[cfg(any(not(windows), test))]
85#[derive(Default)]
86pub(super) struct Cgroups {
87 /// The parent the runs' children are made under, once it was found
88 /// writable; `None` where this machine has none.
89 parent: Option<PathBuf>,
90 /// Set once adoption was attempted, so a machine without a
91 /// delegated cgroup asks once rather than once per command.
92 adopted: bool,
93 /// The shares each run holds.
94 runs: BTreeMap<RunId, Shares>,
95}
96
97#[cfg(any(not(windows), test))]
98impl Cgroups {
99 /// Adopts `parent`: the harness moves itself into `parent/core` and
100 /// `parent/cgroup.subtree_control` opens `cpu` and `memory`, so the
101 /// runs' children can hold both. A `parent` that may not be written
102 /// adopts nothing, and its runs read as unshared (D33).
103 ///
104 /// The tests' door onto the same adoption: a real machine reaches it
105 /// through [`Cgroups::this_machine`] and its own delegated parent, so
106 /// this takes the root a test lent it, which no build outside a test
107 /// has a caller for.
108 #[cfg(test)]
109 pub(super) fn adopt(parent: &Path, pid: u32) -> Cgroups {
110 let mut cgroups = Cgroups {
111 parent: None,
112 adopted: true,
113 runs: BTreeMap::new(),
114 };
115 if writable(parent) && core(parent, pid).is_ok() {
116 cgroups.parent = Some(parent.to_path_buf());
117 }
118 cgroups
119 }
120
121 /// The cgroup this process is in on this machine, adopted on first
122 /// use (D33).
123 fn this_machine() -> Cgroups {
124 Cgroups {
125 parent: adopted(),
126 adopted: true,
127 runs: BTreeMap::new(),
128 }
129 }
130
131 /// Puts `pid` into `run`'s child cgroup, making the child on first
132 /// use with the shares `asked` for, and answers how the memory
133 /// ceiling `asked` holds for this command (Exec.lean D95). A run whose
134 /// cgroup refused its shares holds `Unset` and its command starts
135 /// anyway (D29).
136 pub(super) fn enter(&mut self, run: RunId, pid: u32, asked: Shares) -> Mark {
137 let limit = asked.memory();
138 if asked == Shares::Unset {
139 return Mark::NotAsked;
140 }
141 if !self.adopted {
142 *self = Cgroups::this_machine();
143 }
144 let Some(parent) = &self.parent else {
145 let why = if cfg!(target_os = "macos") {
146 Unapplied::Platform
147 } else {
148 Unapplied::NotDelegated
149 };
150 return Mark::outside(limit, why);
151 };
152 let dir = parent.join(format!("run-{run}"));
153 if let std::collections::btree_map::Entry::Vacant(first) = self.runs.entry(run) {
154 if !made(&dir, asked) {
155 return Mark::outside(limit, Unapplied::Refused);
156 }
157 first.insert(asked);
158 }
159 // A command whose pid cannot be written still runs, under its
160 // parent's shares and outside the ceiling, and its result says so.
161 if std::fs::write(dir.join("cgroup.procs"), pid.to_string()).is_err() {
162 return Mark::outside(limit, Unapplied::Unjoined);
163 }
164 match limit {
165 Some(limit) => Mark::entered(limit, refusals(&dir)),
166 None => Mark::NotAsked,
167 }
168 }
169
170 /// How many times `run`'s cgroup refused an allocation at its
171 /// `memory.max`; `None` when it has no cgroup or the count does not
172 /// read (Exec.lean D95).
173 pub(super) fn refusals(&self, run: RunId) -> Option<u64> {
174 refusals(&self.parent.as_ref()?.join(format!("run-{run}")))
175 }
176
177 /// The shares `run` holds; `Unset` for a run this machine never
178 /// gave a child cgroup.
179 pub(super) fn held(&self, run: RunId) -> Shares {
180 self.runs.get(&run).copied().unwrap_or_default()
181 }
182
183 /// Lets the run's record go; its cgroup directory stays, because a
184 /// cgroup that holds a process cannot be removed (D33).
185 pub(super) fn forget(&mut self, run: RunId) {
186 self.runs.remove(&run);
187 }
188}
189
190/// Makes the run's child cgroup under `parent` and writes the shares
191/// `asked` for: `cpu.weight` for every run, and `memory.max` set to the
192/// requested limit or `max` to clear a prior ceiling. `false` when a write was refused, which leaves the run
193/// unshared rather than failing its command (D29).
194#[cfg(any(not(windows), test))]
195fn made(dir: &Path, asked: Shares) -> bool {
196 let weighed = std::fs::create_dir_all(dir)
197 .and_then(|()| std::fs::write(dir.join("cpu.weight"), RUN_CPU_WEIGHT.to_string()));
198 match asked {
199 Shares::CpuAndMemory { limit } => weighed
200 .and_then(|()| std::fs::write(dir.join("memory.max"), limit.get().to_string()))
201 .is_ok(),
202 Shares::Cpu | Shares::Unset => weighed
203 .and_then(|()| std::fs::write(dir.join("memory.max"), "max"))
204 .is_ok(),
205 }
206}
207
208/// The `oom` line of a run cgroup's `memory.events`: how many times its
209/// usage reached `memory.max` and an allocation was about to fail. The
210/// `max` line is not read, because it also counts a reclaim that
211/// succeeded (Exec.lean D95).
212#[cfg(any(not(windows), test))]
213fn refusals(dir: &Path) -> Option<u64> {
214 std::fs::read_to_string(dir.join("memory.events"))
215 .ok()?
216 .lines()
217 .find_map(|line| line.strip_prefix("oom "))?
218 .trim()
219 .parse()
220 .ok()
221}
222
223/// Moves this process into `parent/core` and opens `cpu` and `memory`
224/// for the runs' children: a cgroup that holds processes may not enable
225/// its own controllers, so the harness leaves the parent first (D29).
226#[cfg(any(not(windows), test))]
227fn core(parent: &Path, pid: u32) -> std::io::Result<()> {
228 let inner = parent.join("core");
229 std::fs::create_dir_all(&inner)?;
230 std::fs::write(inner.join("cgroup.procs"), pid.to_string())?;
231 std::fs::write(parent.join("cgroup.subtree_control"), "+cpu +memory")
232}
233
234/// The parent the runs' cgroups of this process are made under: the
235/// harness's own cgroup, adopted once on first use, or `None` where
236/// this machine has none it may write (D33).
237#[cfg(any(not(windows), test))]
238fn adopted() -> Option<PathBuf> {
239 static PARENT: OnceLock<Option<PathBuf>> = OnceLock::new();
240 PARENT
241 .get_or_init(|| {
242 let parent = own_cgroup()?;
243 if !writable(&parent) {
244 return None;
245 }
246 core(&parent, std::process::id()).ok()?;
247 Some(parent)
248 })
249 .clone()
250}
251
252/// This process's own cgroup on this machine: the `0::` line of
253/// `/proc/self/cgroup`, under the cgroup v2 mount point. `None` where
254/// the platform has no such file or no such line (D29).
255fn own_cgroup() -> Option<PathBuf> {
256 #[cfg(target_os = "linux")]
257 {
258 let raw = std::fs::read_to_string("/proc/self/cgroup").ok()?;
259 let relative = raw.lines().find_map(|line| line.strip_prefix("0::"))?;
260 Some(Path::new("/sys/fs/cgroup").join(relative.trim_start_matches('/')))
261 }
262 #[cfg(not(target_os = "linux"))]
263 {
264 None
265 }
266}
267
268/// Whether `parent` accepts the writes adoption makes: both files are
269/// opened for writing and closed, which changes nothing (D33).
270fn writable(parent: &Path) -> bool {
271 ["cgroup.procs", "cgroup.subtree_control"]
272 .iter()
273 .all(|name| {
274 std::fs::OpenOptions::new()
275 .write(true)
276 .open(parent.join(name))
277 .is_ok()
278 })
279}
280
281#[cfg(test)]
282mod tests;