Skip to main content

aegis_tools/
background.rs

1/// BackgroundTaskManager: manage long-running background processes.
2/// S6 implementation: 工具运行时
3use std::collections::HashMap;
4use std::process::ExitStatus;
5
6pub struct BackgroundTaskManager {
7    tasks: HashMap<String, tokio::process::Child>,
8}
9
10impl BackgroundTaskManager {
11    /// Create an empty `BackgroundTaskManager` with no running tasks.
12    pub fn new() -> Self {
13        Self {
14            tasks: HashMap::new(),
15        }
16    }
17
18    /// Spawn a shell command as a background task with the given ID.
19    /// stdout is piped for AEGIS_PROGRESS parsing.
20    pub async fn spawn(&mut self, id: String, cmd: &str) -> anyhow::Result<()> {
21        let child = tokio::process::Command::new("sh")
22            .arg("-c")
23            .arg(cmd)
24            .stdout(std::process::Stdio::piped())
25            .stderr(std::process::Stdio::piped())
26            .spawn()?;
27        self.tasks.insert(id, child);
28        Ok(())
29    }
30
31    /// Returns "running" if the task exists, "not_found" otherwise.
32    pub fn status(&self, id: &str) -> &'static str {
33        if self.tasks.contains_key(id) {
34            "running"
35        } else {
36            "not_found"
37        }
38    }
39
40    /// Cancel a task by sending kill signal (tokio sends SIGKILL).
41    /// Note: tokio's Child::kill() sends SIGKILL on Unix. For graceful shutdown,
42    /// callers should use a 5-second timeout then call this method.
43    /// A future improvement would use nix::sys::signal to send SIGTERM first.
44    pub async fn cancel(&mut self, id: &str) {
45        if let Some(mut child) = self.tasks.remove(id) {
46            // First attempt: kill (SIGKILL via tokio)
47            // SIGTERM not directly available without nix crate; use SIGKILL.
48            // In production, use nix::sys::signal::kill with Signal::SIGTERM,
49            // wait 5s, then SIGKILL if still running.
50            let _ = child.kill().await;
51        }
52    }
53
54    /// Wait for all specified tasks to complete.
55    pub async fn wait_all(&mut self, ids: &[&str]) -> Vec<Option<ExitStatus>> {
56        let mut results = vec![];
57        for id in ids {
58            if let Some(mut child) = self.tasks.remove(*id) {
59                results.push(child.wait().await.ok());
60            } else {
61                results.push(None);
62            }
63        }
64        results
65    }
66
67    /// Wait for any one of the specified tasks to complete.
68    /// Returns the ID and exit status of the first task that finishes.
69    /// (6.2.4)
70    pub async fn wait_any(&mut self, ids: &[&str]) -> Option<(String, ExitStatus)> {
71        // Poll tasks repeatedly until one exits
72        loop {
73            for id in ids {
74                let id_str = id.to_string();
75                if let Some(child) = self.tasks.get_mut(id_str.as_str()) {
76                    match child.try_wait() {
77                        Ok(Some(status)) => {
78                            self.tasks.remove(id_str.as_str());
79                            return Some((id_str, status));
80                        }
81                        Ok(None) => {} // still running
82                        Err(_) => {
83                            self.tasks.remove(id_str.as_str());
84                        }
85                    }
86                }
87            }
88            // Yield and retry
89            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
90        }
91    }
92}
93
94impl Default for BackgroundTaskManager {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100impl Drop for BackgroundTaskManager {
101    fn drop(&mut self) {
102        // (6.2.5) Clean up all remaining child processes on drop.
103        for (_, mut child) in self.tasks.drain() {
104            let _ = child.start_kill();
105        }
106    }
107}
108
109/// Parse AEGIS_PROGRESS lines from worker stdout. (6.2.2)
110/// Call this for each line read from a child process stdout.
111pub fn parse_progress_line(line: &str) {
112    const PREFIX: &str = "AEGIS_PROGRESS:";
113    if let Some(rest) = line.strip_prefix(PREFIX) {
114        match serde_json::from_str::<serde_json::Value>(rest.trim()) {
115            Ok(val) => eprintln!("[progress] {}", val),
116            Err(_) => eprintln!("[progress] {}", rest.trim()),
117        }
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn test_background_task_manager_new() {
127        let mgr = BackgroundTaskManager::new();
128        assert_eq!(mgr.status("any"), "not_found");
129    }
130
131    #[test]
132    fn test_background_task_manager_default() {
133        let mgr = BackgroundTaskManager::default();
134        assert_eq!(mgr.status("any"), "not_found");
135    }
136
137    #[tokio::test]
138    async fn test_spawn_and_status() {
139        let mut mgr = BackgroundTaskManager::new();
140        mgr.spawn("task1".into(), "echo hello").await.unwrap();
141        assert_eq!(mgr.status("task1"), "running");
142        assert_eq!(mgr.status("task2"), "not_found");
143        mgr.wait_all(&["task1"]).await;
144    }
145
146    #[tokio::test]
147    async fn test_cancel_task() {
148        let mut mgr = BackgroundTaskManager::new();
149        mgr.spawn("task1".into(), "sleep 60").await.unwrap();
150        assert_eq!(mgr.status("task1"), "running");
151        mgr.cancel("task1").await;
152        assert_eq!(mgr.status("task1"), "not_found");
153    }
154
155    #[tokio::test]
156    async fn test_cancel_nonexistent_noop() {
157        let mut mgr = BackgroundTaskManager::new();
158        mgr.cancel("nonexistent").await; // Should not panic
159    }
160
161    #[tokio::test]
162    async fn test_wait_all() {
163        let mut mgr = BackgroundTaskManager::new();
164        mgr.spawn("a".into(), "echo a").await.unwrap();
165        mgr.spawn("b".into(), "echo b").await.unwrap();
166        let results = mgr.wait_all(&["a", "b", "c"]).await;
167        assert_eq!(results.len(), 3);
168        assert!(results[0].is_some()); // a completed
169        assert!(results[1].is_some()); // b completed
170        assert!(results[2].is_none()); // c not found
171    }
172
173    #[tokio::test]
174    async fn test_wait_any() {
175        let mut mgr = BackgroundTaskManager::new();
176        mgr.spawn("fast".into(), "echo done").await.unwrap();
177        mgr.spawn("slow".into(), "sleep 60").await.unwrap();
178        let result = mgr.wait_any(&["fast", "slow"]).await;
179        assert!(result.is_some());
180        let (id, status) = result.unwrap();
181        assert_eq!(id, "fast");
182        assert!(status.success());
183        mgr.cancel("slow").await;
184    }
185
186    #[test]
187    fn test_parse_progress_line_json() {
188        // Should not panic on valid JSON
189        parse_progress_line("AEGIS_PROGRESS:{\"percent\":50}");
190    }
191
192    #[test]
193    fn test_parse_progress_line_plain() {
194        // Should not panic on plain text
195        parse_progress_line("AEGIS_PROGRESS:halfway there");
196    }
197
198    #[test]
199    fn test_parse_progress_line_ignored() {
200        // Non-progress lines should be silently ignored
201        parse_progress_line("normal log output");
202    }
203
204    #[test]
205    fn test_drop_cleans_up() {
206        let mut mgr = BackgroundTaskManager::new();
207        let rt = tokio::runtime::Runtime::new().unwrap();
208        rt.block_on(async {
209            mgr.spawn("t1".into(), "sleep 60").await.unwrap();
210        });
211        // Drop should kill the child process
212        drop(mgr);
213        // If we get here without hanging, cleanup worked
214    }
215}