1use std::collections::{HashMap, HashSet};
18use std::sync::{Arc, OnceLock};
19
20use parking_lot::Mutex;
21
22use super::{agent_started, new_tracker, TrackedProcess, TrackerHandle, TrackingConfidence};
23use crate::backend::mps;
24
25static GLOBAL: OnceLock<Arc<AgentProcessRegistry>> = OnceLock::new();
31
32pub fn set_global(registry: Arc<AgentProcessRegistry>) {
33 let _ = GLOBAL.set(registry);
34}
35
36pub fn global() -> Option<Arc<AgentProcessRegistry>> {
37 GLOBAL.get().cloned()
38}
39
40pub fn track_spawned(block_id: &str, pid: u32) {
47 track(block_id, pid, false);
48}
49
50fn track(block_id: &str, pid: u32, agent: bool) {
51 let Some(registry) = global() else { return };
52 if let Err(e) = registry.assign_kind(block_id, pid, agent) {
53 tracing::warn!(
54 block_id = %block_id,
55 pid = pid,
56 err = %e,
57 "[process-tracker] assign_process failed"
58 );
59 }
60}
61
62pub fn track_adopted(block_id: &str, pid: u32) {
70 if block_id.is_empty() {
71 return;
72 }
73 let Some(registry) = global() else { return };
74 if let Err(e) = registry.adopt(block_id, pid) {
75 tracing::warn!(block_id = %block_id, pid = pid, err = %e, "[process-tracker] adopt failed");
76 }
77}
78
79pub fn place_spawn(block_id: &str, cmd: &mut tokio::process::Command, create: bool) {
87 #[cfg(target_os = "linux")]
88 if let Some(target) = spawn_target(block_id, create) {
89 super::cgroup_linux::join_before_exec(cmd, target);
90 }
91 #[cfg(not(target_os = "linux"))]
92 let _ = (block_id, cmd, create);
93}
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub enum Join {
98 Agent,
101 Adopted,
104}
105
106pub fn spawn_tracked(block_id: &str, cmd: &mut tokio::process::Command, join: Join) -> std::io::Result<tokio::process::Child> {
113 place_spawn(block_id, cmd, join == Join::Agent);
114 #[cfg(windows)]
115 let suspended = start_suspended(block_id, cmd, join == Join::Agent);
116 let child = cmd.spawn()?;
117 if let Some(pid) = child.id() {
118 match join {
119 Join::Agent => track_spawned_agent(block_id, pid),
120 Join::Adopted => track_adopted(block_id, pid),
121 }
122 #[cfg(windows)]
125 if suspended {
126 if let Err(e) = agentmux_common::win32::resume_main_thread(pid) {
127 let mut child = child;
128 let _ = child.start_kill();
129 return Err(std::io::Error::other(format!("resume suspended child {pid}: {e}")));
130 }
131 }
132 }
133 Ok(child)
134}
135
136#[cfg(windows)]
140fn start_suspended(block_id: &str, cmd: &mut tokio::process::Command, create: bool) -> bool {
141 use windows_sys::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED};
142 let Some(registry) = global() else { return false };
143 if block_id.is_empty() {
144 return false;
145 }
146 let tracker = if create {
147 Some(registry.ensure_tracker_kind(block_id, true))
148 } else {
149 registry.inner.lock().get(block_id).map(|e| e.tracker.clone())
150 };
151 if !tracker.is_some_and(|t| t.confidence() == TrackingConfidence::High) {
152 return false;
153 }
154 cmd.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED);
155 true
156}
157
158pub fn place_pty_spawn(block_id: &str, cmd: &mut portable_pty::CommandBuilder) {
161 #[cfg(target_os = "linux")]
162 if let Some(target) = spawn_target(block_id, true) {
163 super::cgroup_linux::join_before_exec_pty(cmd, target);
164 }
165 #[cfg(not(target_os = "linux"))]
166 let _ = (block_id, cmd);
167}
168
169#[cfg(target_os = "linux")]
171fn spawn_target(block_id: &str, create: bool) -> Option<std::path::PathBuf> {
172 if block_id.is_empty() {
173 return None;
174 }
175 let registry = global()?;
176 let tracker = if create {
177 Some(registry.ensure_tracker_kind(block_id, true))
178 } else {
179 registry.inner.lock().get(block_id).map(|e| e.tracker.clone())
180 };
181 tracker?.spawn_target()
182}
183
184pub const SETTING_AGENT_BELOW_NORMAL_PRIORITY: &str = "agent:belownormalpriority";
188
189pub fn track_spawned_agent(block_id: &str, pid: u32) {
193 track(block_id, pid, true);
194 let Some(registry) = global() else { return };
195 let on = registry.agent_below_normal_priority();
196 if let Err(e) = registry.set_below_normal_priority(block_id, on) {
197 tracing::warn!(block_id = %block_id, err = %e, "[process-tracker] could not set job CPU priority");
198 }
199}
200
201#[derive(Debug, Clone)]
204pub struct BlockMembers {
205 pub block_id: String,
206 pub pids: Vec<u32>,
207 pub roots: HashSet<u32>,
209 pub confidence: TrackingConfidence,
210 pub cpu_time_ns: Option<u64>,
211}
212
213pub struct AgentProcessRegistry {
214 inner: Mutex<HashMap<String, RegistryEntry>>,
215 broker: Option<Arc<mps::Broker>>,
216 config: Mutex<Option<Arc<crate::backend::wconfig::ConfigState>>>,
219}
220
221struct RegistryEntry {
222 tracker: Arc<dyn TrackerHandle>,
223 last_pids: HashSet<u32>,
227 roots: HashSet<u32>,
231}
232
233impl AgentProcessRegistry {
234 pub fn new(broker: Option<Arc<mps::Broker>>) -> Self {
235 Self {
236 inner: Mutex::new(HashMap::new()),
237 broker,
238 config: Mutex::new(None),
239 }
240 }
241
242 pub fn set_config(&self, config: Arc<crate::backend::wconfig::ConfigState>) {
244 *self.config.lock() = Some(config);
245 }
246
247 pub fn agent_below_normal_priority(&self) -> bool {
250 self.config
251 .lock()
252 .as_ref()
253 .and_then(|c| c.get_settings().extra.get(SETTING_AGENT_BELOW_NORMAL_PRIORITY).and_then(|v| v.as_bool()))
254 .unwrap_or(true)
255 }
256
257 pub fn set_below_normal_priority(&self, block_id: &str, on: bool) -> Result<(), String> {
259 let tracker = self.ensure_tracker(block_id);
260 tracker.set_below_normal_priority(on)
261 }
262
263 pub fn ensure_tracker(&self, block_id: &str) -> Arc<dyn TrackerHandle> {
267 self.ensure_tracker_kind(block_id, false)
268 }
269
270 pub fn ensure_tracker_kind(&self, block_id: &str, agent: bool) -> Arc<dyn TrackerHandle> {
276 let mut map = self.inner.lock();
277 if let Some(entry) = map.get(block_id) {
278 return entry.tracker.clone();
279 }
280 let tracker = new_tracker(block_id, agent);
281 map.insert(
282 block_id.to_string(),
283 RegistryEntry {
284 tracker: tracker.clone(),
285 last_pids: HashSet::new(),
286 roots: HashSet::new(),
287 },
288 );
289 tracing::info!(
290 block_id = %block_id,
291 confidence = ?tracker.confidence(),
292 "[process-tracker] registered tracker"
293 );
294 tracker
295 }
296
297 pub fn assign_kind(&self, block_id: &str, pid: u32, agent: bool) -> Result<(), String> {
302 let tracker = self.ensure_tracker_kind(block_id, agent);
303 tracker.assign_process(pid)?;
304 if let Some(entry) = self.inner.lock().get_mut(block_id) {
305 entry.roots.insert(pid);
306 }
307 Ok(())
308 }
309
310 pub fn adopt(&self, block_id: &str, pid: u32) -> Result<bool, String> {
313 let tracker = match self.inner.lock().get(block_id) {
314 Some(e) => e.tracker.clone(),
315 None => return Ok(false),
316 };
317 tracker.assign_process(pid)?;
318 Ok(true)
319 }
320
321 pub fn remove(&self, block_id: &str) {
325 let mut map = self.inner.lock();
326 if map.remove(block_id).is_some() {
327 tracing::info!(block_id = %block_id, "[process-tracker] dropped tracker on pane close");
328 }
329 }
330
331 #[cfg(any(windows, test))]
338 pub fn stats(&self) -> (usize, usize) {
339 let map = self.inner.lock();
340 let pids: usize = map.values().map(|e| e.last_pids.len()).sum();
341 (map.len(), pids)
342 }
343
344 pub fn list_block(&self, block_id: &str) -> Vec<TrackedProcess> {
349 let (tracker, roots) = match self.inner.lock().get(block_id) {
350 Some(e) => (e.tracker.clone(), e.roots.clone()),
351 None => return Vec::new(),
352 };
353 agent_started(tracker.list_members(), &roots)
354 }
355
356 pub fn members_by_block(&self) -> Vec<BlockMembers> {
360 let entries: Vec<(String, Arc<dyn TrackerHandle>, HashSet<u32>)> = self
361 .inner
362 .lock()
363 .iter()
364 .map(|(id, e)| (id.clone(), e.tracker.clone(), e.roots.clone()))
365 .collect();
366 entries
367 .into_iter()
368 .map(|(block_id, tracker, roots)| BlockMembers {
369 pids: tracker.member_pids(),
370 cpu_time_ns: tracker.cpu_time_ns(),
371 confidence: tracker.confidence(),
372 block_id,
373 roots,
374 })
375 .collect()
376 }
377
378 pub fn confidence_of(&self, block_id: &str) -> TrackingConfidence {
381 self.inner
382 .lock()
383 .get(block_id)
384 .map(|e| e.tracker.confidence())
385 .unwrap_or(TrackingConfidence::None)
386 }
387
388 pub fn terminate(&self, block_id: &str) -> bool {
392 let tracker = self.inner.lock().get(block_id).map(|e| e.tracker.clone());
393 tracker.is_some_and(|t| t.terminate())
394 }
395
396 pub fn member_count(&self, block_id: &str) -> usize {
399 let tracker = self.inner.lock().get(block_id).map(|e| e.tracker.clone());
400 tracker.map_or(0, |t| t.member_count())
401 }
402
403 pub fn kill_tree(&self, block_id: &str) -> bool {
409 let tracker = self.inner.lock().get(block_id).map(|e| e.tracker.clone());
410 match tracker {
411 Some(t) => {
412 t.kill_tree();
413 true
414 }
415 None => false,
416 }
417 }
418
419 pub fn kill_pid(&self, block_id: &str, pid: u32) -> bool {
422 let tracker = self.inner.lock().get(block_id).map(|e| e.tracker.clone());
423 match tracker {
424 Some(t) => t.kill_pid(pid),
425 None => false,
426 }
427 }
428
429 pub fn poll_and_emit(&self) {
435 let mut map = self.inner.lock();
436 for (block_id, entry) in map.iter_mut() {
437 let current_members = agent_started(entry.tracker.list_members(), &entry.roots);
438 let current_pids: HashSet<u32> = current_members.iter().map(|p| p.pid).collect();
439
440 for added_pid in current_pids.difference(&entry.last_pids) {
441 if let Some(p) = current_members.iter().find(|p| p.pid == *added_pid) {
442 if let Some(broker) = &self.broker {
443 let added = crate::backend::mps_events::ProcessAddedEvent { block_id: block_id.clone(), process: p.clone() };
444 broker.publish_event::<crate::backend::mps_events::AgentProcessAdded>(&added, vec![format!("block:{}", block_id)], 0);
445 }
446 }
447 }
448 for removed_pid in entry.last_pids.difference(¤t_pids) {
449 if let Some(broker) = &self.broker {
450 let exited = crate::backend::mps_events::ProcessExitedEvent { block_id: block_id.clone(), pid: *removed_pid };
451 broker.publish_event::<crate::backend::mps_events::AgentProcessExited>(&exited, vec![format!("block:{}", block_id)], 0);
452 }
453 }
454
455 entry.last_pids = current_pids;
456 }
457 }
458
459}
460
461pub fn spawn_poller(registry: Arc<AgentProcessRegistry>) {
466 tokio::spawn(async move {
467 let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));
468 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
469 loop {
470 interval.tick().await;
471 let registry = Arc::clone(®istry);
476 if let Err(e) = tokio::task::spawn_blocking(move || registry.poll_and_emit()).await {
477 tracing::warn!(error = %e, "[process-tracker] poll task failed");
478 }
479 }
480 });
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486
487 #[test]
488 fn agent_cpu_priority_policy_defaults_on_and_follows_the_setting() {
489 let reg = AgentProcessRegistry::new(None);
490 assert!(reg.agent_below_normal_priority());
492
493 let config = Arc::new(crate::backend::wconfig::ConfigState::with_config(
494 crate::backend::wconfig::build_default_config(),
495 ));
496 reg.set_config(Arc::clone(&config));
497 assert!(reg.agent_below_normal_priority());
499
500 let mut settings = config.get_settings();
501 settings.extra.insert(SETTING_AGENT_BELOW_NORMAL_PRIORITY.to_string(), serde_json::json!(false));
502 config.update_settings(settings.clone());
503 assert!(!reg.agent_below_normal_priority(), "read live, not captured");
504
505 settings.extra.insert(SETTING_AGENT_BELOW_NORMAL_PRIORITY.to_string(), serde_json::json!(true));
506 config.update_settings(settings);
507 assert!(reg.agent_below_normal_priority());
508 }
509
510 #[test]
511 fn track_spawned_is_a_safe_no_op_without_a_global_registry() {
512 track_spawned("test-block-track-spawned-no-global", 999_999);
517 }
518
519 #[test]
520 fn stats_reflects_tracked_block_count_and_drops_on_remove() {
521 let registry = AgentProcessRegistry::new(None);
527 assert_eq!(registry.stats(), (0, 0), "empty registry");
528
529 registry.ensure_tracker("block-a");
530 assert_eq!(registry.stats(), (1, 0), "one block, no poll yet so no known pids");
531
532 registry.ensure_tracker("block-b");
533 assert_eq!(registry.stats().0, 2, "two distinct blocks tracked");
534
535 registry.ensure_tracker("block-a");
538 assert_eq!(registry.stats().0, 2, "re-registering an existing block does not grow the count");
539
540 registry.remove("block-a");
541 assert_eq!(registry.stats().0, 1, "removed block no longer counted");
542 }
543
544 #[test]
555 #[cfg(windows)]
556 fn ensure_tracker_and_assign_process_track_a_real_short_lived_child() {
557 let mut child = if cfg!(windows) {
569 std::process::Command::new("cmd")
570 .args(["/C", "ping -n 2 127.0.0.1 > nul & ping -n 10 127.0.0.1 > nul"])
571 .spawn()
572 } else {
573 std::process::Command::new("sh")
574 .args(["-c", "sleep 10"])
575 .spawn()
576 }
577 .expect("failed to spawn a disposable test child");
578 let pid = child.id();
579
580 let registry = AgentProcessRegistry::new(None);
581 registry
582 .assign_kind("test-block-real-spawn", pid, false)
583 .expect("assign should succeed for a live child we just spawned");
584
585 let raw = registry.ensure_tracker("test-block-real-spawn").list_members();
586 assert!(
587 raw.iter().any(|p| p.pid == pid),
588 "expected pid {pid} to be a job member after assign"
589 );
590
591 let is_ping = |p: &TrackedProcess| p.command.to_ascii_lowercase().ends_with("ping.exe");
595 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
596 let started = loop {
597 let listed = registry.list_block("test-block-real-spawn");
598 if listed.iter().any(is_ping) || std::time::Instant::now() > deadline {
599 break listed;
600 }
601 std::thread::sleep(std::time::Duration::from_millis(100));
602 };
603 assert!(
604 !started.iter().any(|p| p.pid == pid),
605 "the assigned root must not be listed as agent-started"
606 );
607 assert!(
608 started.iter().any(|p| is_ping(p) && p.parent_pid == Some(pid)),
609 "expected the shell's ping child in list_block, got {started:?}"
610 );
611
612 let _ = child.kill();
615 let _ = child.wait();
616 }
617
618 #[test]
619 fn adopt_never_creates_a_tracker() {
620 let registry = AgentProcessRegistry::new(None);
621 assert_eq!(registry.adopt("no-such-block", std::process::id()), Ok(false));
622 assert_eq!(registry.stats().0, 0, "adopt must not register a tracker");
623 }
624
625 #[test]
629 #[cfg(windows)]
630 fn an_adopted_process_is_listed_and_dies_with_the_agent() {
631 let spawn = || {
632 std::process::Command::new("cmd")
633 .args(["/C", "ping -n 30 127.0.0.1 > nul"])
634 .spawn()
635 .expect("failed to spawn a disposable test child")
636 };
637 let (mut agent, mut shell) = (spawn(), spawn());
638 let registry = AgentProcessRegistry::new(None);
639 registry.assign_kind("test-block-adopt", agent.id(), false).expect("assign the agent root");
640 assert_eq!(registry.adopt("test-block-adopt", shell.id()), Ok(true));
641
642 let listed = registry.list_block("test-block-adopt");
643 assert!(
644 listed.iter().any(|p| p.pid == shell.id()),
645 "an adopted shell is agent-started, not a root: {listed:?}"
646 );
647
648 registry.remove("test-block-adopt");
649 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
650 while shell.try_wait().ok().flatten().is_none() && std::time::Instant::now() < deadline {
651 std::thread::sleep(std::time::Duration::from_millis(50));
652 }
653 assert!(shell.try_wait().ok().flatten().is_some(), "releasing the agent's tracker must end the adopted shell");
654 let _ = agent.kill();
655 let _ = agent.wait();
656 }
657
658 #[tokio::test]
661 #[cfg(windows)]
662 async fn a_suspended_child_is_assigned_then_resumed_and_runs() {
663 use windows_sys::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED};
664 let tracker = super::super::windows::JobObjectTracker::new("test-suspended").unwrap();
665 let mut cmd = tokio::process::Command::new("cmd");
666 cmd.args(["/C", "exit 7"]).creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED);
667 let mut child = cmd.spawn().expect("spawn suspended");
668 let pid = child.id().unwrap();
669 tracker.assign_process(pid).unwrap();
670 assert!(tracker.list_members().iter().any(|p| p.pid == pid), "in the job before it runs");
671 agentmux_common::win32::resume_main_thread(pid).unwrap();
672 let status = tokio::time::timeout(std::time::Duration::from_secs(10), child.wait())
673 .await
674 .expect("the resumed child ran and exited")
675 .unwrap();
676 assert_eq!(status.code(), Some(7));
677 }
678
679 #[test]
682 #[cfg(windows)]
683 fn a_jobs_cpu_time_keeps_what_exited_members_used() {
684 let tracker = super::super::windows::JobObjectTracker::new("test-job-cpu").unwrap();
685 assert_eq!(tracker.cpu_time_ns(), Some(0), "an empty job has used nothing");
686 let mut child = std::process::Command::new("cmd")
689 .args(["/C", "for /L %i in (1,1,2000000) do @rem"])
690 .spawn()
691 .expect("spawn cmd");
692 tracker.assign_process(child.id()).unwrap();
693 let _ = child.wait();
694 let used = tracker.cpu_time_ns().unwrap();
695 assert!(used >= 300_000_000, "the exited member's CPU time is kept: {used} ns");
696 assert!(tracker.member_pids().is_empty(), "nothing is left running");
697 }
698
699 #[test]
702 #[cfg(windows)]
703 fn a_job_still_tracks_after_kill_tree() {
704 let spawn = || {
705 std::process::Command::new("cmd")
706 .args(["/C", "ping -n 30 127.0.0.1 > nul"])
707 .spawn()
708 .expect("failed to spawn a disposable test child")
709 };
710 let tracker = super::super::windows::JobObjectTracker::new("test-kill-keeps-job").unwrap();
711 let mut first = spawn();
712 tracker.assign_process(first.id()).unwrap();
713 tracker.kill_tree();
714 let _ = first.wait();
715 let mut second = spawn();
716 tracker.assign_process(second.id()).expect("the job still accepts processes");
717 assert!(tracker.list_members().iter().any(|p| p.pid == second.id()));
718 tracker.kill_tree();
719 let _ = second.wait();
720 }
721}