use std::process::Child;
use std::thread;
use std::time::{Duration, Instant};
use crate::error::{VmRuntimeError, VmRuntimeResult};
#[derive(Debug, Clone, Copy)]
pub struct ShutdownConfig {
pub grace_period: Duration,
pub poll_interval: Duration,
}
impl Default for ShutdownConfig {
fn default() -> Self {
Self {
grace_period: Duration::from_millis(2_000),
poll_interval: Duration::from_millis(50),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShutdownOutcome {
Graceful,
Forced,
AlreadyExited,
}
pub fn graceful_shutdown(
child: &mut Child,
config: &ShutdownConfig,
) -> VmRuntimeResult<ShutdownOutcome> {
if child
.try_wait()
.map_err(|e| VmRuntimeError::Shutdown(format!("try_wait failed: {e}")))?
.is_some()
{
return Ok(ShutdownOutcome::AlreadyExited);
}
#[cfg(target_os = "linux")]
{
send_sigterm(child)?;
}
#[cfg(not(target_os = "linux"))]
{
let _ = child;
return Err(VmRuntimeError::Unsupported(
"graceful_shutdown requires linux for SIGTERM delivery".to_owned(),
));
}
#[cfg(target_os = "linux")]
{
let deadline = Instant::now() + config.grace_period;
loop {
if child
.try_wait()
.map_err(|e| VmRuntimeError::Shutdown(format!("try_wait failed: {e}")))?
.is_some()
{
return Ok(ShutdownOutcome::Graceful);
}
let now = Instant::now();
if now >= deadline {
break;
}
let remaining = deadline.saturating_duration_since(now);
thread::sleep(config.poll_interval.min(remaining));
}
child
.kill()
.map_err(|e| VmRuntimeError::Shutdown(format!("SIGKILL failed: {e}")))?;
child
.wait()
.map_err(|e| VmRuntimeError::Shutdown(format!("wait after SIGKILL failed: {e}")))?;
Ok(ShutdownOutcome::Forced)
}
}
#[cfg(target_os = "linux")]
fn send_sigterm(child: &Child) -> VmRuntimeResult<()> {
let pid = child.id() as i32;
let rc = unsafe { libc::kill(pid, libc::SIGTERM) };
if rc != 0 {
let errno = std::io::Error::last_os_error();
return Err(VmRuntimeError::Shutdown(format!(
"SIGTERM to pid {pid} failed: {errno}"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::{Child, Command};
struct ChildGuard(Option<Child>);
impl ChildGuard {
fn new(child: Child) -> Self {
Self(Some(child))
}
fn as_mut(&mut self) -> &mut Child {
self.0.as_mut().expect("child taken")
}
fn into_inner(mut self) -> Child {
self.0.take().expect("child taken")
}
}
impl Drop for ChildGuard {
fn drop(&mut self) {
if let Some(mut child) = self.0.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn spawn_sh(script: &str) -> Child {
Command::new("sh")
.arg("-c")
.arg(script)
.spawn()
.expect("spawn sh")
}
fn fast_config() -> ShutdownConfig {
ShutdownConfig {
grace_period: Duration::from_millis(200),
poll_interval: Duration::from_millis(10),
}
}
#[test]
#[cfg(target_os = "linux")]
fn graceful_exit_on_sigterm() {
let mut guard = ChildGuard::new(spawn_sh(r#"trap "exit 0" TERM; sleep 10 & wait"#));
thread::sleep(Duration::from_millis(50));
let start = Instant::now();
let outcome = graceful_shutdown(guard.as_mut(), &fast_config()).expect("shutdown");
let elapsed = start.elapsed();
assert_eq!(outcome, ShutdownOutcome::Graceful);
assert!(
elapsed < Duration::from_millis(200),
"graceful exit should finish before grace_period (took {elapsed:?})"
);
let mut child = guard.into_inner();
let post = child.try_wait().expect("try_wait after reap");
assert!(post.is_some(), "child must be reaped by graceful_shutdown");
}
#[test]
#[cfg(target_os = "linux")]
fn forced_kill_when_sigterm_ignored() {
let mut guard = ChildGuard::new(spawn_sh(r#"trap "" TERM; sleep 10"#));
thread::sleep(Duration::from_millis(50));
let cfg = fast_config();
let start = Instant::now();
let outcome = graceful_shutdown(guard.as_mut(), &cfg).expect("shutdown");
let elapsed = start.elapsed();
assert_eq!(outcome, ShutdownOutcome::Forced);
assert!(
elapsed >= cfg.grace_period,
"forced kill should respect grace_period (took {elapsed:?})"
);
assert!(
elapsed < cfg.grace_period + Duration::from_secs(2),
"forced kill should not hang well past grace_period (took {elapsed:?})"
);
let mut child = guard.into_inner();
let post = child.try_wait().expect("try_wait after reap");
assert!(post.is_some(), "child must be reaped after SIGKILL");
}
#[test]
fn already_exited_returns_already_exited() {
let mut guard = ChildGuard::new(Command::new("true").spawn().expect("spawn true"));
thread::sleep(Duration::from_millis(100));
let outcome = graceful_shutdown(guard.as_mut(), &fast_config()).expect("shutdown");
assert_eq!(outcome, ShutdownOutcome::AlreadyExited);
}
#[test]
fn default_config_values() {
let cfg = ShutdownConfig::default();
assert_eq!(cfg.grace_period, Duration::from_millis(2_000));
assert_eq!(cfg.poll_interval, Duration::from_millis(50));
}
}