Skip to main content

agentmux_srv/backend/process_tracker/
cgroup_linux.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Linux tracker: one cgroup v2 per agent, under a systemd user scope srv
5//! puts itself in at startup with `Delegate=yes`, so srv may create and
6//! manage cgroups below it.
7//!
8//! A cgroup holds every descendant of what it was given, whatever they do:
9//! `setsid`, double forks and reparenting don't leave it (process groups and
10//! sessions can't hold an agent's tree: Claude Code and bashwrap each start
11//! new sessions). `cgroup.kill` ends all of them at once.
12//! See `docs/analysis/agent-spawned-process-tracking-2026-10-07.md`.
13
14use std::path::{Path, PathBuf};
15
16use super::{TrackedProcess, TrackerHandle, TrackingConfidence};
17
18const CGROUP_FS: &str = "/sys/fs/cgroup";
19
20/// srv's delegated scope directory, or `None` when it couldn't get one (no
21/// cgroup v2, no systemd user manager): trackers then fall back.
22static ROOT: tokio::sync::OnceCell<Option<PathBuf>> = tokio::sync::OnceCell::const_new();
23
24/// The directory per-agent cgroups are created in, once [`init`] succeeded.
25pub fn root() -> Option<&'static Path> {
26    ROOT.get().and_then(|r| r.as_deref())
27}
28
29/// Move srv into its own delegated systemd user scope
30/// (`agentmux-srv-<pid>.scope`). Call once at startup, before any agent
31/// spawns. Idempotent; never fails startup.
32pub async fn init() {
33    ROOT.get_or_init(|| async {
34        match setup().await {
35            Ok(dir) => {
36                tracing::info!(cgroup = %dir.display(), "[process-tracker] agent processes tracked by cgroup");
37                Some(dir)
38            }
39            Err(e) => {
40                tracing::warn!(error = %e, "[process-tracker] no delegated cgroup; agent process tracking falls back");
41                None
42            }
43        }
44    })
45    .await;
46}
47
48async fn setup() -> Result<PathBuf, String> {
49    if !Path::new(CGROUP_FS).join("cgroup.controllers").exists() {
50        return Err("cgroup v2 is not mounted at /sys/fs/cgroup".into());
51    }
52    let unit = format!("agentmux-srv-{}.scope", std::process::id());
53    if !own_cgroup()?.ends_with(&format!("/{unit}")) {
54        start_transient_scope(&unit).await?;
55        let until = std::time::Instant::now() + std::time::Duration::from_secs(3);
56        while !own_cgroup()?.ends_with(&format!("/{unit}")) {
57            if std::time::Instant::now() >= until {
58                return Err(format!("systemd did not move srv into {unit}"));
59            }
60            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
61        }
62    }
63    let dir = Path::new(CGROUP_FS).join(own_cgroup()?.trim_start_matches('/'));
64    // Prove delegation: a child cgroup can be made and removed.
65    let probe = dir.join("probe");
66    std::fs::create_dir(&probe).map_err(|e| format!("create a cgroup in {}: {e}", dir.display()))?;
67    let _ = std::fs::remove_dir(&probe);
68    Ok(dir)
69}
70
71/// This process's cgroup v2 path (`/user.slice/…`).
72fn own_cgroup() -> Result<String, String> {
73    let text = std::fs::read_to_string("/proc/self/cgroup").map_err(|e| format!("read /proc/self/cgroup: {e}"))?;
74    text.lines()
75        .find_map(|l| l.strip_prefix("0::"))
76        .map(str::to_string)
77        .ok_or_else(|| "no cgroup v2 entry in /proc/self/cgroup".into())
78}
79
80/// Ask the user's systemd manager for a transient scope holding this
81/// process, delegated to us (what `systemd-run --user --scope -p
82/// Delegate=yes` does).
83async fn start_transient_scope(unit: &str) -> Result<(), String> {
84    use zbus::zvariant::Value;
85    let conn = zbus::Connection::session().await.map_err(|e| format!("session bus: {e}"))?;
86    let props: Vec<(&str, Value)> = vec![
87        ("Description", Value::from("AgentMux srv and the agents it runs")),
88        ("PIDs", Value::from(vec![std::process::id()])),
89        ("Delegate", Value::from(true)),
90    ];
91    let aux: Vec<(&str, Vec<(&str, Value)>)> = Vec::new();
92    conn.call_method(
93        Some("org.freedesktop.systemd1"),
94        "/org/freedesktop/systemd1",
95        Some("org.freedesktop.systemd1.Manager"),
96        "StartTransientUnit",
97        &(unit, "fail", props, aux),
98    )
99    .await
100    .map_err(|e| format!("StartTransientUnit {unit}: {e}"))?;
101    Ok(())
102}
103
104/// One agent's cgroup.
105pub struct CgroupTracker {
106    dir: PathBuf,
107}
108
109impl CgroupTracker {
110    pub fn new(root: &Path, block_id: &str) -> Result<Self, String> {
111        let name: String = block_id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' }).collect();
112        let dir = root.join(format!("agent-{name}"));
113        match std::fs::create_dir(&dir) {
114            Ok(()) => {}
115            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
116            Err(e) => return Err(format!("create {}: {e}", dir.display())),
117        }
118        Ok(Self { dir })
119    }
120
121    fn pids(&self) -> Vec<u32> {
122        pids_in(&self.dir)
123    }
124}
125
126fn pids_in(dir: &Path) -> Vec<u32> {
127    std::fs::read_to_string(dir.join("cgroup.procs"))
128        .unwrap_or_default()
129        .lines()
130        .filter_map(|l| l.trim().parse().ok())
131        .collect()
132}
133
134/// `usage_usec` from a `cpu.stat`.
135fn usage_usec(stat: &str) -> Option<u64> {
136    stat.lines().find_map(|l| l.strip_prefix("usage_usec ")).and_then(|v| v.trim().parse().ok())
137}
138
139fn populated(dir: &Path) -> bool {
140    std::fs::read_to_string(dir.join("cgroup.events"))
141        .map(|t| t.lines().any(|l| l == "populated 1"))
142        .unwrap_or(false)
143}
144
145/// Kill everything in `dir`: `cgroup.kill` (kernel 5.14+), else SIGKILL each
146/// member until none is left.
147fn kill_all(dir: &Path) {
148    if std::fs::write(dir.join("cgroup.kill"), "1").is_ok() {
149        return;
150    }
151    for _ in 0..5 {
152        let pids = pids_in(dir);
153        if pids.is_empty() {
154            return;
155        }
156        for pid in pids {
157            signal(pid, libc::SIGKILL);
158        }
159        std::thread::sleep(std::time::Duration::from_millis(20));
160    }
161}
162
163fn signal(pid: u32, sig: libc::c_int) {
164    // SAFETY: kill(2) with a pid read from cgroup.procs and a constant signal.
165    unsafe {
166        libc::kill(pid as libc::pid_t, sig);
167    }
168}
169
170impl TrackerHandle for CgroupTracker {
171    fn assign_process(&self, pid: u32) -> Result<(), String> {
172        std::fs::write(self.dir.join("cgroup.procs"), pid.to_string())
173            .map_err(|e| format!("move pid {pid} into {}: {e}", self.dir.display()))
174    }
175
176    fn list_members(&self) -> Vec<TrackedProcess> {
177        let pids = self.pids();
178        if pids.is_empty() {
179            return Vec::new();
180        }
181        let list: Vec<sysinfo::Pid> = pids.iter().map(|p| sysinfo::Pid::from_u32(*p)).collect();
182        let mut sys = sysinfo::System::new();
183        sys.refresh_processes_specifics(
184            sysinfo::ProcessesToUpdate::Some(&list),
185            true,
186            sysinfo::ProcessRefreshKind::nothing().with_cmd(sysinfo::UpdateKind::Always).with_memory(),
187        );
188        sys.processes()
189            .iter()
190            .map(|(pid, p)| {
191                let cmd: Vec<String> = p.cmd().iter().map(|a| a.to_string_lossy().into_owned()).collect();
192                TrackedProcess {
193                    pid: pid.as_u32(),
194                    command: if cmd.is_empty() { p.name().to_string_lossy().into_owned() } else { cmd.join(" ") },
195                    rss_bytes: p.memory(),
196                    started_at_ms: p.start_time() * 1000,
197                    parent_pid: p.parent().map(|pp| pp.as_u32()),
198                    exe: cmd.first().cloned().unwrap_or_else(|| p.name().to_string_lossy().into_owned()),
199                }
200            })
201            .collect()
202    }
203
204    fn kill_tree(&self) {
205        kill_all(&self.dir);
206    }
207
208    fn kill_pid(&self, pid: u32) -> bool {
209        if !self.pids().contains(&pid) {
210            return false;
211        }
212        signal(pid, libc::SIGKILL);
213        true
214    }
215
216    fn confidence(&self) -> TrackingConfidence {
217        TrackingConfidence::High
218    }
219
220    fn terminate(&self) -> bool {
221        for pid in self.pids() {
222            signal(pid, libc::SIGTERM);
223        }
224        true
225    }
226
227    fn member_count(&self) -> usize {
228        self.pids().len()
229    }
230
231    fn member_pids(&self) -> Vec<u32> {
232        self.pids()
233    }
234
235    /// `cpu.stat`'s `usage_usec` exists whether or not the cpu controller is
236    /// enabled, and counts members that have exited.
237    fn cpu_time_ns(&self) -> Option<u64> {
238        let stat = std::fs::read_to_string(self.dir.join("cpu.stat")).ok()?;
239        usage_usec(&stat).map(|us| us * 1000)
240    }
241
242    fn spawn_target(&self) -> Option<PathBuf> {
243        Some(self.dir.join("cgroup.procs"))
244    }
245}
246
247impl Drop for CgroupTracker {
248    /// The agent is gone: end whatever it left and remove its cgroup, which
249    /// the kernel allows only once it's empty.
250    fn drop(&mut self) {
251        let dir = self.dir.clone();
252        kill_all(&dir);
253        std::thread::spawn(move || {
254            let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
255            while populated(&dir) && std::time::Instant::now() < until {
256                std::thread::sleep(std::time::Duration::from_millis(20));
257            }
258            if let Err(e) = std::fs::remove_dir(&dir) {
259                tracing::warn!(cgroup = %dir.display(), error = %e, "[process-tracker] could not remove an agent's cgroup");
260            }
261        });
262    }
263}
264
265/// Make a child join `procs` (a `cgroup.procs` file) before it execs, so it
266/// is tracked before it runs any code of its own and before it can fork.
267pub fn join_before_exec(cmd: &mut tokio::process::Command, procs: PathBuf) {
268    let Ok(path) = std::ffi::CString::new(procs.into_os_string().into_encoded_bytes()) else { return };
269    // SAFETY: the hook only calls open/write/close, which are
270    // async-signal-safe, on memory allocated before the fork.
271    unsafe {
272        cmd.pre_exec(move || {
273            let fd = libc::open(path.as_ptr(), libc::O_WRONLY | libc::O_CLOEXEC);
274            if fd >= 0 {
275                // "0" moves the writing process: this child.
276                libc::write(fd, b"0".as_ptr().cast(), 1);
277                libc::close(fd);
278            }
279            // Failing to join is not fatal: the parent assigns the PID right
280            // after spawn as well.
281            Ok(())
282        });
283    }
284}
285
286/// [`join_before_exec`] for a PTY spawn (portable-pty has no pre-exec hook):
287/// run the command through `sh`, which joins and then `exec`s it, so the
288/// program starts already in the cgroup, as the same process.
289pub fn join_before_exec_pty(cmd: &mut portable_pty::CommandBuilder, procs: PathBuf) {
290    let argv = cmd.get_argv_mut();
291    if argv.is_empty() {
292        return; // the user's default shell: never an agent
293    }
294    let mut wrapped: Vec<std::ffi::OsString> = vec![
295        "/bin/sh".into(),
296        "-c".into(),
297        r#"echo 0 > "$0" 2>/dev/null; exec "$@""#.into(),
298        procs.into_os_string(),
299    ];
300    wrapped.append(argv);
301    *argv = wrapped;
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn usage_usec_is_read_from_cpu_stat() {
310        let stat = "usage_usec 1234567
311user_usec 1000000
312system_usec 234567
313";
314        assert_eq!(usage_usec(stat), Some(1_234_567));
315        assert_eq!(usage_usec("user_usec 5
316"), None);
317    }
318
319    /// A delegated scope for this test binary, or `None` where there is no
320    /// systemd user manager (CI containers): the tests then skip.
321    async fn test_root() -> Option<&'static Path> {
322        init().await;
323        root()
324    }
325
326    fn sh(script: &str, procs: Option<PathBuf>) -> tokio::process::Child {
327        let mut cmd = tokio::process::Command::new("sh");
328        cmd.args(["-c", script]).kill_on_drop(true);
329        if let Some(p) = procs {
330            join_before_exec(&mut cmd, p);
331        }
332        cmd.spawn().expect("spawn sh")
333    }
334
335    async fn wait_for(mut f: impl FnMut() -> bool) -> bool {
336        let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
337        while !f() {
338            if std::time::Instant::now() >= until {
339                return false;
340            }
341            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
342        }
343        true
344    }
345
346    /// The case process groups can't hold: a `setsid` daemon, a double
347    /// fork, and a plain child all stay in the agent's cgroup, are listed,
348    /// and end with `kill_tree`.
349    #[tokio::test]
350    async fn every_descendant_is_tracked_and_killed_however_it_detaches() {
351        let Some(root) = test_root().await else {
352            eprintln!("skipped: no delegated cgroup here");
353            return;
354        };
355        let tracker = CgroupTracker::new(root, "test-detaching-descendants").unwrap();
356        let mut child = sh(
357            "setsid sleep 3001 & (sleep 3002 &); sleep 3003 & echo started; wait",
358            tracker.spawn_target(),
359        );
360        assert!(wait_for(|| tracker.member_count() >= 4).await, "members: {:?}", tracker.pids());
361        let listed: Vec<String> = tracker.list_members().into_iter().map(|p| p.command).collect();
362        for s in ["sleep 3001", "sleep 3002", "sleep 3003"] {
363            assert!(listed.iter().any(|c| c == s), "{s} not listed in {listed:?}");
364        }
365        tracker.kill_tree();
366        assert!(wait_for(|| !populated(&tracker.dir)).await, "left: {:?}", tracker.pids());
367        let _ = child.wait().await;
368    }
369
370    /// `terminate` asks politely: a process that exits on SIGTERM is gone
371    /// without a kill.
372    #[tokio::test]
373    async fn terminate_sends_sigterm_to_every_member() {
374        let Some(root) = test_root().await else { return };
375        let tracker = CgroupTracker::new(root, "test-terminate").unwrap();
376        let _child = sh("setsid sleep 3011 & sleep 3012", tracker.spawn_target());
377        assert!(wait_for(|| tracker.member_count() >= 2).await);
378        tracker.terminate();
379        assert!(wait_for(|| tracker.member_count() == 0).await, "left: {:?}", tracker.pids());
380    }
381
382    /// Dropping the tracker (the agent's block is gone) ends what's left and
383    /// removes the cgroup.
384    #[tokio::test]
385    async fn drop_kills_what_is_left_and_removes_the_cgroup() {
386        let Some(root) = test_root().await else { return };
387        let tracker = CgroupTracker::new(root, "test-drop").unwrap();
388        let dir = tracker.dir.clone();
389        let _child = sh("trap '' TERM; setsid sleep 3021 & sleep 3022", tracker.spawn_target());
390        assert!(wait_for(|| pids_in(&dir).len() >= 2).await);
391        drop(tracker);
392        assert!(wait_for(|| !dir.exists()).await, "cgroup still there: {:?}", pids_in(&dir));
393    }
394
395    /// A PTY-style spawn wrapped by `join_before_exec_pty` starts in the
396    /// cgroup as the same process, with its own argv.
397    #[tokio::test]
398    async fn a_wrapped_pty_command_joins_before_it_runs() {
399        let Some(root) = test_root().await else { return };
400        let tracker = CgroupTracker::new(root, "test-pty-wrap").unwrap();
401        let mut b = portable_pty::CommandBuilder::new("sleep");
402        b.arg("3041");
403        join_before_exec_pty(&mut b, tracker.spawn_target().unwrap());
404        let argv: Vec<String> = b.get_argv().iter().map(|a| a.to_string_lossy().into_owned()).collect();
405        let mut child = tokio::process::Command::new(&argv[0]).args(&argv[1..]).kill_on_drop(true).spawn().unwrap();
406        let pid = child.id().unwrap();
407        assert!(wait_for(|| tracker.pids() == vec![pid]).await, "members: {:?}", tracker.pids());
408        let listed = tracker.list_members();
409        assert_eq!(listed.first().map(|p| p.command.as_str()), Some("sleep 3041"), "exec'd in place");
410        tracker.kill_tree();
411        let _ = child.wait().await;
412    }
413
414    /// `assign_process` after spawn tracks a child spawned without the
415    /// pre-exec hook (the PTY path), and `kill_pid` only touches members.
416    #[tokio::test]
417    async fn assign_after_spawn_and_kill_pid_only_members() {
418        let Some(root) = test_root().await else { return };
419        let tracker = CgroupTracker::new(root, "test-assign").unwrap();
420        let mut child = sh("sleep 3031", None);
421        let pid = child.id().unwrap();
422        tracker.assign_process(pid).unwrap();
423        assert_eq!(tracker.pids(), vec![pid]);
424        assert!(!tracker.kill_pid(1), "pid 1 is not a member");
425        assert!(tracker.kill_pid(pid));
426        let _ = child.wait().await;
427        assert!(wait_for(|| tracker.member_count() == 0).await);
428    }
429}