agentmux_srv/backend/process_tracker/
scan.rs1use std::collections::{HashMap, HashSet};
25use std::ffi::OsString;
26use std::sync::{Arc, Mutex};
27
28use super::{TrackedProcess, TrackerHandle, TrackingConfidence};
29
30const SNAPSHOT_TTL: std::time::Duration = std::time::Duration::from_millis(500);
33
34struct Snapshot {
35 sys: sysinfo::System,
36 at: Option<std::time::Instant>,
37}
38
39static SNAPSHOT: Mutex<Option<Snapshot>> = Mutex::new(None);
40
41fn with_snapshot<T>(after: Option<std::time::Instant>, f: impl FnOnce(&sysinfo::System) -> T) -> T {
46 let mut guard = SNAPSHOT.lock().unwrap_or_else(|e| e.into_inner());
47 let snap = guard.get_or_insert_with(|| Snapshot { sys: sysinfo::System::new(), at: None });
48 let stale = match snap.at {
49 None => true,
50 Some(t) => after.is_some_and(|a| t < a) || t.elapsed() >= SNAPSHOT_TTL,
51 };
52 if stale {
53 let started = std::time::Instant::now();
56 snap.sys.refresh_processes_specifics(
57 sysinfo::ProcessesToUpdate::All,
58 true,
59 sysinfo::ProcessRefreshKind::nothing()
60 .with_cmd(sysinfo::UpdateKind::Always)
61 .with_environ(sysinfo::UpdateKind::Always)
62 .with_memory(),
63 );
64 snap.at = Some(started);
65 }
66 f(&snap.sys)
67}
68
69fn same_process(started_ms: u64, p: &sysinfo::Process) -> bool {
72 started_ms.abs_diff(p.start_time() * 1000) <= 1000
73}
74
75fn is_root(roots: &HashMap<u32, u64>, pid: u32, p: &sysinfo::Process) -> bool {
76 roots.get(&pid).is_some_and(|started| same_process(*started, p))
77}
78
79fn members_in(sys: &sysinfo::System, roots: &HashMap<u32, u64>, tag: &OsString) -> Vec<TrackedProcess> {
83 let me = std::process::id();
84 let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
85 for (pid, p) in sys.processes() {
86 if let Some(parent) = p.parent() {
87 children.entry(parent.as_u32()).or_default().push(pid.as_u32());
88 }
89 }
90 let mut set: Vec<u32> = sys
91 .processes()
92 .iter()
93 .filter(|(_, p)| p.status() != sysinfo::ProcessStatus::Zombie)
94 .filter(|(pid, p)| is_root(roots, pid.as_u32(), p) || p.environ().iter().any(|e| e == tag))
95 .map(|(pid, _)| pid.as_u32())
96 .collect();
97 let mut seen: HashSet<u32> = set.iter().copied().collect();
98 let mut i = 0;
99 while i < set.len() {
100 for c in children.get(&set[i]).into_iter().flatten() {
101 let zombie = sys.process(sysinfo::Pid::from_u32(*c)).is_some_and(|p| p.status() == sysinfo::ProcessStatus::Zombie);
102 if !zombie && seen.insert(*c) {
103 set.push(*c);
104 }
105 }
106 i += 1;
107 }
108 set.into_iter()
109 .filter(|pid| *pid != me && *pid > 1)
110 .filter_map(|pid| sys.process(sysinfo::Pid::from_u32(pid)).map(|p| (pid, p)))
111 .map(|(pid, p)| {
112 let cmd: Vec<String> = p.cmd().iter().map(|a| a.to_string_lossy().into_owned()).collect();
113 TrackedProcess {
114 pid,
115 command: if cmd.is_empty() { p.name().to_string_lossy().into_owned() } else { cmd.join(" ") },
116 rss_bytes: p.memory(),
117 started_at_ms: p.start_time() * 1000,
118 parent_pid: p.parent().map(|pp| pp.as_u32()),
119 exe: cmd.first().cloned().unwrap_or_else(|| p.name().to_string_lossy().into_owned()),
120 }
121 })
122 .collect()
123}
124
125#[cfg(unix)]
128fn ask_to_exit(pid: u32) -> bool {
129 unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) == 0 }
131}
132
133#[cfg(windows)]
134fn ask_to_exit(_pid: u32) -> bool {
135 false
136}
137
138#[cfg(unix)]
139fn kill_now(pid: u32) {
140 unsafe {
142 libc::kill(pid as libc::pid_t, libc::SIGKILL);
143 }
144}
145
146#[cfg(windows)]
147fn kill_now(pid: u32) {
148 use windows_sys::Win32::Foundation::CloseHandle;
149 use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
150 unsafe {
152 let h = OpenProcess(PROCESS_TERMINATE, 0, pid);
153 if !h.is_null() {
154 TerminateProcess(h, 1);
155 CloseHandle(h);
156 }
157 }
158}
159
160pub fn block_tag(block_id: &str) -> OsString {
162 OsString::from(format!("AGENTMUX_BLOCKID={block_id}"))
163}
164
165pub fn tagged(block_id: &str) -> Vec<TrackedProcess> {
168 with_snapshot(Some(std::time::Instant::now()), |sys| members_in(sys, &HashMap::new(), &block_tag(block_id)))
169}
170
171struct Inner {
172 tag: OsString,
173 roots: Mutex<HashMap<u32, u64>>,
177}
178
179impl Inner {
180 fn members(&self, fresh: bool) -> Vec<TrackedProcess> {
181 let after = fresh.then(std::time::Instant::now);
182 with_snapshot(after, |sys| {
183 let mut roots = self.roots.lock().unwrap_or_else(|e| e.into_inner());
184 roots.retain(|pid, started| sys.process(sysinfo::Pid::from_u32(*pid)).is_some_and(|p| same_process(*started, p)));
185 members_in(sys, &roots, &self.tag)
186 })
187 }
188
189 fn kill_all(&self) {
192 for _ in 0..4 {
193 let members = self.members(true);
194 if members.is_empty() {
195 return;
196 }
197 for p in members.iter().rev() {
198 kill_now(p.pid);
199 }
200 std::thread::sleep(std::time::Duration::from_millis(30));
201 }
202 }
203}
204
205pub struct ScanTracker {
207 inner: Arc<Inner>,
208}
209
210impl ScanTracker {
211 pub fn new(block_id: &str) -> Self {
212 Self { inner: Arc::new(Inner { tag: block_tag(block_id), roots: Mutex::new(HashMap::new()) }) }
213 }
214}
215
216impl TrackerHandle for ScanTracker {
217 fn assign_process(&self, pid: u32) -> Result<(), String> {
218 let started = with_snapshot(Some(std::time::Instant::now()), |sys| {
221 sys.process(sysinfo::Pid::from_u32(pid)).map(|p| p.start_time() * 1000)
222 });
223 let Some(started) = started else {
224 return Err(format!("pid {pid} is not running"));
225 };
226 self.inner.roots.lock().unwrap_or_else(|e| e.into_inner()).insert(pid, started);
227 Ok(())
228 }
229
230 fn list_members(&self) -> Vec<TrackedProcess> {
231 self.inner.members(false)
232 }
233
234 fn kill_tree(&self) {
235 self.inner.kill_all();
236 }
237
238 fn kill_pid(&self, pid: u32) -> bool {
239 if !self.inner.members(true).iter().any(|p| p.pid == pid) {
240 return false;
241 }
242 kill_now(pid);
243 true
244 }
245
246 fn confidence(&self) -> TrackingConfidence {
247 TrackingConfidence::BestEffort
248 }
249
250 fn terminate(&self) -> bool {
251 let mut asked = false;
252 for p in self.inner.members(true) {
253 asked |= ask_to_exit(p.pid);
254 }
255 asked
256 }
257
258 fn member_count(&self) -> usize {
259 self.inner.members(true).len()
260 }
261}
262
263impl Drop for ScanTracker {
264 fn drop(&mut self) {
267 let inner = Arc::clone(&self.inner);
268 std::thread::spawn(move || inner.kill_all());
269 }
270}
271
272#[cfg(all(test, unix))]
273mod tests {
274 use super::*;
275
276 fn spawn(script: &str, block_id: Option<&str>) -> std::process::Child {
277 let mut cmd = std::process::Command::new("sh");
278 cmd.args(["-c", script]);
279 if let Some(id) = block_id {
280 cmd.env("AGENTMUX_BLOCKID", id);
281 }
282 cmd.spawn().expect("spawn sh")
283 }
284
285 fn wait_for(mut f: impl FnMut() -> bool) -> bool {
286 let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
287 while !f() {
288 if std::time::Instant::now() >= until {
289 return false;
290 }
291 std::thread::sleep(std::time::Duration::from_millis(30));
292 }
293 true
294 }
295
296 fn commands(t: &ScanTracker) -> Vec<String> {
297 t.inner.members(true).into_iter().map(|p| p.command).collect()
298 }
299
300 #[test]
304 fn a_detached_tagged_process_is_found_and_killed() {
305 let block = format!("scan-test-detached-{}", std::process::id());
306 let tracker = ScanTracker::new(&block);
307 let mut sh = spawn("(sleep 4001 >/dev/null 2>&1 < /dev/null &)", Some(&block));
308 let _ = sh.wait();
309 let found = || commands(&tracker).iter().any(|c| c.contains("4001"));
310 assert!(wait_for(found), "members: {:?}; tagged: {:?}", commands(&tracker), tagged(&block));
311 tracker.kill_tree();
312 assert!(wait_for(|| commands(&tracker).is_empty()), "left: {:?}", commands(&tracker));
313 }
314
315 #[test]
319 fn a_fresh_scan_sees_a_just_spawned_process_under_concurrent_scans() {
320 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
321 let busy: Vec<_> = (0..6)
322 .map(|_| {
323 let stop = Arc::clone(&stop);
324 std::thread::spawn(move || {
325 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
326 let _ = tagged("scan-test-busy-nothing");
327 std::thread::sleep(std::time::Duration::from_micros(200));
328 }
329 })
330 })
331 .collect();
332 let tracker = ScanTracker::new(&format!("scan-test-race-{}", std::process::id()));
333 for _ in 0..200 {
334 let mut child = spawn("exec sleep 4041", None);
335 let assigned = tracker.assign_process(child.id());
336 let _ = child.kill();
337 let _ = child.wait();
338 assert!(assigned.is_ok(), "{assigned:?}");
339 }
340 stop.store(true, std::sync::atomic::Ordering::Relaxed);
341 for t in busy {
342 let _ = t.join();
343 }
344 }
345
346 #[test]
349 fn an_exited_root_is_pruned_and_its_pid_not_trusted() {
350 let block = format!("scan-test-prune-{}", std::process::id());
351 let tracker = ScanTracker::new(&block);
352 let mut root = spawn("exec sleep 4031", None);
353 tracker.assign_process(root.id()).unwrap();
354 assert!(wait_for(|| tracker.member_count() == 1));
355 let _ = root.kill();
356 let _ = root.wait();
357 assert!(wait_for(|| tracker.member_count() == 0));
358 assert!(tracker.inner.roots.lock().unwrap().is_empty(), "the exited root was pruned");
359 assert!(tracker.assign_process(root.id()).is_err(), "a PID that isn't running can't be assigned");
360 }
361
362 #[test]
366 fn roots_descendants_count_and_other_blocks_do_not() {
367 let block = format!("scan-test-roots-{}", std::process::id());
368 let tracker = ScanTracker::new(&block);
369 let mut root = spawn("env -i sleep 4011 & sleep 4012; wait", None);
370 tracker.assign_process(root.id()).unwrap();
371 let mut other = spawn("sleep 4013", Some("some-other-block"));
372 assert!(wait_for(|| commands(&tracker).iter().any(|c| c == "sleep 4011")), "{:?}", commands(&tracker));
373 assert!(!commands(&tracker).iter().any(|c| c == "sleep 4013"), "another block's process is not a member");
374 tracker.terminate();
375 assert!(wait_for(|| tracker.member_count() == 0), "left: {:?}", commands(&tracker));
376 let _ = root.wait();
377 let _ = other.kill();
378 let _ = other.wait();
379 }
380
381 #[test]
383 fn drop_kills_what_is_left() {
384 let block = format!("scan-test-drop-{}", std::process::id());
385 let mut sh = spawn("trap '' TERM; (sleep 4021 >/dev/null 2>&1 < /dev/null &)", Some(&block));
386 let _ = sh.wait();
387 let tracker = ScanTracker::new(&block);
388 assert!(wait_for(|| tracker.member_count() == 1));
389 drop(tracker);
390 assert!(wait_for(|| tagged(&block).is_empty()), "left: {:?}", tagged(&block));
391 }
392}