Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ members = [
resolver = "2"

[workspace.package]
version = "2.0.0"
version = "2.0.1"
edition = "2021"
authors = ["A3S Lab Team"]
license = "MIT"
Expand Down
32 changes: 27 additions & 5 deletions src/guest/init/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -780,8 +780,9 @@ fn remount_rootfs_readonly() -> Result<(), Box<dyn std::error::Error>> {

/// Wait for all child processes and reap zombies.
///
/// Only exits when `container_pid` exits. Other child processes (e.g., from
/// exec server) are reaped silently.
/// Only exits when `container_pid` exits AND `shutdown_requested` is set (SIGTERM).
/// Otherwise loops forever so background services (exec/PTY/attestation) stay alive
/// after the container exits — the VM stays alive until explicitly stopped by the host.
fn wait_for_children(container_pid: nix::unistd::Pid) -> Result<(), Box<dyn std::error::Error>> {
use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus};
use nix::unistd::Pid;
Expand All @@ -802,8 +803,22 @@ fn wait_for_children(container_pid: nix::unistd::Pid) -> Result<(), Box<dyn std:
match waitpid(Pid::from_raw(-1), Some(WaitPidFlag::WNOHANG)) {
Ok(WaitStatus::Exited(pid, status)) => {
if pid == container_pid {
info!("Container process {} exited with status {}", pid, status);
process::exit(status);
// Container exited — keep the VM alive so vsock services remain available.
// The host stops the VM via SIGTERM, at which point graceful_shutdown runs.
info!(
"Container process {} exited with status {}. \
VM staying alive for vsock services (stop with Ctrl-C)",
pid, status
);
// Loop forever — VM stays alive until SIGTERM from host
loop {
if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
info!("SIGTERM received during container-exited idle, shutting down");
graceful_shutdown(CHILD_SHUTDOWN_TIMEOUT_MS);
return Ok(());
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
} else {
info!(
"Child process {} exited with status {} (reaped)",
Expand All @@ -814,7 +829,14 @@ fn wait_for_children(container_pid: nix::unistd::Pid) -> Result<(), Box<dyn std:
Ok(WaitStatus::Signaled(pid, signal, _)) => {
if pid == container_pid {
error!("Container process {} killed by signal {:?}", pid, signal);
process::exit(128 + signal as i32);
loop {
if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
info!("SIGTERM received, shutting down");
graceful_shutdown(CHILD_SHUTDOWN_TIMEOUT_MS);
return Ok(());
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
} else if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
info!(
"Child process {} terminated by signal {:?} during shutdown",
Expand Down
5 changes: 5 additions & 0 deletions src/runtime/src/vm/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,10 +485,12 @@ mod tests {
event_emitter: emitter,
provider: None,
handler: Arc::new(RwLock::new(None)),
#[cfg(unix)]
exec_client: None,
net_manager: None,
home_dir: home_dir.to_path_buf(),
anonymous_volumes: Vec::new(),
#[cfg(unix)]
tee: None,
rootfs_provider: crate::rootfs::default_provider(),
exec_socket_path: None,
Expand Down Expand Up @@ -622,6 +624,7 @@ mod tests {
assert!(cache.entry_count().unwrap() <= 2);
}

#[cfg(unix)]
#[tokio::test]
async fn test_exec_command_rejects_created_state() {
let tmp = TempDir::new().unwrap();
Expand All @@ -633,6 +636,7 @@ mod tests {
assert!(err.to_string().contains("not yet booted"));
}

#[cfg(unix)]
#[tokio::test]
async fn test_exec_command_rejects_stopped_state() {
let tmp = TempDir::new().unwrap();
Expand All @@ -645,6 +649,7 @@ mod tests {
assert!(err.to_string().contains("stopped"));
}

#[cfg(unix)]
#[tokio::test]
async fn test_exec_command_no_client() {
let tmp = TempDir::new().unwrap();
Expand Down
119 changes: 92 additions & 27 deletions src/runtime/src/vm/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ impl VmManager {
);
(exec, args, oci_config.env.clone())
}
None => (SBIN_INIT.to_string(), vec![], vec![]),
None => ("/bin/sh".to_string(), vec!["-c".to_string(), "echo No command specified; exec /bin/sh".to_string()], vec![]),
};

// Pass exec + args as individual env vars (avoids spaces being truncated
Expand Down Expand Up @@ -215,8 +215,8 @@ impl VmManager {
}
}
None => Entrypoint {
executable: SBIN_INIT.to_string(),
args: vec![],
executable: "/bin/sh".to_string(),
args: vec!["-c".to_string(), "echo No command specified; exec /bin/sh".to_string()],
env: vec![],
},
}
Expand Down Expand Up @@ -342,8 +342,8 @@ impl VmManager {
let args: Vec<String> = oci_cmd.iter().skip(1).cloned().collect();
(exec, args)
} else {
// Neither set: fall back to default init
(SBIN_INIT.to_string(), vec![])
// Neither set: fall back to /bin/sh (universal across all Linux distros)
("/bin/sh".to_string(), vec!["-c".to_string(), "echo No command specified; exec /bin/sh".to_string()])
}
}

Expand Down Expand Up @@ -375,40 +375,105 @@ impl VmManager {
None
}

/// Parse a volume mount string into an FsMount.
/// Parse a volume mount string into a FsMount.
///
/// Supported formats:
/// - `host_path:guest_path` (read-write)
/// - `host_path:guest_path:ro` (read-only)
/// - `host_path:guest_path:rw` (read-write, explicit)
///
/// Handles Windows paths with drive letters (e.g. `C:\Users\Temp:/data:ro`) by
/// using the colon-split parts array to reliably determine the host/guest boundary.
fn parse_volume_mount(volume: &str, index: usize) -> Result<FsMount> {
let parts: Vec<&str> = volume.split(':').collect();

let (host_path_str, _guest_path, read_only) = match parts.len() {
2 => (parts[0], parts[1], false),
3 => {
let ro = match parts[2] {
"ro" => true,
"rw" => false,
other => {
return Err(BoxError::ConfigError(format!(
"Invalid volume mode '{}' (expected 'ro' or 'rw'): {}",
other, volume
)));
}
};
(parts[0], parts[1], ro)
}
_ => {
// A valid volume must have at least 2 colon-separated parts (host:guest)
if parts.len() < 2 {
return Err(BoxError::ConfigError(format!(
"Invalid volume format (expected host:guest[:ro|rw]): {}",
volume
)));
}

// Detect whether a mode suffix is present by checking if the LAST
// colon-separated segment is "ro" or "rw".
let last = parts.last();
let has_mode = last.map_or(false, |s| s == &"ro" || s == &"rw");

// If the last segment is NOT a valid mode (ro/rw), check if it looks like
// a path component. If it does NOT, it's an invalid mode suffix (e.g. :invalid).
// Path-like segments start with /, \, ./, ../, or (on Windows) a drive letter.
let looks_like_path = |s: &&str| -> bool {
s.starts_with('/')
|| s.starts_with('\\')
|| s.starts_with("./")
|| s.starts_with("../")
|| (s.len() == 2 && s.chars().next().map_or(false, |c| c.is_alphabetic()) && s.ends_with(':'))
};
if parts.len() >= 2 && !has_mode && !last.map_or(false, looks_like_path) {
return Err(BoxError::ConfigError(format!(
"Invalid volume mode '{}' (expected 'ro' or 'rw'): {}",
last.unwrap(),
volume
)));
}

// Determine the guest path and mode based on whether a mode suffix exists.
// With mode: guest = parts[parts.len() - 2], mode = parts[parts.len() - 1]
// Without mode: guest = parts[parts.len() - 1]
let (guest_path_str, mode_str) = if has_mode {
let guest = parts[parts.len() - 2];
let mode = parts[parts.len() - 1];
(guest, mode)
} else {
(parts[parts.len() - 1], "")
};

// Validate guest path is not empty or a mode keyword
if guest_path_str.is_empty()
|| guest_path_str == "ro"
|| guest_path_str == "rw"
{
return Err(BoxError::ConfigError(format!(
"Invalid volume format (expected host:guest[:ro|rw]): {}",
volume
)));
}

// Determine read_only from mode string
let read_only = match mode_str {
"ro" => true,
"rw" => false,
other if !other.is_empty() => {
return Err(BoxError::ConfigError(format!(
"Invalid volume format (expected host:guest[:ro|rw]): {}",
volume
"Invalid volume mode '{}' (expected 'ro' or 'rw'): {}",
other, volume
)));
}
_ => false,
};

// Reconstruct the host path from parts:
// - If parts[0] is a single-letter (Windows drive letter), reconstruct the
// Windows path by joining parts[0..guest_idx] with colons.
// - Otherwise (Unix), join parts[0..guest_idx] with colons.
let guest_idx = if has_mode { parts.len() - 2 } else { parts.len() - 1 };
let host_path_str = if parts[0].len() == 1 {
// Windows drive letter — parts[0] is "C", parts[1..] is the rest of the path
let host_parts = &parts[..guest_idx];
let reconstructed = host_parts.join(":");
// If the reconstructed path ends with a trailing colon (from a trailing
// backslash in the original Windows path like "C:\path\:"), strip it.
reconstructed
.strip_suffix(':')
.map(|s| s.to_string())
.unwrap_or_else(|| reconstructed)
} else {
parts[..guest_idx].join(":")
};

// Resolve and validate host path
let host_path = PathBuf::from(host_path_str);
let host_path = PathBuf::from(&host_path_str);
if !host_path.exists() {
std::fs::create_dir_all(&host_path).map_err(|e| BoxError::BoxBootError {
message: format!(
Expand Down Expand Up @@ -436,7 +501,7 @@ impl VmManager {
tracing::info!(
tag = %tag,
host = %host_path.display(),
guest = _guest_path,
guest = guest_path_str,
read_only,
"Adding user volume mount"
);
Expand Down Expand Up @@ -612,7 +677,7 @@ mod tests {
};

let (exec, _args) = VmManager::resolve_oci_entrypoint(&config, &[], None);
assert_eq!(exec, "/sbin/init");
assert_eq!(exec, "/bin/sh");
}

#[test]
Expand Down
Loading