use std::cell::Cell;
use std::path::Path;
use std::sync::Arc;
use block2::RcBlock;
use dispatch2::{DispatchQueue, DispatchRetained};
use objc2::AnyThread;
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2_foundation::{NSError, NSString, NSURL};
use objc2_virtualization::{VZVirtualMachine, VZVirtualMachineState};
use tokio::sync::watch;
use crate::config::{self, VmConfig};
use crate::delegate::VmDelegate;
use crate::event::{VmEvent, VmEventBus, VmEventKind};
use crate::types::{VmId, VmInfo};
use crate::KasouError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum VmState {
Stopped,
Starting,
Running,
Pausing,
Paused,
Resuming,
Stopping,
Saving,
Restoring,
Error,
}
impl VmState {
pub(crate) fn from_vz(state: VZVirtualMachineState) -> Self {
match state {
VZVirtualMachineState::Stopped => Self::Stopped,
VZVirtualMachineState::Starting => Self::Starting,
VZVirtualMachineState::Running => Self::Running,
VZVirtualMachineState::Pausing => Self::Pausing,
VZVirtualMachineState::Paused => Self::Paused,
VZVirtualMachineState::Resuming => Self::Resuming,
VZVirtualMachineState::Stopping => Self::Stopping,
VZVirtualMachineState::Saving => Self::Saving,
VZVirtualMachineState::Restoring => Self::Restoring,
VZVirtualMachineState::Error => Self::Error,
_ => Self::Error,
}
}
pub fn is_active(self) -> bool {
matches!(
self,
Self::Starting
| Self::Running
| Self::Pausing
| Self::Paused
| Self::Resuming
| Self::Saving
| Self::Restoring
)
}
#[allow(clippy::match_like_matches_macro)]
pub fn can_transition_to(self, target: Self) -> bool {
matches!(
(self, target),
(Self::Stopped, Self::Starting)
| (Self::Starting, Self::Running)
| (Self::Running, Self::Pausing)
| (Self::Pausing, Self::Paused)
| (Self::Paused, Self::Resuming)
| (Self::Resuming, Self::Running)
| (Self::Running, Self::Stopping)
| (Self::Paused, Self::Stopping)
| (Self::Stopping, Self::Stopped)
| (Self::Running, Self::Stopped)
| (Self::Paused, Self::Saving)
| (Self::Saving, Self::Paused)
| (Self::Stopped, Self::Restoring)
| (Self::Restoring, Self::Paused)
| (Self::Starting, Self::Error)
| (Self::Running, Self::Error)
| (Self::Pausing, Self::Error)
| (Self::Paused, Self::Error)
| (Self::Resuming, Self::Error)
| (Self::Stopping, Self::Error)
| (Self::Saving, Self::Error)
| (Self::Restoring, Self::Error)
)
}
pub fn is_terminal(self) -> bool {
matches!(self, Self::Error)
}
}
impl std::fmt::Display for VmState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Stopped => write!(f, "stopped"),
Self::Starting => write!(f, "starting"),
Self::Running => write!(f, "running"),
Self::Pausing => write!(f, "pausing"),
Self::Paused => write!(f, "paused"),
Self::Resuming => write!(f, "resuming"),
Self::Stopping => write!(f, "stopping"),
Self::Saving => write!(f, "saving"),
Self::Restoring => write!(f, "restoring"),
Self::Error => write!(f, "error"),
}
}
}
struct SendVm(Retained<VZVirtualMachine>);
unsafe impl Send for SendVm {}
pub struct VmHandle {
vm: SendVm,
queue: DispatchRetained<DispatchQueue>,
_delegate: Retained<VmDelegate>,
state_rx: watch::Receiver<VmState>,
state_tx: Arc<watch::Sender<VmState>>,
config: VmConfig,
event_bus: Arc<VmEventBus>,
created_at: std::time::Instant,
}
unsafe impl Send for VmHandle {}
impl Drop for VmHandle {
fn drop(&mut self) {
let state = *self.state_rx.borrow();
if state.is_active() && state != VmState::Paused {
tracing::warn!(state = %state, "VmHandle dropped while VM active, forcing hard stop");
let vm_addr = &*self.vm.0 as *const VZVirtualMachine as usize;
self.queue.exec_sync(move || {
let vm_ptr = vm_addr as *const VZVirtualMachine;
let block = RcBlock::new(|_error: *mut NSError| {});
unsafe { (*vm_ptr).stopWithCompletionHandler(&block) };
});
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
}
fn dispatch_vz_op(
queue: &DispatchQueue,
vm_addr: usize,
op_name: &'static str,
call: fn(*const VZVirtualMachine, &block2::DynBlock<dyn Fn(*mut NSError)>),
) -> Result<(), KasouError> {
let (tx, rx) = std::sync::mpsc::channel();
queue.exec_async(move || {
let tx = Cell::new(Some(tx));
let block = RcBlock::new(move |error: *mut NSError| {
let result = if error.is_null() {
Ok(())
} else {
let chain = crate::util::ns_error_chain(unsafe { &*error });
Err(KasouError::OperationFailed(format!("{op_name} failed: {chain}")))
};
if let Some(tx) = tx.take() {
let _ = tx.send(result);
}
});
let vm_ptr = vm_addr as *const VZVirtualMachine;
call(vm_ptr, &block);
});
rx.recv().map_err(|_| KasouError::QueueCancelled)?
}
fn dispatch_vz_url_op(
queue: &DispatchQueue,
vm_addr: usize,
path: &Path,
op_name: &'static str,
call: fn(*const VZVirtualMachine, *const NSURL, &block2::DynBlock<dyn Fn(*mut NSError)>),
) -> Result<(), KasouError> {
let path_string = path.to_str().ok_or_else(|| {
KasouError::InvalidConfig(format!("path not UTF-8: {}", path.display()))
})?.to_string();
let (tx, rx) = std::sync::mpsc::channel();
queue.exec_async(move || {
let ns_path = NSString::from_str(&path_string);
let url = NSURL::initFileURLWithPath(NSURL::alloc(), &ns_path);
let tx = Cell::new(Some(tx));
let block = RcBlock::new(move |error: *mut NSError| {
let result = if error.is_null() {
Ok(())
} else {
let chain = crate::util::ns_error_chain(unsafe { &*error });
Err(KasouError::OperationFailed(format!("{op_name} failed: {chain}")))
};
if let Some(tx) = tx.take() {
let _ = tx.send(result);
}
});
let vm_ptr = vm_addr as *const VZVirtualMachine;
call(vm_ptr, &*url as *const NSURL, &block);
});
rx.recv().map_err(|_| KasouError::QueueCancelled)?
}
impl VmHandle {
pub fn create(vm_config: VmConfig) -> Result<Self, KasouError> {
vm_config.validate()?;
tracing::info!(id = %vm_config.id, "building VZ configuration");
let vz_config = config::build_vz_config(&vm_config)?;
let (state_tx, state_rx) = watch::channel(VmState::Stopped);
let state_tx = Arc::new(state_tx);
let delegate = VmDelegate::new(Arc::clone(&state_tx));
let event_bus = Arc::new(VmEventBus::default());
let queue = DispatchQueue::new("io.pleme.kasou.vm", None);
tracing::info!(id = %vm_config.id, "creating VZVirtualMachine");
let vm = unsafe {
VZVirtualMachine::initWithConfiguration_queue(
VZVirtualMachine::alloc(),
&vz_config,
&queue,
)
};
unsafe {
vm.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
};
Ok(Self {
vm: SendVm(vm),
queue,
_delegate: delegate,
state_rx,
state_tx,
config: vm_config,
event_bus,
created_at: std::time::Instant::now(),
})
}
fn vm_addr(&self) -> usize {
&*self.vm.0 as *const VZVirtualMachine as usize
}
pub fn start(&self) -> Result<(), KasouError> {
let before = self.state();
dispatch_vz_op(&self.queue, self.vm_addr(), "start", |vm, block| {
unsafe { (*vm).startWithCompletionHandler(block) };
})?;
self.transition_state(before, VmState::Running);
Ok(())
}
pub fn stop(&self) -> Result<(), KasouError> {
let before = self.state();
dispatch_vz_op(&self.queue, self.vm_addr(), "stop", |vm, block| {
unsafe { (*vm).stopWithCompletionHandler(block) };
})?;
self.transition_state(before, VmState::Stopped);
Ok(())
}
pub fn pause(&self) -> Result<(), KasouError> {
let before = self.state();
dispatch_vz_op(&self.queue, self.vm_addr(), "pause", |vm, block| {
unsafe { (*vm).pauseWithCompletionHandler(block) };
})?;
self.transition_state(before, VmState::Paused);
Ok(())
}
pub fn resume(&self) -> Result<(), KasouError> {
let before = self.state();
dispatch_vz_op(&self.queue, self.vm_addr(), "resume", |vm, block| {
unsafe { (*vm).resumeWithCompletionHandler(block) };
})?;
self.transition_state(before, VmState::Running);
Ok(())
}
pub fn save_state(&self, path: &Path) -> Result<(), KasouError> {
dispatch_vz_url_op(&self.queue, self.vm_addr(), path, "save", |vm, url, block| {
unsafe { (*vm).saveMachineStateToURL_completionHandler(&*url, block) };
})?;
if let Ok(file) = std::fs::OpenOptions::new().read(true).open(path) {
let _ = file.sync_all();
}
self.emit(VmEventKind::SnapshotCreated { path: path.to_path_buf() });
Ok(())
}
pub fn restore_state(&self, path: &Path) -> Result<(), KasouError> {
let before = self.state();
dispatch_vz_url_op(&self.queue, self.vm_addr(), path, "restore", |vm, url, block| {
unsafe { (*vm).restoreMachineStateFromURL_completionHandler(&*url, block) };
})?;
self.transition_state(before, VmState::Paused);
self.emit(VmEventKind::SnapshotRestored { path: path.to_path_buf() });
Ok(())
}
pub fn request_stop(&self) -> Result<(), KasouError> {
let (tx, rx) = std::sync::mpsc::channel();
let vm_addr = self.vm_addr();
self.queue.exec_async(move || {
let vm_ptr = vm_addr as *const VZVirtualMachine;
let result = unsafe { (*vm_ptr).requestStopWithError() }.map_err(|e| {
let chain = crate::util::ns_error_chain(&e);
KasouError::OperationFailed(format!("request stop failed: {chain}"))
});
let _ = tx.send(result);
});
let result = rx.recv().map_err(|_| KasouError::QueueCancelled)?;
if result.is_ok() {
self.emit(VmEventKind::ShutdownRequested);
}
result
}
pub fn state(&self) -> VmState {
*self.state_rx.borrow()
}
pub fn state_watch(&self) -> watch::Receiver<VmState> {
self.state_rx.clone()
}
pub fn config(&self) -> &VmConfig {
&self.config
}
pub fn id(&self) -> &VmId {
&self.config.id
}
pub fn events(&self) -> tokio::sync::broadcast::Receiver<VmEvent> {
self.event_bus.subscribe()
}
pub fn info(&self) -> VmInfo {
let uptime = self.created_at.elapsed().as_secs();
let mac_address = self.config.network.mac_address.as_ref()
.and_then(|m| crate::types::MacAddress::parse(m).ok());
let ip_address = self.config.network.mac_address.as_ref()
.and_then(|mac| crate::dhcp::lookup_ip_by_mac(mac));
VmInfo {
id: self.config.id.clone(),
state: self.state(),
pid: Some(std::process::id()),
uptime_secs: Some(uptime),
mac_address,
ip_address,
}
}
pub fn wait_for_ip(&self, timeout: std::time::Duration) -> Result<String, KasouError> {
let mac = self.config.network.mac_address.as_ref().ok_or_else(|| {
KasouError::InvalidConfig("no MAC address configured — cannot discover IP".into())
})?;
let deadline = std::time::Instant::now() + timeout;
loop {
if let Some(ip) = crate::dhcp::lookup_ip_by_mac(mac) {
self.emit(VmEventKind::IpAssigned { ip: ip.clone() });
return Ok(ip);
}
if std::time::Instant::now() >= deadline {
return Err(KasouError::OperationFailed(format!(
"VM did not obtain DHCP IP within {}s (MAC: {mac})",
timeout.as_secs()
)));
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
fn transition_state(&self, from: VmState, to: VmState) {
let _ = self.state_tx.send(to);
self.emit(VmEventKind::StateChanged { from, to });
}
fn emit(&self, kind: VmEventKind) {
self.event_bus.emit(VmEvent {
timestamp: std::time::Instant::now(),
vm_id: self.config.id.clone(),
kind,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stopped_can_start() {
assert!(VmState::Stopped.can_transition_to(VmState::Starting));
}
#[test]
fn starting_reaches_running() {
assert!(VmState::Starting.can_transition_to(VmState::Running));
}
#[test]
fn running_can_pause_stop() {
assert!(VmState::Running.can_transition_to(VmState::Pausing));
assert!(VmState::Running.can_transition_to(VmState::Stopping));
assert!(VmState::Running.can_transition_to(VmState::Stopped)); }
#[test]
fn pause_resume_cycle() {
assert!(VmState::Running.can_transition_to(VmState::Pausing));
assert!(VmState::Pausing.can_transition_to(VmState::Paused));
assert!(VmState::Paused.can_transition_to(VmState::Resuming));
assert!(VmState::Resuming.can_transition_to(VmState::Running));
}
#[test]
fn save_restore_cycle() {
assert!(VmState::Paused.can_transition_to(VmState::Saving));
assert!(VmState::Saving.can_transition_to(VmState::Paused));
assert!(VmState::Stopped.can_transition_to(VmState::Restoring));
assert!(VmState::Restoring.can_transition_to(VmState::Paused));
}
#[test]
fn error_from_any_active() {
for state in [
VmState::Starting, VmState::Running, VmState::Pausing,
VmState::Paused, VmState::Resuming, VmState::Stopping,
VmState::Saving, VmState::Restoring,
] {
assert!(state.can_transition_to(VmState::Error), "{state} should reach Error");
}
}
#[test]
fn error_is_terminal() {
assert!(VmState::Error.is_terminal());
assert!(!VmState::Stopped.is_terminal());
assert!(!VmState::Running.is_terminal());
}
#[test]
fn invalid_transitions_rejected() {
assert!(!VmState::Stopped.can_transition_to(VmState::Running)); assert!(!VmState::Running.can_transition_to(VmState::Starting));
assert!(!VmState::Error.can_transition_to(VmState::Running));
assert!(!VmState::Error.can_transition_to(VmState::Stopped));
}
#[test]
fn active_states() {
assert!(VmState::Running.is_active());
assert!(VmState::Paused.is_active());
assert!(VmState::Saving.is_active());
assert!(VmState::Restoring.is_active());
assert!(!VmState::Stopped.is_active());
assert!(!VmState::Error.is_active());
}
#[test]
fn display_round_trip() {
for state in [
VmState::Stopped, VmState::Starting, VmState::Running,
VmState::Pausing, VmState::Paused, VmState::Resuming,
VmState::Stopping, VmState::Saving, VmState::Restoring,
VmState::Error,
] {
let s = state.to_string();
assert!(!s.is_empty(), "display should not be empty for {state:?}");
}
}
}