Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/agent/boot_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ pub struct BootConfig {
pub overlay_disk_path: PathBuf,
/// Path to the vsock Unix socket.
pub vsock_socket: PathBuf,
/// Path to the libkrun runtime control socket.
#[serde(default)]
pub control_socket: Option<PathBuf>,
/// Optional path to console log file.
pub console_log: Option<PathBuf>,
/// Path to write startup errors.
Expand Down
2 changes: 2 additions & 0 deletions src/agent/krun.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub struct KrunFunctions {
pub disable_implicit_vsock: unsafe extern "C" fn(u32) -> i32,
pub add_vsock: unsafe extern "C" fn(u32, u32) -> i32,
pub set_console_output: unsafe extern "C" fn(u32, *const libc::c_char) -> i32,
pub set_control_socket: Option<unsafe extern "C" fn(u32, *const libc::c_char) -> i32>,
pub set_egress_policy: Option<unsafe extern "C" fn(u32, *const *const libc::c_char) -> i32>,
pub add_net_unixstream: Option<
unsafe extern "C" fn(u32, *const libc::c_char, libc::c_int, *mut u8, u32, u32) -> i32,
Expand Down Expand Up @@ -129,6 +130,7 @@ impl KrunFunctions {
disable_implicit_vsock: load_sym!(krun_disable_implicit_vsock),
add_vsock: load_sym!(krun_add_vsock),
set_console_output: load_sym!(krun_set_console_output),
set_control_socket: load_optional_sym!("krun_set_control_socket"),
set_egress_policy: load_optional_sym!("krun_set_egress_policy"),
add_net_unixstream: load_optional_sym!("krun_add_net_unixstream"),
get_egress_handle: load_optional_sym!("krun_get_egress_handle"),
Expand Down
23 changes: 23 additions & 0 deletions src/agent/launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ pub struct LaunchConfig<'a> {
pub disks: &'a VmDisks<'a>,
/// Path to the vsock Unix socket for the control channel.
pub vsock_socket: &'a Path,
/// Path to the libkrun runtime control socket for host VM lifecycle control.
pub control_socket: Option<&'a Path>,
/// Optional path to write console output.
pub console_log: Option<&'a Path>,
/// Host directory mounts to expose to the guest.
Expand Down Expand Up @@ -154,6 +156,7 @@ pub fn launch_agent_vm(config: &LaunchConfig<'_>) -> Result<()> {
rootfs_path,
disks,
vsock_socket,
control_socket,
console_log,
mounts,
port_mappings,
Expand Down Expand Up @@ -191,6 +194,7 @@ pub fn launch_agent_vm(config: &LaunchConfig<'_>) -> Result<()> {
let krun_add_disk2 = krun.add_disk2;
let krun_add_vsock_port2 = krun.add_vsock_port2;
let krun_set_console_output = krun.set_console_output;
let krun_set_control_socket = krun.set_control_socket;
let krun_set_port_map = krun.set_port_map;
let krun_add_virtiofs = krun.add_virtiofs;
let krun_start_enter = krun.start_enter;
Expand All @@ -212,6 +216,25 @@ pub fn launch_agent_vm(config: &LaunchConfig<'_>) -> Result<()> {
}
let ctx = ctx as u32;

if let Some(control_socket) = control_socket {
let Some(krun_set_control_socket) = krun_set_control_socket else {
krun_free_ctx(ctx);
return Err(Error::agent(
"configure control socket",
"libkrun does not support krun_set_control_socket; update bundled libkrun",
));
};
let control_socket =
path_to_cstring(control_socket).inspect_err(|_| krun_free_ctx(ctx))?;
if krun_set_control_socket(ctx, control_socket.as_ptr()) < 0 {
krun_free_ctx(ctx);
return Err(Error::agent(
"configure control socket",
"krun_set_control_socket failed",
));
}
}

// Set VM config
if krun_set_vm_config(ctx, resources.cpus, resources.memory_mib) < 0 {
krun_free_ctx(ctx);
Expand Down
112 changes: 94 additions & 18 deletions src/agent/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ use crate::error::{Error, Result};
use crate::process::{self, ChildProcess};
use crate::storage::{OverlayDisk, StorageDisk};
use parking_lot::Mutex;
use std::io::{Read, Write};
use std::net::Shutdown;
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
Expand Down Expand Up @@ -279,6 +282,8 @@ pub struct AgentManager {
overlay_disk: OverlayDisk,
/// vsock socket path for control channel.
vsock_socket: PathBuf,
/// libkrun runtime control socket path for VM pause/resume.
control_socket: PathBuf,
/// PID file path for tracking the VM process across CLI invocations.
pid_file: PathBuf,
/// Config file path for persisting running VM config across CLI invocations.
Expand Down Expand Up @@ -357,6 +362,7 @@ impl AgentManager {
std::fs::create_dir_all(&smolvm_runtime)?;

let vsock_socket = smolvm_runtime.join("agent.sock");
let control_socket = smolvm_runtime.join("krun-control.sock");
let pid_file = smolvm_runtime.join("agent.pid");
let config_file = smolvm_runtime.join("agent.config.json");
let console_log = Some(smolvm_runtime.join("agent-console.log"));
Expand All @@ -368,6 +374,7 @@ impl AgentManager {
storage_disk,
overlay_disk,
vsock_socket,
control_socket,
pid_file,
config_file,
console_log,
Expand Down Expand Up @@ -508,6 +515,11 @@ impl AgentManager {
&self.vsock_socket
}

/// Path to the libkrun runtime control socket.
pub fn control_socket(&self) -> &Path {
&self.control_socket
}

/// Get the console log path.
pub fn console_log(&self) -> Option<&Path> {
self.console_log.as_deref()
Expand Down Expand Up @@ -618,6 +630,25 @@ impl AgentManager {
None
}

/// Resolve the VM process PID and its captured start time.
///
/// Tries the in-memory child handle first (set when this process launched
/// the VM), then falls back to the PID file (needed when a fresh
/// AgentManager reconnects to a VM started by a previous CLI invocation).
fn vm_pid(&self) -> (Option<libc::pid_t>, Option<u64>) {
let inner = self.inner.lock();
if let Some(child) = inner.child.as_ref() {
// Use the start time captured at fork — not recomputed from the
// current PID, which would be self-fulfilling if the OS recycled it.
(Some(child.pid()), child.start_time())
} else {
match self.read_pid_file_with_start_time() {
Some((pid, start_time)) => (Some(pid), start_time),
None => (None, None),
}
}
}

/// Read PID and start time from the PID file.
fn read_pid_file_with_start_time(&self) -> Option<(i32, Option<u64>)> {
let content = std::fs::read_to_string(&self.pid_file).ok()?;
Expand Down Expand Up @@ -964,6 +995,7 @@ impl AgentManager {

// Clean up old socket and stale markers
let _ = std::fs::remove_file(&self.vsock_socket);
let _ = std::fs::remove_file(&self.control_socket);
let ready_marker = self.rootfs_path.join(READY_MARKER_FILENAME);
let _ = std::fs::remove_file(&ready_marker);
let _ = std::fs::remove_file(&self.startup_error_log);
Expand Down Expand Up @@ -1084,6 +1116,7 @@ impl AgentManager {
storage_disk_path: self.storage_disk.path().to_path_buf(),
overlay_disk_path: self.overlay_disk.path().to_path_buf(),
vsock_socket: self.vsock_socket.clone(),
control_socket: Some(self.control_socket.clone()),
console_log: self.console_log.clone(),
startup_error_log: self.startup_error_log.clone(),
storage_size_gb,
Expand Down Expand Up @@ -1230,7 +1263,12 @@ impl AgentManager {
///
/// Only call after the VM process is confirmed dead.
fn cleanup_marker_files(&self) {
for path in [&self.pid_file, &self.config_file, &self.vsock_socket] {
for path in [
&self.pid_file,
&self.config_file,
&self.vsock_socket,
&self.control_socket,
] {
if let Err(e) = std::fs::remove_file(path) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::debug!(error = %e, path = %path.display(), "failed to remove marker file");
Expand Down Expand Up @@ -1314,23 +1352,7 @@ impl AgentManager {

tracing::info!("stopping agent VM");

// Get the child PID and start time — try in-memory first, then PID file.
// The PID file fallback is critical for default VMs where a fresh
// AgentManager doesn't know the PID from a previous CLI invocation.
let (child_pid, pid_start_time) = {
let inner = self.inner.lock();
if let Some(child) = inner.child.as_ref() {
// Use the start time captured when the child handle was created,
// not recomputed from the PID (which would be self-fulfilling
// if the PID was recycled by the OS).
(Some(child.pid()), child.start_time())
} else {
match self.read_pid_file_with_start_time() {
Some((pid, start_time)) => (Some(pid), start_time),
None => (None, None),
}
}
};
let (child_pid, pid_start_time) = self.vm_pid();

if let Some(pid) = child_pid {
if let Err(e) = self.stop_vm_process(pid, pid_start_time) {
Expand Down Expand Up @@ -1370,6 +1392,60 @@ impl AgentManager {
Ok(())
}

/// Send a one-line command to the libkrun runtime control socket and return
/// the response line.
///
/// The socket is served by the VMM child process, so this works across CLI
/// invocations where the parent no longer has an in-process libkrun context.
/// Returns `Ok(response)` when the VMM replies with `"OK ..."`, and `Err`
/// for `"ERR ..."` or connection failures.
fn send_control_command(&self, command: &str) -> Result<String> {
let mut stream = UnixStream::connect(&self.control_socket).map_err(|e| {
Error::agent(
"connect control socket",
format!("{}: {}", self.control_socket.display(), e),
)
})?;
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.map_err(|e| Error::agent("configure control socket", e.to_string()))?;
stream
.write_all(format!("{command}\n").as_bytes())
.map_err(|e| Error::agent("write control command", e.to_string()))?;
let _ = stream.shutdown(Shutdown::Write);

let mut response = String::new();
stream
.read_to_string(&mut response)
.map_err(|e| Error::agent("read control response", e.to_string()))?;

if response.starts_with("OK ") {
Ok(response)
} else {
Err(Error::agent("control command", response.trim().to_string()))
}
}

/// Pause the agent VM through libkrun's runtime control path.
///
/// libkrun coordinates vCPU pause inside the VMM process. The DB record
/// must be updated to `Paused` by the caller after this succeeds.
pub fn pause(&self) -> Result<()> {
tracing::info!("pausing VM through libkrun control socket");
self.send_control_command("PAUSE")?;
Ok(())
}

/// Resume the agent VM through libkrun's runtime control path.
///
/// The VM continues exactly where it was paused, with all state intact.
/// The DB record must be updated back to `Running` by the caller.
pub fn resume(&self) -> Result<()> {
tracing::info!("resuming VM through libkrun control socket");
self.send_control_command("RESUME")?;
Ok(())
}

/// Wait for the agent to be ready.
///
/// Polls for a ready marker file (`.smolvm-ready`) in the virtiofs rootfs.
Expand Down
3 changes: 3 additions & 0 deletions src/agent/state_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ use crate::db::SmolvmDb;
/// records doesn't take seconds.
pub fn resolve_state(name: &str, record: &VmRecord) -> RecordState {
if record.state != RecordState::Running {
if record.state == RecordState::Paused {
return record.actual_state();
}
return record.state.clone();
}

Expand Down
6 changes: 5 additions & 1 deletion src/api/handlers/machines.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,10 @@ pub async fn start_machine(
return Ok(Json(record_to_info(&name, &record)));
}

if resolved == RecordState::Paused {
return Ok(Json(record_to_info(&name, &record)));
}

if resolved == RecordState::Unreachable {
// Zombie: verified-kill the VMM and clear the DB record
// before falling through to a clean fresh start. Any stale
Expand Down Expand Up @@ -561,7 +565,7 @@ pub async fn stop_machine(

// Check state
let actual_state = record.actual_state();
if actual_state != RecordState::Running {
if !matches!(actual_state, RecordState::Running | RecordState::Paused) {
// Already stopped, just return current info
return Ok(Json(record_to_info(&name, &record)));
}
Expand Down
1 change: 1 addition & 0 deletions src/cli/internal_boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ pub fn run(config_path: PathBuf) -> smolvm::Result<()> {
rootfs_path: &config.rootfs_path,
disks: &disks,
vsock_socket: &config.vsock_socket,
control_socket: config.control_socket.as_deref(),
console_log: config.console_log.as_deref(),
mounts: &config.mounts,
port_mappings: &config.ports,
Expand Down
55 changes: 55 additions & 0 deletions src/cli/machine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ pub enum MachineCmd {
/// Stop a running machine
Stop(StopCmd),

/// Pause a running machine
Pause(PauseCmd),

/// Resume a paused machine
Resume(ResumeCmd),

/// Delete a machine configuration
#[command(visible_alias = "rm")]
Delete(DeleteCmd),
Expand Down Expand Up @@ -141,6 +147,8 @@ impl MachineCmd {
MachineCmd::Create(cmd) => cmd.run(),
MachineCmd::Start(cmd) => cmd.run(),
MachineCmd::Stop(cmd) => cmd.run(),
MachineCmd::Pause(cmd) => cmd.run(),
MachineCmd::Resume(cmd) => cmd.run(),
MachineCmd::Delete(cmd) => cmd.run(),
MachineCmd::Status(cmd) => cmd.run(),
MachineCmd::Ls(cmd) => cmd.run(),
Expand Down Expand Up @@ -1403,6 +1411,53 @@ impl StopCmd {
}
}

// ============================================================================
// Pause Command
// ============================================================================

/// Pause a running machine.
///
/// Coordinates pause through libkrun. All in-memory state (guest RAM,
/// vCPU registers, running processes, open connections) is preserved.
/// Use `machine resume` to continue execution.
#[derive(Args, Debug)]
pub struct PauseCmd {
/// Machine to pause (default: "default")
#[arg(short = 'n', long, value_name = "NAME")]
pub name: Option<String>,
}

impl PauseCmd {
pub fn run(self) -> smolvm::Result<()> {
let name = vm_common::resolve_vm_name(self.name)?;
let name_str = name.as_deref().unwrap_or("default");
vm_common::pause_vm_named(name_str)
}
}

// ============================================================================
// Resume Command
// ============================================================================

/// Resume a paused machine.
///
/// Continues the VM through libkrun. Execution picks up exactly where it was
/// paused: no boot sequence, no state loss.
#[derive(Args, Debug)]
pub struct ResumeCmd {
/// Machine to resume (default: "default")
#[arg(short = 'n', long, value_name = "NAME")]
pub name: Option<String>,
}

impl ResumeCmd {
pub fn run(self) -> smolvm::Result<()> {
let name = vm_common::resolve_vm_name(self.name)?;
let name_str = name.as_deref().unwrap_or("default");
vm_common::resume_vm_named(name_str)
}
}

// ============================================================================
// Delete Command
// ============================================================================
Expand Down
Loading