use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use statig::blocking::IntoStateMachineExt;
use tokio::sync::{mpsc, oneshot, watch};
use tokio::task::JoinHandle;
use crate::boot_assets::BootAssetProvider;
use crate::error::{CoreError, Result};
use crate::event::{Event, EventBus};
use crate::machine::MachineManager;
use super::balloon;
use super::balloon::controller::{
BalloonCommand, BalloonController, BalloonDeps, PressureWatch, WatchFrame,
};
use super::machine::{Effect, Effects, Notify, VmEvent, VmLifecycle};
use super::{
BALLOON_SHRINK_DELAY_SECS, HealthMonitor, RecoveryPolicy, VmLifecycleConfig, VmLifecycleState,
};
pub(super) enum Command {
EnsureReady {
timeout: Duration,
reply: oneshot::Sender<Result<u32>>,
},
Shutdown {
reply: oneshot::Sender<Result<()>>,
},
ForceStop {
reply: oneshot::Sender<Result<()>>,
},
Activity,
}
pub(super) enum InternalEvent {
AgentReady,
BootFailed(String),
Stopped,
StopFailed(String),
}
pub(super) struct Completion {
pub(super) epoch: u64,
pub(super) outcome: InternalEvent,
}
pub(super) struct LifecycleShared {
pub(super) machine_name: String,
pub(super) data_image_filename: String,
pub(super) data_dir: std::path::PathBuf,
pub(super) machine_manager: Arc<MachineManager>,
pub(super) event_bus: EventBus,
pub(super) boot_assets: Arc<BootAssetProvider>,
pub(super) recovery: RecoveryPolicy,
pub(super) health_monitor: Arc<HealthMonitor>,
pub(super) config: VmLifecycleConfig,
pub(super) backend: std::sync::atomic::AtomicU8,
pub(super) restart_generation: std::sync::atomic::AtomicU64,
pub(super) last_activity_ms: std::sync::atomic::AtomicU64,
pub(super) active_ops: std::sync::atomic::AtomicUsize,
pub(super) kubernetes_hold: std::sync::atomic::AtomicBool,
}
impl LifecycleShared {
pub(super) fn record_activity(&self) {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
self.last_activity_ms.store(now_ms, Ordering::Relaxed);
}
fn idle_seconds(&self) -> u64 {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let last = self.last_activity_ms.load(Ordering::Relaxed);
now_ms.saturating_sub(last) / 1000
}
pub(super) fn backend(&self) -> arcbox_vmm::VmBackend {
match self.backend.load(Ordering::Acquire) {
0 => arcbox_vmm::VmBackend::Hv,
_ => arcbox_vmm::VmBackend::Vz,
}
}
fn get_cid(&self) -> Result<u32> {
self.machine_manager
.get_cid(&self.machine_name)
.ok_or_else(|| CoreError::Machine("default machine has no CID".to_string()))
}
}
pub(super) struct RealBalloonDeps {
pub(super) shared: Arc<LifecycleShared>,
}
impl RealBalloonDeps {
fn connect_agent(&self) -> Result<crate::agent_client::AgentClient> {
self.shared
.machine_manager
.connect_agent(&self.shared.machine_name)
}
}
impl BalloonDeps for RealBalloonDeps {
type Watch = AgentPressureWatch;
fn reclaim_capable(&self) -> bool {
false
}
fn full_memory_bytes(&self) -> Option<u64> {
self.shared
.machine_manager
.get(&self.shared.machine_name)
.map(|info| info.memory_mb * 1024 * 1024)
}
fn set_balloon_target(&self, bytes: u64) -> Result<()> {
let info = self
.shared
.machine_manager
.get(&self.shared.machine_name)
.ok_or_else(|| CoreError::Machine("machine record missing".to_string()))?;
self.shared
.machine_manager
.vm_manager()
.set_balloon_target(&info.vm_id, bytes)
}
async fn guest_stats(&self, budget: Duration) -> Option<balloon::GuestStats> {
let mut agent = self.connect_agent().ok()?;
let query = async move {
let info = if agent.is_blocking() {
tokio::task::spawn_blocking(move || agent.get_system_info_blocking())
.await
.ok()?
.ok()?
} else {
agent.get_system_info().await.ok()?
};
Some(balloon::GuestStats {
total: info.total_memory,
available: info.available_memory,
loadavg1: info.load_average.first().copied().unwrap_or(0.0),
})
};
tokio::time::timeout(budget, query).await.ok().flatten()
}
async fn open_pressure_watch(&self) -> Result<AgentPressureWatch> {
use super::balloon::controller::{
PRESSURE_MAX_REFAULT_RATE, PRESSURE_MIN_AVAILABLE, PRESSURE_PSI_FULL_STALL_US,
WATCH_KEEPALIVE, WATCH_WINDOW,
};
let mut agent = self.connect_agent()?;
agent
.watch_memory_pressure(arcbox_connect::v1::WatchMemoryPressureRequest {
timeout_ms: u32::try_from(WATCH_WINDOW.as_millis()).unwrap_or(u32::MAX),
min_available_bytes: PRESSURE_MIN_AVAILABLE,
max_refault_rate: PRESSURE_MAX_REFAULT_RATE,
keepalive_ms: u32::try_from(WATCH_KEEPALIVE.as_millis()).unwrap_or(u32::MAX),
psi_full_stall_us: PRESSURE_PSI_FULL_STALL_US,
..Default::default()
})
.await?;
let mut watch = AgentPressureWatch { agent };
watch.next_frame(Duration::from_secs(5)).await?;
Ok(watch)
}
}
pub(super) struct AgentPressureWatch {
agent: crate::agent_client::AgentClient,
}
impl PressureWatch for AgentPressureWatch {
async fn next_frame(&mut self, max_wait: Duration) -> Result<WatchFrame> {
use arcbox_connect::v1::memory_pressure_event::Reason;
let event = self.agent.next_memory_pressure_event(max_wait).await?;
Ok(match event.reason.as_known() {
Some(Reason::LowAvailable | Reason::RefaultSpike) => WatchFrame::Pressure,
Some(Reason::Settled) => WatchFrame::Settled,
Some(Reason::WindowElapsed) => WatchFrame::WindowElapsed,
Some(Reason::Keepalive) | None => WatchFrame::Keepalive,
})
}
}
pub(super) struct LifecycleActor {
shared: Arc<LifecycleShared>,
commands: mpsc::UnboundedReceiver<Command>,
events_rx: mpsc::UnboundedReceiver<Completion>,
events_tx: mpsc::UnboundedSender<Completion>,
state_tx: watch::Sender<VmLifecycleState>,
effects: Effects,
waiters: Vec<oneshot::Sender<Result<u32>>>,
stop_waiters: Vec<oneshot::Sender<Result<()>>>,
pending_timeout: Option<Duration>,
pending_stop: bool,
inflight: Option<JoinHandle<()>>,
epoch: u64,
removal: Option<JoinHandle<()>>,
cmd_tx: mpsc::UnboundedSender<Command>,
balloon_tx: mpsc::UnboundedSender<BalloonCommand>,
balloon_seed: Option<mpsc::UnboundedReceiver<BalloonCommand>>,
}
impl LifecycleActor {
pub(super) fn new(
shared: Arc<LifecycleShared>,
commands: mpsc::UnboundedReceiver<Command>,
cmd_tx: mpsc::UnboundedSender<Command>,
state_tx: watch::Sender<VmLifecycleState>,
) -> Self {
let (events_tx, events_rx) = mpsc::unbounded_channel();
let (balloon_tx, balloon_rx) = mpsc::unbounded_channel();
Self {
shared,
commands,
events_rx,
events_tx,
state_tx,
effects: Effects::default(),
waiters: Vec::new(),
stop_waiters: Vec::new(),
pending_timeout: None,
pending_stop: false,
inflight: None,
epoch: 0,
removal: None,
cmd_tx,
balloon_tx,
balloon_seed: Some(balloon_rx),
}
}
pub(super) async fn run(mut self) {
let mut machine = VmLifecycle
.uninitialized_state_machine()
.init_with_context(&mut self.effects);
if let Some(balloon_rx) = self.balloon_seed.take() {
let deps = RealBalloonDeps {
shared: Arc::clone(&self.shared),
};
let shared = Arc::clone(&self.shared);
let cmd_tx = self.cmd_tx.clone();
let activity = Arc::new(move || {
shared.record_activity();
let _ = cmd_tx.send(Command::Activity);
});
drop(tokio::spawn(
BalloonController::new(balloon_rx, deps, activity).run(),
));
}
let mut idle_ticker = tokio::time::interval(Duration::from_secs(BALLOON_SHRINK_DELAY_SECS));
idle_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
const VM_LIVENESS_POLL_SECS: u64 = 2;
let mut liveness_ticker = tokio::time::interval(Duration::from_secs(VM_LIVENESS_POLL_SECS));
liveness_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
cmd = self.commands.recv() => {
let Some(cmd) = cmd else { break };
self.on_command(&mut machine, cmd);
}
Some(ev) = self.events_rx.recv() => {
self.on_internal(&mut machine, ev);
}
_ = idle_ticker.tick() => {
self.on_idle_tick(&mut machine);
}
_ = liveness_ticker.tick() => {
self.on_liveness_tick(&mut machine);
}
}
}
if let Some(handle) = self.inflight.take() {
handle.abort();
}
}
fn public(&self) -> VmLifecycleState {
*self.state_tx.borrow()
}
fn dispatch(&mut self, machine: &mut Machine, event: VmEvent) {
let before = machine.state().to_public();
machine.handle_with_context(&event, &mut self.effects);
let after = machine.state().to_public();
if before != after {
tracing::info!(
machine = %self.shared.machine_name,
from = before.as_str(),
to = after.as_str(),
"VM lifecycle transition"
);
self.state_tx.send_replace(after);
if after == VmLifecycleState::Idle {
let _ = self.balloon_tx.send(BalloonCommand::EnterIdle);
} else if before == VmLifecycleState::Idle {
let _ = self.balloon_tx.send(BalloonCommand::ExitIdle);
}
}
for effect in self.effects.take() {
self.apply(effect);
}
}
fn apply(&mut self, effect: Effect) {
match effect {
Effect::SpawnBoot { create, timeout_ms } => {
let epoch = self.abort_inflight();
let shared = Arc::clone(&self.shared);
let events = self.events_tx.clone();
let timeout = Duration::from_millis(timeout_ms);
self.inflight = Some(tokio::spawn(async move {
shared.run_boot(create, timeout, epoch, &events).await;
}));
}
Effect::SpawnStop => {
let epoch = self.abort_inflight();
let shared = Arc::clone(&self.shared);
let events = self.events_tx.clone();
self.inflight = Some(tokio::spawn(async move {
shared.run_stop(epoch, &events).await;
}));
}
Effect::SpawnReboot { timeout_ms } => {
let epoch = self.abort_inflight();
let shared = Arc::clone(&self.shared);
let events = self.events_tx.clone();
let timeout = Duration::from_millis(timeout_ms);
self.inflight = Some(tokio::spawn(async move {
shared.run_reboot(timeout, epoch, &events).await;
}));
}
Effect::AbortInflight => {
self.abort_inflight();
}
Effect::RemoveMachine => {
let shared = Arc::clone(&self.shared);
self.removal = Some(tokio::task::spawn_blocking(move || {
let _ = shared.machine_manager.remove(&shared.machine_name, true);
}));
}
Effect::BumpGeneration => {
self.shared
.restart_generation
.fetch_add(1, Ordering::Release);
}
Effect::Publish(notify) => {
let name = self.shared.machine_name.clone();
self.shared.event_bus.publish(match notify {
Notify::Started => Event::MachineStarted { name },
Notify::Idle => Event::MachineIdle { name },
Notify::Stopped => Event::MachineStopped { name },
});
}
Effect::ReadyWaiters => {
for waiter in self.waiters.drain(..) {
let _ = waiter.send(self.shared.get_cid());
}
}
Effect::FailWaiters(reason) => {
for waiter in self.waiters.drain(..) {
let _ = waiter.send(Err(CoreError::Vm(reason.clone())));
}
}
}
}
fn on_command(&mut self, machine: &mut Machine, cmd: Command) {
match cmd {
Command::EnsureReady { timeout, reply } => {
self.on_ensure_ready(machine, timeout, reply);
}
Command::Shutdown { reply } => self.on_shutdown(machine, reply),
Command::ForceStop { reply } => {
self.shared.health_monitor.stop();
self.dispatch(machine, VmEvent::ForceStop);
for waiter in self.stop_waiters.drain(..) {
let _ = waiter.send(Ok(()));
}
self.pending_timeout = None;
self.pending_stop = false;
let removal = self.removal.take();
drop(tokio::spawn(async move {
if let Some(handle) = removal {
let _ = handle.await;
}
let _ = reply.send(Ok(()));
}));
}
Command::Activity => {
self.dispatch(machine, VmEvent::Activity);
}
}
}
fn on_ensure_ready(
&mut self,
machine: &mut Machine,
timeout: Duration,
reply: oneshot::Sender<Result<u32>>,
) {
self.shared.record_activity();
let state = self.public();
if state.is_ready() {
self.dispatch(machine, VmEvent::Activity);
let _ = reply.send(self.shared.get_cid());
return;
}
self.waiters.push(reply);
if state.needs_start() && self.inflight.is_none() {
let create = self.decide_create(state);
self.dispatch(
machine,
VmEvent::Start {
create,
timeout_ms: timeout.as_millis() as u64,
},
);
} else {
self.pending_timeout.get_or_insert(timeout);
}
}
fn on_shutdown(&mut self, machine: &mut Machine, reply: oneshot::Sender<Result<()>>) {
let state = self.public();
match state {
VmLifecycleState::Stopping => {
self.stop_waiters.push(reply);
}
VmLifecycleState::Creating | VmLifecycleState::Starting => {
self.stop_waiters.push(reply);
self.pending_stop = true;
}
state if state.is_ready() => {
self.stop_waiters.push(reply);
self.dispatch(machine, VmEvent::Stop);
}
_ => {
let _ = reply.send(Ok(()));
}
}
}
fn abort_inflight(&mut self) -> u64 {
if let Some(handle) = self.inflight.take() {
handle.abort();
}
self.epoch += 1;
self.epoch
}
fn on_internal(&mut self, machine: &mut Machine, completion: Completion) {
if completion.epoch != self.epoch {
tracing::debug!(
machine = %self.shared.machine_name,
stale_epoch = completion.epoch,
current_epoch = self.epoch,
"dropping stale lifecycle sub-task completion"
);
return;
}
self.inflight = None;
match completion.outcome {
InternalEvent::AgentReady => {
self.dispatch(machine, VmEvent::AgentReady);
if std::mem::take(&mut self.pending_stop) {
self.dispatch(machine, VmEvent::Stop);
}
}
InternalEvent::BootFailed(reason) => {
self.dispatch(machine, VmEvent::Failure);
for waiter in self.waiters.drain(..) {
let _ = waiter.send(Err(CoreError::Vm(reason.clone())));
}
if std::mem::take(&mut self.pending_stop) {
for waiter in self.stop_waiters.drain(..) {
let _ = waiter.send(Ok(()));
}
}
}
InternalEvent::Stopped => {
self.dispatch(machine, VmEvent::Stopped);
for waiter in self.stop_waiters.drain(..) {
let _ = waiter.send(Ok(()));
}
}
InternalEvent::StopFailed(reason) => {
self.dispatch(machine, VmEvent::Failure);
for waiter in self.stop_waiters.drain(..) {
let _ = waiter.send(Err(CoreError::Vm(reason.clone())));
}
}
}
self.start_if_pending(machine);
}
fn start_if_pending(&mut self, machine: &mut Machine) {
let state = self.public();
if self.waiters.is_empty() || !state.needs_start() || self.inflight.is_some() {
return;
}
let timeout = self
.pending_timeout
.take()
.unwrap_or(self.shared.config.startup_timeout);
let create = self.decide_create(state);
self.dispatch(
machine,
VmEvent::Start {
create,
timeout_ms: timeout.as_millis() as u64,
},
);
}
fn on_idle_tick(&mut self, machine: &mut Machine) {
if !self.shared.config.auto_stop
|| self.shared.kubernetes_hold.load(Ordering::Relaxed)
|| self.shared.active_ops.load(Ordering::Acquire) > 0
|| self.public() != VmLifecycleState::Running
{
return;
}
if self.shared.idle_seconds() >= self.shared.config.idle_timeout.as_secs() {
tracing::info!(
"VM entered idle state after {}s of inactivity",
self.shared.idle_seconds()
);
self.dispatch(machine, VmEvent::IdleTimeout);
}
}
fn on_liveness_tick(&mut self, machine: &mut Machine) {
if !matches!(
self.public(),
VmLifecycleState::Running | VmLifecycleState::Idle
) {
return;
}
if self
.shared
.machine_manager
.vm_self_stopped(&self.shared.machine_name)
== Some(true)
{
tracing::info!("guest requested reboot (SYSTEM_RESET); rebooting VM in place");
let timeout_ms =
u64::try_from(self.shared.config.startup_timeout.as_millis()).unwrap_or(u64::MAX);
self.dispatch(machine, VmEvent::GuestReset { timeout_ms });
}
}
fn decide_create(&self, state: VmLifecycleState) -> bool {
let exists = self
.shared
.machine_manager
.get(&self.shared.machine_name)
.is_some();
if !exists && state != VmLifecycleState::NotExist {
tracing::warn!(
state = state.as_str(),
"machine record missing while lifecycle state indicates existing VM; recreating"
);
}
!exists
}
}
type Machine = statig::blocking::InitializedStateMachine<VmLifecycle>;