Skip to main content

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;