1pub 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
86pub struct ShimHandler {
90 pid: u32,
91 pid_start_time: Option<u64>,
93 box_id: String,
94 process: Option<Child>,
98 metrics_sys: Mutex<System>,
101 exit_code: Option<i32>,
103}
104
105impl ShimHandler {
106 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 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 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 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 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 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 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 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 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 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 if !self.is_running() {
225 return Ok(()); }
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 if let Some(mut process) = self.process.take() {
253 tracing::debug!(pid = self.pid, box_id = %self.box_id, "Terminating VM process");
254
255 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 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 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 sys.refresh_process(pid);
316
317 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 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 #[cfg(windows)]
420 let child = std::process::Command::new("cmd")
421 .args(["/C", "exit 7"])
422 .spawn()
423 .unwrap();
424 #[cfg(not(windows))]
425 let child = std::process::Command::new("sh")
426 .arg("-c")
427 .arg("exit 7")
428 .spawn()
429 .unwrap();
430 let mut handler = ShimHandler::from_child(child, "box-child-exit".to_string());
431
432 let exit_code = loop {
433 if let Some(code) = handler.try_wait_exit().unwrap() {
434 break code;
435 }
436 std::thread::sleep(std::time::Duration::from_millis(10));
437 };
438
439 assert_eq!(exit_code, 7);
440 assert_eq!(handler.exit_code(), Some(7));
441 assert_eq!(handler.try_wait_exit().unwrap(), Some(7));
442 }
443
444 #[cfg(unix)]
445 #[test]
446 fn test_shim_handler_stop_terminates_owned_child() {
447 let child = std::process::Command::new("sh")
448 .arg("-c")
449 .arg("trap 'exit 0' TERM; while :; do sleep 0.05; done")
450 .spawn()
451 .unwrap();
452 let mut handler = ShimHandler::from_child(child, "box-child-stop".to_string());
453
454 assert!(handler.is_running());
455 handler.stop(libc::SIGTERM, 2_000).unwrap();
456
457 assert!(!handler.is_running());
458 let exit_code = handler.exit_code();
459 assert!(matches!(exit_code, None | Some(0)));
460 assert_eq!(handler.try_wait_exit().unwrap(), exit_code);
461 }
462
463 #[cfg(unix)]
464 #[test]
465 fn test_shim_handler_stop_attached_missing_pid_is_ok() {
466 let mut handler = ShimHandler::from_pid(999_999_999, "missing".to_string());
467
468 handler.stop(libc::SIGTERM, 10).unwrap();
469
470 assert!(!handler.is_running());
471 assert_eq!(handler.exit_code(), None);
472 }
473
474 #[cfg(windows)]
475 #[test]
476 fn test_shim_handler_stop_terminates_attached_child() {
477 let mut child = std::process::Command::new("powershell.exe")
478 .args(["-NoProfile", "-Command", "Start-Sleep -Seconds 30"])
479 .spawn()
480 .unwrap();
481 let mut handler = ShimHandler::from_pid(child.id(), "box-attached-stop".to_string());
482
483 assert!(handler.is_running());
484 let stop_result = handler.stop(15, 0);
485 let exited = child.try_wait().unwrap().is_some();
486 if !exited {
487 let _ = child.kill();
488 let _ = child.wait();
489 }
490
491 stop_result.unwrap();
492 assert!(exited, "attached Windows process survived handler.stop()");
493 assert!(!handler.is_running());
494 }
495
496 #[cfg(windows)]
497 #[test]
498 fn test_shim_handler_stop_allows_attached_child_to_exit_gracefully() {
499 let mut child = std::process::Command::new("powershell.exe")
500 .args([
501 "-NoProfile",
502 "-Command",
503 "Start-Sleep -Milliseconds 150; exit 0",
504 ])
505 .spawn()
506 .unwrap();
507 let mut handler = ShimHandler::from_pid(child.id(), "box-attached-graceful".to_string());
508
509 handler.stop(15, 2_000).unwrap();
510 let status = child.wait().unwrap();
511
512 assert!(status.success(), "graceful child was force-terminated");
513 assert!(!handler.is_running());
514 }
515
516 #[test]
517 fn test_shim_handler_is_running_nonexistent_pid() {
518 let handler = ShimHandler::from_pid(999_999_999, "test".to_string());
520 assert!(!handler.is_running());
521 }
522
523 #[test]
524 fn test_shim_handler_metrics_nonexistent_pid() {
525 let handler = ShimHandler::from_pid(999_999_999, "test".to_string());
526 let m = handler.metrics();
527 assert!(m.cpu_percent.is_none() || m.cpu_percent == Some(0.0));
529 }
530
531 #[test]
532 fn test_shim_handler_is_running_current_process() {
533 let pid = std::process::id();
535 let handler = ShimHandler::from_pid(pid, "self".to_string());
536 assert!(handler.is_running());
537 }
538
539 #[test]
540 fn test_default_shutdown_timeout() {
541 assert_eq!(DEFAULT_SHUTDOWN_TIMEOUT_MS, 10_000);
542 }
543
544 #[test]
545 fn test_vm_metrics_debug() {
546 let m = VmMetrics {
547 cpu_percent: Some(25.5),
548 memory_bytes: Some(512),
549 };
550 let debug = format!("{:?}", m);
551 assert!(debug.contains("25.5"));
552 assert!(debug.contains("512"));
553 }
554}