Skip to main content

a3s_box_runtime/vmm/
handler.rs

1//! ShimHandler — concrete VmHandler for a libkrun shim subprocess.
2
3pub use a3s_box_core::vmm::{VmHandler, VmMetrics, DEFAULT_SHUTDOWN_TIMEOUT_MS};
4
5use a3s_box_core::error::Result;
6use std::process::Child;
7use std::sync::Mutex;
8use sysinfo::{Pid, System};
9
10/// Handler for a running VM subprocess (shim process).
11///
12/// Provides lifecycle operations (stop, metrics, status) for a VM identified by PID.
13pub struct ShimHandler {
14    pid: u32,
15    /// Stable host-process identity captured when the handler is created.
16    pid_start_time: Option<u64>,
17    box_id: String,
18    /// Child process handle for proper lifecycle management.
19    /// When we spawn the process, we keep the Child to properly wait() on stop.
20    /// When we attach to an existing process, this is None.
21    process: Option<Child>,
22    /// Shared System instance for CPU metrics calculation across calls.
23    /// CPU usage requires comparing snapshots over time, so we must reuse the same System.
24    metrics_sys: Mutex<System>,
25    /// Exit code of the shim process, set when stop() collects the exit status.
26    exit_code: Option<i32>,
27}
28
29impl ShimHandler {
30    /// Create a handler for a spawned VM with process ownership.
31    ///
32    /// This constructor takes ownership of the Child process handle for proper
33    /// lifecycle management (clean shutdown with wait()).
34    pub fn from_child(process: Child, box_id: String) -> Self {
35        let pid = process.id();
36        Self {
37            pid,
38            pid_start_time: crate::process::pid_start_time(pid),
39            box_id,
40            process: Some(process),
41            metrics_sys: Mutex::new(System::new()),
42            exit_code: None,
43        }
44    }
45
46    /// Create a handler for an existing VM (attach mode).
47    ///
48    /// Used when reconnecting to a running box. We don't have a Child handle,
49    /// so we manage the process by PID only.
50    pub fn from_pid(pid: u32, box_id: String) -> Self {
51        Self {
52            pid,
53            pid_start_time: crate::process::pid_start_time(pid),
54            box_id,
55            process: None,
56            metrics_sys: Mutex::new(System::new()),
57            exit_code: None,
58        }
59    }
60
61    /// Get the box ID.
62    pub fn box_id(&self) -> &str {
63        &self.box_id
64    }
65}
66
67impl VmHandler for ShimHandler {
68    fn pid(&self) -> u32 {
69        self.pid
70    }
71
72    #[cfg(unix)]
73    fn stop(&mut self, signal: i32, timeout_ms: u64) -> Result<()> {
74        // Graceful shutdown: send configured signal first, wait, then SIGKILL if needed.
75        // This gives libkrun time to flush its virtio-blk buffers to disk.
76
77        // `try_wait_exit` may already have reaped an owned child. Never signal
78        // that old numeric PID after it becomes eligible for reuse.
79        if self.exit_code.is_some() {
80            self.process.take();
81            return Ok(());
82        }
83        if !self.is_running() {
84            return Ok(());
85        }
86
87        if let Some(mut process) = self.process.take() {
88            // Step 1: Send configured stop signal for graceful shutdown
89            let pid = process.id();
90            tracing::debug!(pid, box_id = %self.box_id, signal, "Sending stop signal to VM process");
91            unsafe {
92                libc::kill(pid as i32, signal);
93            }
94
95            // Step 2: Wait with timeout for process to exit
96            let start = std::time::Instant::now();
97            loop {
98                match process.try_wait() {
99                    Ok(Some(status)) => {
100                        tracing::debug!(pid, ?status, "VM process exited gracefully");
101                        self.exit_code = status.code();
102                        return Ok(());
103                    }
104                    Ok(None) => {
105                        // Still running, check timeout
106                        if start.elapsed().as_millis() > timeout_ms as u128 {
107                            tracing::warn!(
108                                pid,
109                                timeout_ms,
110                                "VM process did not exit gracefully, sending SIGKILL"
111                            );
112                            let _ = process.kill();
113                            if let Ok(status) = process.wait() {
114                                self.exit_code = status.code();
115                            }
116                            return Ok(());
117                        }
118                        // Brief sleep before checking again
119                        std::thread::sleep(std::time::Duration::from_millis(50));
120                    }
121                    Err(e) => {
122                        tracing::warn!(pid, error = %e, "Error checking process status, forcing kill");
123                        let _ = process.kill();
124                        let _ = process.wait();
125                        return Ok(());
126                    }
127                }
128            }
129        } else {
130            // Attached mode: use configured signal then SIGKILL with polling
131            tracing::debug!(pid = self.pid, box_id = %self.box_id, signal, "Sending stop signal to attached VM process");
132            unsafe {
133                libc::kill(self.pid as i32, signal);
134            }
135
136            // Poll for exit with timeout
137            let start = std::time::Instant::now();
138            loop {
139                let mut status: i32 = 0;
140                let result = unsafe { libc::waitpid(self.pid as i32, &mut status, libc::WNOHANG) };
141
142                if result > 0 {
143                    tracing::debug!(pid = self.pid, "VM process exited gracefully");
144                    return Ok(());
145                }
146                if result < 0 {
147                    // Error - process may not be our child (common in attached mode)
148                    if !self.is_running() {
149                        return Ok(()); // Already dead
150                    }
151                }
152
153                if start.elapsed().as_millis() > timeout_ms as u128 {
154                    if !self.is_running() {
155                        return Ok(());
156                    }
157                    tracing::warn!(
158                        pid = self.pid,
159                        timeout_ms,
160                        "VM process did not exit gracefully, sending SIGKILL"
161                    );
162                    unsafe {
163                        libc::kill(self.pid as i32, libc::SIGKILL);
164                    }
165                    return Ok(());
166                }
167
168                std::thread::sleep(std::time::Duration::from_millis(50));
169            }
170        }
171    }
172
173    #[cfg(windows)]
174    fn stop(&mut self, _signal: i32, timeout_ms: u64) -> Result<()> {
175        // Windows version: use Child::kill() or terminate process by PID
176        if let Some(mut process) = self.process.take() {
177            tracing::debug!(pid = self.pid, box_id = %self.box_id, "Terminating VM process");
178
179            // Try graceful wait first
180            let start = std::time::Instant::now();
181            loop {
182                match process.try_wait() {
183                    Ok(Some(status)) => {
184                        tracing::debug!(pid = self.pid, ?status, "VM process exited");
185                        self.exit_code = status.code();
186                        return Ok(());
187                    }
188                    Ok(None) => {
189                        if start.elapsed().as_millis() > timeout_ms as u128 {
190                            tracing::warn!(pid = self.pid, "VM process did not exit, forcing kill");
191                            let _ = process.kill();
192                            if let Ok(status) = process.wait() {
193                                self.exit_code = status.code();
194                            }
195                            return Ok(());
196                        }
197                        std::thread::sleep(std::time::Duration::from_millis(50));
198                    }
199                    Err(e) => {
200                        tracing::warn!(pid = self.pid, error = %e, "Error checking process, forcing kill");
201                        let _ = process.kill();
202                        let _ = process.wait();
203                        return Ok(());
204                    }
205                }
206            }
207        } else {
208            // Attached mode: just check if process exists
209            tracing::debug!(pid = self.pid, "Checking attached VM process");
210            let start = std::time::Instant::now();
211            while start.elapsed().as_millis() <= timeout_ms as u128 {
212                if !self.is_running() {
213                    return Ok(());
214                }
215                std::thread::sleep(std::time::Duration::from_millis(50));
216            }
217            tracing::warn!(
218                pid = self.pid,
219                "Attached VM process still running after timeout"
220            );
221            Ok(())
222        }
223    }
224
225    fn metrics(&self) -> VmMetrics {
226        if !self.is_running() {
227            return VmMetrics::default();
228        }
229
230        let pid = Pid::from_u32(self.pid);
231
232        // Use the shared System instance for stateful CPU tracking
233        let mut sys = match self.metrics_sys.lock() {
234            Ok(guard) => guard,
235            Err(e) => {
236                tracing::warn!(error = %e, "metrics_sys lock poisoned");
237                return VmMetrics::default();
238            }
239        };
240
241        // Refresh process info - this updates the internal state for delta calculation
242        sys.refresh_process(pid);
243
244        // Try to get process information
245        if let Some(proc_info) = sys.process(pid) {
246            return VmMetrics {
247                cpu_percent: Some(proc_info.cpu_usage()),
248                memory_bytes: Some(proc_info.memory()),
249            };
250        }
251
252        // Process not found or not running - return empty metrics
253        VmMetrics::default()
254    }
255
256    #[cfg(unix)]
257    fn is_running(&self) -> bool {
258        crate::process::is_process_alive_with_identity(self.pid, self.pid_start_time)
259    }
260
261    #[cfg(windows)]
262    fn is_running(&self) -> bool {
263        crate::process::is_process_alive_with_identity(self.pid, self.pid_start_time)
264    }
265
266    fn exit_code(&self) -> Option<i32> {
267        self.exit_code
268    }
269
270    fn try_wait_exit(&mut self) -> Result<Option<i32>> {
271        if self.exit_code.is_some() {
272            return Ok(self.exit_code);
273        }
274
275        let Some(process) = self.process.as_mut() else {
276            return Ok(None);
277        };
278
279        match process.try_wait() {
280            Ok(Some(status)) => {
281                self.exit_code = status.code();
282                Ok(self.exit_code)
283            }
284            Ok(None) => Ok(None),
285            Err(e) => Err(a3s_box_core::error::BoxError::ExecError(format!(
286                "Failed to poll VM process {}: {}",
287                self.pid, e
288            ))),
289        }
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn test_vm_metrics_default() {
299        let m = VmMetrics::default();
300        assert!(m.cpu_percent.is_none());
301        assert!(m.memory_bytes.is_none());
302    }
303
304    #[test]
305    fn test_vm_metrics_clone() {
306        let m = VmMetrics {
307            cpu_percent: Some(50.0),
308            memory_bytes: Some(1024 * 1024),
309        };
310        let cloned = m.clone();
311        assert_eq!(cloned.cpu_percent, Some(50.0));
312        assert_eq!(cloned.memory_bytes, Some(1024 * 1024));
313    }
314
315    #[test]
316    fn test_shim_handler_from_pid() {
317        let handler = ShimHandler::from_pid(12345, "box-abc".to_string());
318        assert_eq!(handler.pid(), 12345);
319        assert_eq!(handler.box_id(), "box-abc");
320        assert_eq!(handler.exit_code(), None);
321    }
322
323    #[cfg(target_os = "linux")]
324    #[test]
325    fn attached_handler_rejects_a_reused_pid_identity() {
326        let mut child = std::process::Command::new("sleep")
327            .arg("30")
328            .spawn()
329            .unwrap();
330        let mut handler = ShimHandler::from_pid(child.id(), "box-stale-pid".to_string());
331        handler.pid_start_time = Some(u64::MAX);
332
333        assert!(!handler.is_running());
334        let metrics = handler.metrics();
335        assert!(metrics.cpu_percent.is_none());
336        assert!(metrics.memory_bytes.is_none());
337        handler.stop(libc::SIGTERM, 0).unwrap();
338        assert!(child.try_wait().unwrap().is_none());
339
340        let _ = child.kill();
341        let _ = child.wait();
342    }
343
344    #[test]
345    fn test_shim_handler_try_wait_exit_captures_child_exit_code() {
346        let child = std::process::Command::new("sh")
347            .arg("-c")
348            .arg("exit 7")
349            .spawn()
350            .unwrap();
351        let mut handler = ShimHandler::from_child(child, "box-child-exit".to_string());
352
353        let exit_code = loop {
354            if let Some(code) = handler.try_wait_exit().unwrap() {
355                break code;
356            }
357            std::thread::sleep(std::time::Duration::from_millis(10));
358        };
359
360        assert_eq!(exit_code, 7);
361        assert_eq!(handler.exit_code(), Some(7));
362        assert_eq!(handler.try_wait_exit().unwrap(), Some(7));
363    }
364
365    #[cfg(unix)]
366    #[test]
367    fn test_shim_handler_stop_terminates_owned_child() {
368        let child = std::process::Command::new("sh")
369            .arg("-c")
370            .arg("trap 'exit 0' TERM; while :; do sleep 0.05; done")
371            .spawn()
372            .unwrap();
373        let mut handler = ShimHandler::from_child(child, "box-child-stop".to_string());
374
375        assert!(handler.is_running());
376        handler.stop(libc::SIGTERM, 2_000).unwrap();
377
378        assert!(!handler.is_running());
379        let exit_code = handler.exit_code();
380        assert!(matches!(exit_code, None | Some(0)));
381        assert_eq!(handler.try_wait_exit().unwrap(), exit_code);
382    }
383
384    #[cfg(unix)]
385    #[test]
386    fn test_shim_handler_stop_attached_missing_pid_is_ok() {
387        let mut handler = ShimHandler::from_pid(999_999_999, "missing".to_string());
388
389        handler.stop(libc::SIGTERM, 10).unwrap();
390
391        assert!(!handler.is_running());
392        assert_eq!(handler.exit_code(), None);
393    }
394
395    #[test]
396    fn test_shim_handler_is_running_nonexistent_pid() {
397        // PID 999999999 should not exist
398        let handler = ShimHandler::from_pid(999_999_999, "test".to_string());
399        assert!(!handler.is_running());
400    }
401
402    #[test]
403    fn test_shim_handler_metrics_nonexistent_pid() {
404        let handler = ShimHandler::from_pid(999_999_999, "test".to_string());
405        let m = handler.metrics();
406        // Non-existent process should return default metrics
407        assert!(m.cpu_percent.is_none() || m.cpu_percent == Some(0.0));
408    }
409
410    #[test]
411    fn test_shim_handler_is_running_current_process() {
412        // Current process PID should be running
413        let pid = std::process::id();
414        let handler = ShimHandler::from_pid(pid, "self".to_string());
415        assert!(handler.is_running());
416    }
417
418    #[test]
419    fn test_default_shutdown_timeout() {
420        assert_eq!(DEFAULT_SHUTDOWN_TIMEOUT_MS, 10_000);
421    }
422
423    #[test]
424    fn test_vm_metrics_debug() {
425        let m = VmMetrics {
426            cpu_percent: Some(25.5),
427            memory_bytes: Some(512),
428        };
429        let debug = format!("{:?}", m);
430        assert!(debug.contains("25.5"));
431        assert!(debug.contains("512"));
432    }
433}