agentmux_srv/backend/process_tracker/
cgroup_linux.rs1use std::path::{Path, PathBuf};
15
16use super::{TrackedProcess, TrackerHandle, TrackingConfidence};
17
18const CGROUP_FS: &str = "/sys/fs/cgroup";
19
20static ROOT: tokio::sync::OnceCell<Option<PathBuf>> = tokio::sync::OnceCell::const_new();
23
24pub fn root() -> Option<&'static Path> {
26 ROOT.get().and_then(|r| r.as_deref())
27}
28
29pub 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 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
71fn 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
80async 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
104pub 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
134fn 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
145fn 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 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 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 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
265pub 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 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 libc::write(fd, b"0".as_ptr().cast(), 1);
277 libc::close(fd);
278 }
279 Ok(())
282 });
283 }
284}
285
286pub 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; }
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 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 #[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 #[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 #[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 #[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 #[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}