Skip to main content

agentmux_srv/backend/process_tracker/
scan.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Finding an agent's processes in the process table, by the env tag they
5//! inherit. Two uses:
6//!
7//! - [`tagged`], on every OS: after a teardown, what still carries the tag got
8//!   out of the agent's tracker, and is reported (`agent_teardown`).
9//! - [`ScanTracker`]: the best-effort tracker where the OS can't hold the
10//!   tree: macOS (no cgroups), Linux without a delegated cgroup, and Windows
11//!   when a Job Object can't be created.
12//!
13//! An agent's processes are:
14//!
15//! - every descendant of the processes assigned to it (its CLI), and
16//! - every process carrying its `AGENTMUX_BLOCKID` in its environment, which
17//!   whatever the agent starts inherits, `setsid` daemons and orphans
18//!   reparented to init or launchd included, plus their descendants.
19//!
20//! A process that cleared its environment after leaving the tree is missed;
21//! hence `BestEffort`. See
22//! `docs/analysis/agent-spawned-process-tracking-2026-10-07.md` §6.3.
23
24use std::collections::{HashMap, HashSet};
25use std::ffi::OsString;
26use std::sync::{Arc, Mutex};
27
28use super::{TrackedProcess, TrackerHandle, TrackingConfidence};
29
30/// A process-table snapshot shared by every scan tracker, refreshed at most
31/// this often: the 2 s poll of N agents costs one OS scan, not N.
32const 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
41/// Run `f` over a snapshot of the process table, environments and command
42/// lines included, taken no earlier than `after` (when given) and no older
43/// than [`SNAPSHOT_TTL`]. Callers waiting on the lock share the scan the
44/// first of them takes.
45fn 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        // Stamped with when the scan STARTED: a process spawned while it ran
54        // may be missing from it, so it must not satisfy a later `after`.
55        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
69/// Is `p` the process recorded as started at `started_ms`? A PID the OS has
70/// reused for another process is not (start times are whole seconds).
71fn 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
79/// The members of one agent's tree in `sys`: its live `roots` (PID → start
80/// time) and every process whose environment holds `tag`
81/// (`AGENTMUX_BLOCKID=<block>`), then all their descendants. Never srv itself.
82fn 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/// Ask `pid` to exit. `false` where there is no such signal (Windows: the
126/// agent side runs without a console, so no CTRL_BREAK).
127#[cfg(unix)]
128fn ask_to_exit(pid: u32) -> bool {
129    // SAFETY: kill(2) with a pid from the process table and a constant signal.
130    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    // SAFETY: kill(2) with a pid from the process table and a constant signal.
141    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    // SAFETY: a handle opened, used and closed here.
151    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
160/// The env entry that marks a block's processes.
161pub fn block_tag(block_id: &str) -> OsString {
162    OsString::from(format!("AGENTMUX_BLOCKID={block_id}"))
163}
164
165/// Every live process tagged with `block_id` (and its descendants), from a
166/// fresh scan. What a teardown reports as having escaped its tracker.
167pub 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    /// The processes assigned to the agent (its CLI, one per spawn): PID →
174    /// start time (Unix ms). Pruned as they exit, so a reused PID never
175    /// counts.
176    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    /// SIGKILL every member, leaves first, again until none is left (a
190    /// member may fork between the scan and the signal).
191    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
205/// One agent's best-effort tracker.
206pub 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        // Its start time, from a fresh scan: recorded with the PID so a later
219        // process reusing the PID is not taken for it.
220        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    /// The agent is gone: end what it left. Off the caller's thread, since a
265    /// scan takes tens of milliseconds and the caller may be async.
266    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    /// The tag finds a detached process (a double fork, so reparented to
301    /// init or launchd once its shell exits; `setsid` isn't on every Unix),
302    /// and `kill_tree` ends it.
303    #[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    /// A scan asked for "after now" sees a process spawned just before the
316    /// call, even while other threads keep the shared snapshot busy (a scan
317    /// that started before the spawn but finished after it doesn't count).
318    #[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    /// A root that exited is forgotten: a later process reusing its PID is
347    /// not taken for the agent's.
348    #[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    /// Descendants of an assigned root count even without the tag (a
363    /// process that cleared its environment), and processes of other blocks
364    /// don't.
365    #[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    /// Dropping the tracker (the agent's block is gone) ends what it left.
382    #[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}