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#[cfg(windows)]
11fn wait_then_terminate_attached_process(pid: u32, box_id: &str, timeout_ms: u64) -> Result<()> {
12    use a3s_box_core::error::BoxError;
13    use windows_sys::Win32::Foundation::{CloseHandle, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT};
14    use windows_sys::Win32::System::Threading::{
15        OpenProcess, TerminateProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
16    };
17
18    const TERMINATION_WAIT_MS: u32 = 5_000;
19    const MAX_FINITE_WAIT_MS: u64 = (u32::MAX - 1) as u64;
20
21    let handle = unsafe { OpenProcess(PROCESS_TERMINATE | PROCESS_SYNCHRONIZE, 0, pid) };
22    if handle == 0 {
23        let error = std::io::Error::last_os_error();
24        if !crate::process::is_process_alive(pid) {
25            return Ok(());
26        }
27        return Err(BoxError::ExecError(format!(
28            "Failed to open attached VM process {pid} for box {box_id}: {error}"
29        )));
30    }
31
32    let result = (|| {
33        let graceful_wait_ms = timeout_ms.min(MAX_FINITE_WAIT_MS) as u32;
34        match unsafe { WaitForSingleObject(handle, graceful_wait_ms) } {
35            WAIT_OBJECT_0 => return Ok(()),
36            WAIT_TIMEOUT => tracing::warn!(
37                pid,
38                box_id,
39                timeout_ms,
40                "Attached VM process did not exit after the guest stop request; forcing termination"
41            ),
42            WAIT_FAILED => tracing::warn!(
43                pid,
44                box_id,
45                error = %std::io::Error::last_os_error(),
46                "Failed while waiting for attached VM process; forcing termination"
47            ),
48            status => tracing::warn!(
49                pid,
50                box_id,
51                status,
52                "Unexpected attached VM wait status; forcing termination"
53            ),
54        }
55
56        if unsafe { TerminateProcess(handle, 1) } == 0 {
57            let error = std::io::Error::last_os_error();
58            if unsafe { WaitForSingleObject(handle, 0) } == WAIT_OBJECT_0 {
59                return Ok(());
60            }
61            return Err(BoxError::ExecError(format!(
62                "Failed to terminate attached VM process {pid} for box {box_id}: {error}"
63            )));
64        }
65
66        match unsafe { WaitForSingleObject(handle, TERMINATION_WAIT_MS) } {
67            WAIT_OBJECT_0 => Ok(()),
68            WAIT_TIMEOUT => Err(BoxError::ExecError(format!(
69                "Attached VM process {pid} for box {box_id} did not exit after termination"
70            ))),
71            WAIT_FAILED => Err(BoxError::ExecError(format!(
72                "Failed to wait for attached VM process {pid} for box {box_id}: {}",
73                std::io::Error::last_os_error()
74            ))),
75            status => Err(BoxError::ExecError(format!(
76                "Unexpected wait status {status} for attached VM process {pid} of box {box_id}"
77            ))),
78        }
79    })();
80    unsafe {
81        CloseHandle(handle);
82    }
83    result
84}
85
86/// Handler for a running VM subprocess (shim process).
87///
88/// Provides lifecycle operations (stop, metrics, status) for a VM identified by PID.
89pub struct ShimHandler {
90    pid: u32,
91    /// Stable host-process identity captured when the handler is created.
92    pid_start_time: Option<u64>,
93    box_id: String,
94    /// Child process handle for proper lifecycle management.
95    /// When we spawn the process, we keep the Child to properly wait() on stop.
96    /// When we attach to an existing process, this is None.
97    process: Option<Child>,
98    /// Shared System instance for CPU metrics calculation across calls.
99    /// CPU usage requires comparing snapshots over time, so we must reuse the same System.
100    metrics_sys: Mutex<System>,
101    /// Exit code of the shim process, set when stop() collects the exit status.
102    exit_code: Option<i32>,
103}
104
105impl ShimHandler {
106    /// Create a handler for a spawned VM with process ownership.
107    ///
108    /// This constructor takes ownership of the Child process handle for proper
109    /// lifecycle management (clean shutdown with wait()).
110    pub fn from_child(process: Child, box_id: String) -> Self {
111        let pid = process.id();
112        Self {
113            pid,
114            pid_start_time: crate::process::pid_start_time(pid),
115            box_id,
116            process: Some(process),
117            metrics_sys: Mutex::new(System::new()),
118            exit_code: None,
119        }
120    }
121
122    /// Create a handler for an existing VM (attach mode).
123    ///
124    /// Used when reconnecting to a running box. We don't have a Child handle,
125    /// so we manage the process by PID only.
126    pub fn from_pid(pid: u32, box_id: String) -> Self {
127        Self {
128            pid,
129            pid_start_time: crate::process::pid_start_time(pid),
130            box_id,
131            process: None,
132            metrics_sys: Mutex::new(System::new()),
133            exit_code: None,
134        }
135    }
136
137    /// Get the box ID.
138    pub fn box_id(&self) -> &str {
139        &self.box_id
140    }
141}
142
143impl VmHandler for ShimHandler {
144    fn pid(&self) -> u32 {
145        self.pid
146    }
147
148    #[cfg(unix)]
149    fn stop(&mut self, signal: i32, timeout_ms: u64) -> Result<()> {
150        // Graceful shutdown: send configured signal first, wait, then SIGKILL if needed.
151        // This gives libkrun time to flush its virtio-blk buffers to disk.
152
153        // `try_wait_exit` may already have reaped an owned child. Never signal
154        // that old numeric PID after it becomes eligible for reuse.
155        if self.exit_code.is_some() {
156            self.process.take();
157            return Ok(());
158        }
159        if !self.is_running() {
160            return Ok(());
161        }
162
163        if let Some(mut process) = self.process.take() {
164            // Step 1: Send configured stop signal for graceful shutdown
165            let pid = process.id();
166            tracing::debug!(pid, box_id = %self.box_id, signal, "Sending stop signal to VM process");
167            unsafe {
168                libc::kill(pid as i32, signal);
169            }
170
171            // Step 2: Wait with timeout for process to exit
172            let start = std::time::Instant::now();
173            loop {
174                match process.try_wait() {
175                    Ok(Some(status)) => {
176                        tracing::debug!(pid, ?status, "VM process exited gracefully");
177                        self.exit_code = status.code();
178                        return Ok(());
179                    }
180                    Ok(None) => {
181                        // Still running, check timeout
182                        if start.elapsed().as_millis() > timeout_ms as u128 {
183                            tracing::warn!(
184                                pid,
185                                timeout_ms,
186                                "VM process did not exit gracefully, sending SIGKILL"
187                            );
188                            let _ = process.kill();
189                            if let Ok(status) = process.wait() {
190                                self.exit_code = status.code();
191                            }
192                            return Ok(());
193                        }
194                        // Brief sleep before checking again
195                        std::thread::sleep(std::time::Duration::from_millis(50));
196                    }
197                    Err(e) => {
198                        tracing::warn!(pid, error = %e, "Error checking process status, forcing kill");
199                        let _ = process.kill();
200                        let _ = process.wait();
201                        return Ok(());
202                    }
203                }
204            }
205        } else {
206            // Attached mode: use configured signal then SIGKILL with polling
207            tracing::debug!(pid = self.pid, box_id = %self.box_id, signal, "Sending stop signal to attached VM process");
208            unsafe {
209                libc::kill(self.pid as i32, signal);
210            }
211
212            // Poll for exit with timeout
213            let start = std::time::Instant::now();
214            loop {
215                let mut status: i32 = 0;
216                let result = unsafe { libc::waitpid(self.pid as i32, &mut status, libc::WNOHANG) };
217
218                if result > 0 {
219                    tracing::debug!(pid = self.pid, "VM process exited gracefully");
220                    return Ok(());
221                }
222                if result < 0 {
223                    // Error - process may not be our child (common in attached mode)
224                    if !self.is_running() {
225                        return Ok(()); // Already dead
226                    }
227                }
228
229                if start.elapsed().as_millis() > timeout_ms as u128 {
230                    if !self.is_running() {
231                        return Ok(());
232                    }
233                    tracing::warn!(
234                        pid = self.pid,
235                        timeout_ms,
236                        "VM process did not exit gracefully, sending SIGKILL"
237                    );
238                    unsafe {
239                        libc::kill(self.pid as i32, libc::SIGKILL);
240                    }
241                    return Ok(());
242                }
243
244                std::thread::sleep(std::time::Duration::from_millis(50));
245            }
246        }
247    }
248
249    #[cfg(windows)]
250    fn stop(&mut self, _signal: i32, timeout_ms: u64) -> Result<()> {
251        // Windows version: use Child::kill() or terminate process by PID
252        if let Some(mut process) = self.process.take() {
253            tracing::debug!(pid = self.pid, box_id = %self.box_id, "Terminating VM process");
254
255            // Try graceful wait first
256            let start = std::time::Instant::now();
257            loop {
258                match process.try_wait() {
259                    Ok(Some(status)) => {
260                        tracing::debug!(pid = self.pid, ?status, "VM process exited");
261                        self.exit_code = status.code();
262                        return Ok(());
263                    }
264                    Ok(None) => {
265                        if start.elapsed().as_millis() > timeout_ms as u128 {
266                            tracing::warn!(pid = self.pid, "VM process did not exit, forcing kill");
267                            let _ = process.kill();
268                            if let Ok(status) = process.wait() {
269                                self.exit_code = status.code();
270                            }
271                            return Ok(());
272                        }
273                        std::thread::sleep(std::time::Duration::from_millis(50));
274                    }
275                    Err(e) => {
276                        tracing::warn!(pid = self.pid, error = %e, "Error checking process, forcing kill");
277                        let _ = process.kill();
278                        let _ = process.wait();
279                        return Ok(());
280                    }
281                }
282            }
283        } else {
284            if !self.is_running() {
285                return Ok(());
286            }
287
288            // Every CLI invocation reconstructs the managed runtime from its
289            // durable PID, so normal `stop` reaches this attached path. The VM
290            // manager has already sent the guest stop request. Hold a handle to
291            // this exact process object while waiting, then force-terminate it
292            // on timeout so PID reuse cannot target an unrelated process.
293            tracing::debug!(pid = self.pid, box_id = %self.box_id, timeout_ms, "Waiting for attached VM process to stop");
294            wait_then_terminate_attached_process(self.pid, &self.box_id, timeout_ms)
295        }
296    }
297
298    fn metrics(&self) -> VmMetrics {
299        if !self.is_running() {
300            return VmMetrics::default();
301        }
302
303        let pid = Pid::from_u32(self.pid);
304
305        // Use the shared System instance for stateful CPU tracking
306        let mut sys = match self.metrics_sys.lock() {
307            Ok(guard) => guard,
308            Err(e) => {
309                tracing::warn!(error = %e, "metrics_sys lock poisoned");
310                return VmMetrics::default();
311            }
312        };
313
314        // Refresh process info - this updates the internal state for delta calculation
315        sys.refresh_process(pid);
316
317        // Try to get process information
318        if let Some(proc_info) = sys.process(pid) {
319            return VmMetrics {
320                cpu_percent: Some(proc_info.cpu_usage()),
321                memory_bytes: Some(proc_info.memory()),
322            };
323        }
324
325        // Process not found or not running - return empty metrics
326        VmMetrics::default()
327    }
328
329    #[cfg(unix)]
330    fn is_running(&self) -> bool {
331        crate::process::is_process_alive_with_identity(self.pid, self.pid_start_time)
332    }
333
334    #[cfg(windows)]
335    fn is_running(&self) -> bool {
336        crate::process::is_process_alive_with_identity(self.pid, self.pid_start_time)
337    }
338
339    fn exit_code(&self) -> Option<i32> {
340        self.exit_code
341    }
342
343    fn try_wait_exit(&mut self) -> Result<Option<i32>> {
344        if self.exit_code.is_some() {
345            return Ok(self.exit_code);
346        }
347
348        let Some(process) = self.process.as_mut() else {
349            return Ok(None);
350        };
351
352        match process.try_wait() {
353            Ok(Some(status)) => {
354                self.exit_code = status.code();
355                Ok(self.exit_code)
356            }
357            Ok(None) => Ok(None),
358            Err(e) => Err(a3s_box_core::error::BoxError::ExecError(format!(
359                "Failed to poll VM process {}: {}",
360                self.pid, e
361            ))),
362        }
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn test_vm_metrics_default() {
372        let m = VmMetrics::default();
373        assert!(m.cpu_percent.is_none());
374        assert!(m.memory_bytes.is_none());
375    }
376
377    #[test]
378    fn test_vm_metrics_clone() {
379        let m = VmMetrics {
380            cpu_percent: Some(50.0),
381            memory_bytes: Some(1024 * 1024),
382        };
383        let cloned = m.clone();
384        assert_eq!(cloned.cpu_percent, Some(50.0));
385        assert_eq!(cloned.memory_bytes, Some(1024 * 1024));
386    }
387
388    #[test]
389    fn test_shim_handler_from_pid() {
390        let handler = ShimHandler::from_pid(12345, "box-abc".to_string());
391        assert_eq!(handler.pid(), 12345);
392        assert_eq!(handler.box_id(), "box-abc");
393        assert_eq!(handler.exit_code(), None);
394    }
395
396    #[cfg(target_os = "linux")]
397    #[test]
398    fn attached_handler_rejects_a_reused_pid_identity() {
399        let mut child = std::process::Command::new("sleep")
400            .arg("30")
401            .spawn()
402            .unwrap();
403        let mut handler = ShimHandler::from_pid(child.id(), "box-stale-pid".to_string());
404        handler.pid_start_time = Some(u64::MAX);
405
406        assert!(!handler.is_running());
407        let metrics = handler.metrics();
408        assert!(metrics.cpu_percent.is_none());
409        assert!(metrics.memory_bytes.is_none());
410        handler.stop(libc::SIGTERM, 0).unwrap();
411        assert!(child.try_wait().unwrap().is_none());
412
413        let _ = child.kill();
414        let _ = child.wait();
415    }
416
417    #[test]
418    fn test_shim_handler_try_wait_exit_captures_child_exit_code() {
419        let child = std::process::Command::new("sh")
420            .arg("-c")
421            .arg("exit 7")
422            .spawn()
423            .unwrap();
424        let mut handler = ShimHandler::from_child(child, "box-child-exit".to_string());
425
426        let exit_code = loop {
427            if let Some(code) = handler.try_wait_exit().unwrap() {
428                break code;
429            }
430            std::thread::sleep(std::time::Duration::from_millis(10));
431        };
432
433        assert_eq!(exit_code, 7);
434        assert_eq!(handler.exit_code(), Some(7));
435        assert_eq!(handler.try_wait_exit().unwrap(), Some(7));
436    }
437
438    #[cfg(unix)]
439    #[test]
440    fn test_shim_handler_stop_terminates_owned_child() {
441        let child = std::process::Command::new("sh")
442            .arg("-c")
443            .arg("trap 'exit 0' TERM; while :; do sleep 0.05; done")
444            .spawn()
445            .unwrap();
446        let mut handler = ShimHandler::from_child(child, "box-child-stop".to_string());
447
448        assert!(handler.is_running());
449        handler.stop(libc::SIGTERM, 2_000).unwrap();
450
451        assert!(!handler.is_running());
452        let exit_code = handler.exit_code();
453        assert!(matches!(exit_code, None | Some(0)));
454        assert_eq!(handler.try_wait_exit().unwrap(), exit_code);
455    }
456
457    #[cfg(unix)]
458    #[test]
459    fn test_shim_handler_stop_attached_missing_pid_is_ok() {
460        let mut handler = ShimHandler::from_pid(999_999_999, "missing".to_string());
461
462        handler.stop(libc::SIGTERM, 10).unwrap();
463
464        assert!(!handler.is_running());
465        assert_eq!(handler.exit_code(), None);
466    }
467
468    #[cfg(windows)]
469    #[test]
470    fn test_shim_handler_stop_terminates_attached_child() {
471        let mut child = std::process::Command::new("powershell.exe")
472            .args(["-NoProfile", "-Command", "Start-Sleep -Seconds 30"])
473            .spawn()
474            .unwrap();
475        let mut handler = ShimHandler::from_pid(child.id(), "box-attached-stop".to_string());
476
477        assert!(handler.is_running());
478        let stop_result = handler.stop(15, 0);
479        let exited = child.try_wait().unwrap().is_some();
480        if !exited {
481            let _ = child.kill();
482            let _ = child.wait();
483        }
484
485        stop_result.unwrap();
486        assert!(exited, "attached Windows process survived handler.stop()");
487        assert!(!handler.is_running());
488    }
489
490    #[cfg(windows)]
491    #[test]
492    fn test_shim_handler_stop_allows_attached_child_to_exit_gracefully() {
493        let mut child = std::process::Command::new("powershell.exe")
494            .args([
495                "-NoProfile",
496                "-Command",
497                "Start-Sleep -Milliseconds 150; exit 0",
498            ])
499            .spawn()
500            .unwrap();
501        let mut handler = ShimHandler::from_pid(child.id(), "box-attached-graceful".to_string());
502
503        handler.stop(15, 2_000).unwrap();
504        let status = child.wait().unwrap();
505
506        assert!(status.success(), "graceful child was force-terminated");
507        assert!(!handler.is_running());
508    }
509
510    #[test]
511    fn test_shim_handler_is_running_nonexistent_pid() {
512        // PID 999999999 should not exist
513        let handler = ShimHandler::from_pid(999_999_999, "test".to_string());
514        assert!(!handler.is_running());
515    }
516
517    #[test]
518    fn test_shim_handler_metrics_nonexistent_pid() {
519        let handler = ShimHandler::from_pid(999_999_999, "test".to_string());
520        let m = handler.metrics();
521        // Non-existent process should return default metrics
522        assert!(m.cpu_percent.is_none() || m.cpu_percent == Some(0.0));
523    }
524
525    #[test]
526    fn test_shim_handler_is_running_current_process() {
527        // Current process PID should be running
528        let pid = std::process::id();
529        let handler = ShimHandler::from_pid(pid, "self".to_string());
530        assert!(handler.is_running());
531    }
532
533    #[test]
534    fn test_default_shutdown_timeout() {
535        assert_eq!(DEFAULT_SHUTDOWN_TIMEOUT_MS, 10_000);
536    }
537
538    #[test]
539    fn test_vm_metrics_debug() {
540        let m = VmMetrics {
541            cpu_percent: Some(25.5),
542            memory_bytes: Some(512),
543        };
544        let debug = format!("{:?}", m);
545        assert!(debug.contains("25.5"));
546        assert!(debug.contains("512"));
547    }
548}