Skip to main content

rlmctl_core/
status.rs

1use crate::cgroup::UNLIMIT_CGROUP_NAME;
2use crate::CgroupManager;
3use common::Result;
4use std::fs;
5use std::path::Path;
6
7#[derive(Debug)]
8pub struct ProcessStatus {
9    pub pid: u32,
10    pub name: String,
11    pub cgroup_name: String,
12    pub memory_max: Option<u64>,
13    pub cpu_quota: Option<u32>,
14    pub io_read_bps: Option<u64>,
15    pub io_write_bps: Option<u64>,
16    pub is_shared: bool,
17    pub process_count: Option<usize>,
18    pub populated: bool,
19}
20
21/// Names of the cgroups under rlm's base that `status` reports on:
22/// "pid-N" (`rlm limit`), "app-*" and "multi-*" (shared limits), "run-*"
23/// (`rlm run`) and "gtk-*" (GUI run). The `unlimit` bucket is never one.
24fn managed_cgroup_names(manager: &CgroupManager) -> Result<Vec<String>> {
25    let base = manager.base_path();
26    if !base.exists() {
27        return Ok(Vec::new());
28    }
29    let mut names = Vec::new();
30    for entry in fs::read_dir(base)? {
31        let entry = entry?;
32        if !entry.path().is_dir() {
33            continue;
34        }
35        let Some(name) = entry.file_name().to_str().map(str::to_string) else {
36            continue;
37        };
38        if name == UNLIMIT_CGROUP_NAME {
39            continue;
40        }
41        if ["pid-", "app-", "multi-", "run-", "gtk-"]
42            .iter()
43            .any(|p| name.starts_with(p))
44        {
45            names.push(name);
46        }
47    }
48    names.sort();
49    Ok(names)
50}
51
52/// Get status of all processes managed by rlm. Read-only: it never removes
53/// or changes a cgroup, so a GUI refresh loop can call it safely. A cgroup
54/// is listed while it is populated; `pid` is the first PID in its
55/// `cgroup.procs` (0 if none) and `name` that process's comm, or "?".
56pub fn get_managed_processes(manager: &CgroupManager) -> Result<Vec<ProcessStatus>> {
57    let base = manager.base_path();
58    let mut results = Vec::new();
59
60    for cgroup_name in managed_cgroup_names(manager)? {
61        if manager.is_populated(&cgroup_name) != Some(true) {
62            continue;
63        }
64        let path = base.join(&cgroup_name);
65
66        let memory_max = parse_memory_max(&path);
67        let cpu_quota = parse_cpu_quota(&path);
68        let (io_read_bps, io_write_bps) = parse_io_limits(&path);
69
70        // Skip cgroups with no active limits (all set to max/unlimited)
71        if memory_max.is_none()
72            && cpu_quota.is_none()
73            && io_read_bps.is_none()
74            && io_write_bps.is_none()
75        {
76            continue;
77        }
78
79        let pid = read_first_pid(&path).unwrap_or(0);
80        let name = fs::read_to_string(format!("/proc/{pid}/comm"))
81            .ok()
82            .filter(|_| pid != 0)
83            .map(|s| s.trim().to_string())
84            .unwrap_or_else(|| "?".to_string());
85
86        let is_shared = !cgroup_name.starts_with("pid-");
87        let process_count = if is_shared {
88            fs::read_to_string(path.join("cgroup.procs"))
89                .ok()
90                .map(|c| c.lines().filter(|l| !l.trim().is_empty()).count())
91        } else {
92            None
93        };
94
95        results.push(ProcessStatus {
96            pid,
97            name,
98            cgroup_name,
99            memory_max,
100            cpu_quota,
101            io_read_bps,
102            io_write_bps,
103            is_shared,
104            process_count,
105            populated: true,
106        });
107    }
108
109    Ok(results)
110}
111
112/// Names of rlm cgroups that hold no process, sorted. `status` reports them
113/// but never removes them.
114pub fn empty_cgroups(manager: &CgroupManager) -> Vec<String> {
115    managed_cgroup_names(manager)
116        .unwrap_or_default()
117        .into_iter()
118        .filter(|n| manager.is_populated(n) == Some(false))
119        .collect()
120}
121
122fn read_first_pid(cgroup_path: &Path) -> Option<u32> {
123    let content = fs::read_to_string(cgroup_path.join("cgroup.procs")).ok()?;
124    content.lines().next()?.trim().parse().ok()
125}
126
127fn parse_memory_max(cgroup_path: &Path) -> Option<u64> {
128    let content = fs::read_to_string(cgroup_path.join("memory.max")).ok()?;
129    let content = content.trim();
130    if content == "max" {
131        return None;
132    }
133    content.parse().ok()
134}
135
136fn parse_cpu_quota(cgroup_path: &Path) -> Option<u32> {
137    let content = fs::read_to_string(cgroup_path.join("cpu.max")).ok()?;
138    let content = content.trim();
139    if content == "max" || content.starts_with("max ") {
140        return None;
141    }
142
143    // Format: "quota period" e.g., "50000 100000" = 50%
144    let mut parts = content.split_whitespace();
145    let quota: u64 = parts.next()?.parse().ok()?;
146    let period: u64 = parts.next()?.parse().ok()?;
147
148    if period == 0 {
149        return None;
150    }
151
152    // Use saturating arithmetic to prevent overflow
153    Some(quota.saturating_mul(100).saturating_div(period) as u32)
154}
155
156fn parse_io_limits(cgroup_path: &Path) -> (Option<u64>, Option<u64>) {
157    let content = match fs::read_to_string(cgroup_path.join("io.max")) {
158        Ok(c) => c,
159        Err(_) => return (None, None),
160    };
161
162    let mut read_bps = None;
163    let mut write_bps = None;
164
165    // Format: "major:minor rbps=X wbps=Y" (one line per device)
166    for line in content.lines() {
167        for part in line.split_whitespace().skip(1) {
168            if let Some(val) = part.strip_prefix("rbps=") {
169                if val != "max" {
170                    read_bps = read_bps.or_else(|| val.parse().ok());
171                }
172            } else if let Some(val) = part.strip_prefix("wbps=") {
173                if val != "max" {
174                    write_bps = write_bps.or_else(|| val.parse().ok());
175                }
176            }
177        }
178    }
179
180    (read_bps, write_bps)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use std::fs;
187
188    fn cg(dir: &Path, name: &str, procs: &str, populated: bool) {
189        let p = dir.join(name);
190        fs::create_dir(&p).unwrap();
191        fs::write(p.join("cgroup.procs"), procs).unwrap();
192        fs::write(
193            p.join("cgroup.events"),
194            format!("populated {}\nfrozen 0\n", u8::from(populated)),
195        )
196        .unwrap();
197        fs::write(p.join("memory.max"), "104857600\n").unwrap();
198    }
199
200    #[test]
201    fn status_is_read_only() {
202        let dir = tempfile::tempdir().unwrap();
203        cg(dir.path(), "run-1-2", "", false);
204        let m = CgroupManager::at(dir.path().to_path_buf());
205        assert!(get_managed_processes(&m).unwrap().is_empty());
206        assert!(
207            dir.path().join("run-1-2").exists(),
208            "status must never remove cgroups"
209        );
210        assert_eq!(empty_cgroups(&m), vec!["run-1-2".to_string()]);
211    }
212
213    #[test]
214    fn populated_cgroup_is_listed_even_if_its_first_pid_is_gone() {
215        let dir = tempfile::tempdir().unwrap();
216        cg(dir.path(), "app-firefox", "999999999\n", true);
217        let m = CgroupManager::at(dir.path().to_path_buf());
218        let s = get_managed_processes(&m).unwrap();
219        assert_eq!(s.len(), 1);
220        assert_eq!(s[0].name, "?");
221        assert!(s[0].populated);
222    }
223}