From 23726d7420e70b450ee729a4361ebaee89ff41ab Mon Sep 17 00:00:00 2001 From: Finn Sheng Date: Sun, 15 Feb 2026 18:09:38 -0500 Subject: [PATCH] Refactor (#10) * refactor: cleanup duplicated logic * refactor: cleanup config.rs * refactor: cleanup explain.rs * refactor: cleanup instance.rs * refactor: cleanup * refactor: use UnixStream instead of status file * Refactor vm lifetime (#8) * fix: handle vm supervisor being killed * fix: fixed the loop connection retry * refactor: extracted vm_manager liveness check logic * Script failure report (#9) * feat: added script failure report * feat: vm error report can also report ssh.sh * refactor: liveness check when connecting to ssh * fix: fixed the wrong InstanceError::VMError * fix: fixed the is_lock_stable --- Cargo.lock | 17 ++ Cargo.toml | 2 + docs/tasks.md | 24 +- src/bin/vibebox-supervisor.rs | 15 +- src/bin/vibebox.rs | 148 ++++----- src/config.rs | 544 +++++++++++++++++++++++++++++----- src/error_report.sh | 28 ++ src/explain.rs | 79 ++--- src/instance.rs | 449 ++++++++++++++++++---------- src/lib.rs | 1 + src/session_manager.rs | 149 ++++------ src/ssh.sh | 23 +- src/tui.rs | 62 ++-- src/utils.rs | 18 ++ src/vm.rs | 285 ++++++++---------- src/vm_manager.rs | 318 +++++++++++++------- 16 files changed, 1362 insertions(+), 800 deletions(-) create mode 100644 src/error_report.sh create mode 100644 src/utils.rs diff --git a/Cargo.lock b/Cargo.lock index ad3a86f..a9edcc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + [[package]] name = "assert_cmd" version = "2.1.2" @@ -144,6 +150,15 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytesize" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" +dependencies = [ + "serde_core", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -1305,8 +1320,10 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" name = "vibebox" version = "0.3.1" dependencies = [ + "anyhow", "assert_cmd", "block2", + "bytesize", "clap", "color-eyre", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 020ab3f..d033e46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,8 @@ ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } dialoguer = "0.12.0" +bytesize = {version = "2.3.1",features = ["serde"]} +anyhow = "1.0.101" [dev-dependencies] assert_cmd = "2" diff --git a/docs/tasks.md b/docs/tasks.md index dba5d8e..fe3ee2e 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -25,7 +25,7 @@ 5. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar. 6. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio. 7. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration. -8. [ ] Run tests and validate coverage for the new module. +8. [x] Run tests and validate coverage for the new module. ## TUI @@ -49,19 +49,23 @@ 4. [x] set up the cli. 9. [x] fix ui overlap, and consistency issue. 10. [x] `purge-cache` to clear the cache. -11. [ ] intensive integration test. +11. [x] intensive integration test. ## Publish -1. [ ] write the docs. -2. [ ] setup quick install link. -3. [ ] setup website. +1. [x] write the docs. +2. [x] setup quick install link. +3. [x] setup website. ## Stage 2 1. [ ] retouch the cli ux. -2. [ ] refactor the code. -3. [ ] Redirect vm output to log. -4. [ ] Redirect vm output to vibebox starting it. -5. [ ] use anyhow to sync api. -6. [ ] add support for ipv6. +2. [x] refactor the code. +3. [ ] refactor the mount system. +4. [x] refactor the vm process lifetime. +5. [x] Redirect vm output to log. +6. [x] Redirect vm output to vibebox starting it. +7. [x] use anyhow to sync api. +8. [ ] add support for ipv6. +9. [x] use UnixStream instead of status file +10. [x] liveness check should also happen when waiting for ssh port diff --git a/src/bin/vibebox-supervisor.rs b/src/bin/vibebox-supervisor.rs index 218a4c4..e239985 100644 --- a/src/bin/vibebox-supervisor.rs +++ b/src/bin/vibebox-supervisor.rs @@ -21,15 +21,16 @@ fn main() -> Result<()> { color_eyre::install()?; tracing::info!("starting vm supervisor"); - let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - let config = config::load_config(&cwd); - let instance_dir = instance::ensure_instance_dir(&cwd) + let project_dir = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + let config = config::load_config(&project_dir) .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - let _ = instance::touch_last_active(&instance_dir); + let _ = instance::ensure_instance_dir(&project_dir) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + let _ = instance::touch_last_active(&project_dir); let args = vm::VmArg { cpu_count: config.box_cfg.cpu_count, - ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024), - disk_bytes: config.box_cfg.disk_gb.saturating_mul(1024 * 1024 * 1024), + ram_bytes: config.box_cfg.ram_size.as_u64(), + disk_bytes: config.box_cfg.disk_size.as_u64(), no_default_mounts: false, mounts: config.box_cfg.mounts.clone(), }; @@ -37,7 +38,7 @@ fn main() -> Result<()> { tracing::info!(auto_shutdown_ms, "vm supervisor config"); let result = vm_manager::run_manager(args, auto_shutdown_ms); - let _ = instance::touch_last_active(&instance_dir); + let _ = instance::touch_last_active(&project_dir); if let Err(err) = result { tracing::error!(error = %err, "vm supervisor exited"); return Err(color_eyre::eyre::eyre!(err.to_string())); diff --git a/src/bin/vibebox.rs b/src/bin/vibebox.rs index 000f817..5ae6e66 100644 --- a/src/bin/vibebox.rs +++ b/src/bin/vibebox.rs @@ -1,3 +1,7 @@ +use bytesize::ByteSize; +use clap::Parser; +use color_eyre::Result; +use dialoguer::Confirm; use std::{ env, ffi::OsString, @@ -6,10 +10,6 @@ use std::{ path::{Path, PathBuf}, sync::{Arc, Mutex}, }; - -use clap::Parser; -use color_eyre::Result; -use dialoguer::Confirm; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use tracing_subscriber::filter::LevelFilter; @@ -17,8 +17,9 @@ use tracing_subscriber::registry::Registry; use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload}; use vibebox::tui::{AppState, VmInfo}; +use vibebox::utils::relative_to_home; use vibebox::{ - SessionManager, commands, config, explain, instance, session_manager, tui, vm, vm_manager, + SessionManager, commands, config, explain, instance, session_manager, tui, vm_manager, }; #[derive(Debug, Parser)] @@ -56,53 +57,26 @@ fn main() -> Result<()> { let config_override = cli.config.clone(); let raw_args: Vec = env::args_os().collect(); - let config = config::load_config_with_path(&cwd, config_override.as_deref()); + let config = config::load_config_with_path(&cwd, config_override.as_deref()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; - if env::var("VIBEBOX_VM_MANAGER").as_deref() == Ok("1") { - tracing::info!("starting vm manager mode"); - let args = vm::VmArg { - cpu_count: config.box_cfg.cpu_count, - ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024), - disk_bytes: config.box_cfg.disk_gb.saturating_mul(1024 * 1024 * 1024), - no_default_mounts: false, - mounts: config.box_cfg.mounts.clone(), - }; - let auto_shutdown_ms = config.supervisor.auto_shutdown_ms; - tracing::info!(auto_shutdown_ms, "vm manager config"); - if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) { - tracing::error!(error = %err, "vm manager exited"); - return Err(color_eyre::eyre::eyre!(err.to_string())); - } - return Ok(()); - } - - vm::ensure_signed(); - - let vm_args = vm::VmArg { - cpu_count: config.box_cfg.cpu_count, - ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024), - disk_bytes: config.box_cfg.disk_gb.saturating_mul(1024 * 1024 * 1024), - no_default_mounts: false, - mounts: config.box_cfg.mounts.clone(), - }; - let auto_shutdown_ms = config.supervisor.auto_shutdown_ms; let vm_info = VmInfo { - max_memory_mb: vm_args.ram_bytes / (1024 * 1024), - cpu_cores: vm_args.cpu_count, - max_disk_gb: (vm_args.disk_bytes as f32) / 1024.0 / 1024.0 / 1024.0, + max_memory: config.box_cfg.ram_size, + cpu_cores: config.box_cfg.cpu_count, + max_disk: config.box_cfg.disk_size, system_name: "Debian".to_string(), // TODO: read system name from the VM. - auto_shutdown_ms, + auto_shutdown_ms: config.supervisor.auto_shutdown_ms, }; if let Ok(manager) = SessionManager::new() { if let Err(err) = manager.update_global_sessions(&cwd) { tracing::warn!(error = %err, "failed to update a global session list"); } } else { - tracing::warn!("failed to initialize session manager"); + tracing::error!("failed to initialize session manager"); + std::process::exit(1); } let commands = commands::build_commands(); let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info, commands))); - { let mut locked = app.lock().expect("app state poisoned"); tui::render_tui_once(&mut locked)?; @@ -112,24 +86,40 @@ fn main() -> Result<()> { writeln!(stdout)?; stdout.flush()?; } - warn_disk_size_mismatch(&cwd, vm_args.disk_bytes); + warn_disk_size_mismatch(&cwd, config.box_cfg.disk_size); if let Some(handle) = stderr_handle { let _ = handle.modify(|filter| *filter = LevelFilter::INFO); } - tracing::debug!(auto_shutdown_ms, "auto shutdown config"); - let manager_conn = - vm_manager::ensure_manager(&raw_args, auto_shutdown_ms, config_override.as_deref()) - .map_err(|err| { - tracing::error!(error = %err, "failed to ensure vm manager"); - color_eyre::eyre::eyre!(err.to_string()) - })?; - - instance::run_with_ssh(manager_conn).map_err(|err| { + tracing::debug!(config.supervisor.auto_shutdown_ms, "auto shutdown config"); + let manager_conn = vm_manager::ensure_manager( + &raw_args, + config.supervisor.auto_shutdown_ms, + config_override.as_deref(), + ) + .map_err(|err| { tracing::error!(error = %err, "failed to ensure vm manager"); color_eyre::eyre::eyre!(err.to_string()) })?; + if let Err(err) = instance::run_with_ssh(manager_conn) { + if let Some(instance::InstanceError::UnexpectedDisconnection) = + err.downcast_ref::() + { + tracing::warn!("vm manager disconnected; exiting vibebox"); + } else if let Some(instance::InstanceError::VMError(vm_error)) = + err.downcast_ref::() + { + tracing::error!("[vm]: {vm_error}"); + tracing::info!("vibecoding paused: the VM says today is a rest day 😴"); + std::process::exit(1); + } else { + let message = err.to_string(); + tracing::error!(error = %message, "vibebox exited: uncaught error"); + return Err(color_eyre::eyre::eyre!(message)); + } + } + tracing::info!("See you again — keep vibecoding (no SEVs, only vibes) 😈"); Ok(()) @@ -211,13 +201,14 @@ fn handle_command(command: Command, cwd: &Path, config_override: Option<&Path>) "Purged {} file{} totaling {} from {}", file_count, if file_count == 1 { "" } else { "s" }, - format_bytes(total_bytes), + ByteSize(total_bytes), cache_dir.display() ); Ok(()) } Command::Explain => { - let config = config::load_config_with_path(cwd, config_override); + let config = config::load_config_with_path(cwd, config_override) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; let mounts = explain::build_mount_rows(cwd, &config) .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; let networks = explain::build_network_rows(cwd) @@ -240,20 +231,6 @@ fn project_name(directory: &Path) -> String { .to_string() } -fn relative_to_home(directory: &Path) -> String { - let Ok(home) = env::var("HOME") else { - return directory.display().to_string(); - }; - let home_path = PathBuf::from(home); - if let Ok(stripped) = directory.strip_prefix(&home_path) { - if stripped.components().next().is_none() { - return "~".to_string(); - } - return format!("~/{}", stripped.display()); - } - directory.display().to_string() -} - fn cache_dir() -> Result { let home = env::var("HOME").map(PathBuf::from)?; let cache_home = env::var("XDG_CACHE_HOME") @@ -302,23 +279,6 @@ fn measure_dir(path: &Path) -> Result<(u64, u64)> { Ok((file_count, total_bytes)) } -fn format_bytes(bytes: u64) -> String { - const KB: f64 = 1024.0; - const MB: f64 = KB * 1024.0; - const GB: f64 = MB * 1024.0; - - let b = bytes as f64; - if b >= GB { - format!("{:.2} GB", b / GB) - } else if b >= MB { - format!("{:.1} MB", b / MB) - } else if b >= KB { - format!("{:.1} KB", b / KB) - } else { - format!("{} B", bytes) - } -} - fn format_last_active(value: Option<&str>) -> String { let Some(raw) = value else { return "-".to_string(); @@ -360,24 +320,22 @@ fn format_last_active(value: Option<&str>) -> String { format!("{} day{} ago", days, if days == 1 { "" } else { "s" }) } -fn warn_disk_size_mismatch(cwd: &Path, configured_bytes: u64) { +fn warn_disk_size_mismatch(cwd: &Path, configured_size: ByteSize) { let instance_raw = cwd .join(session_manager::INSTANCE_DIR_NAME) .join("instance.raw"); let Ok(meta) = fs::metadata(&instance_raw) else { return; }; - let current_bytes = meta.len(); - if current_bytes == configured_bytes { + let current_size = ByteSize::b(meta.len()); + if current_size == configured_size { return; } - let current_gb = current_bytes as f64 / (1024.0 * 1024.0 * 1024.0); - let target_gb = configured_bytes as f64 / (1024.0 * 1024.0 * 1024.0); tracing::warn!( - "instance disk size does not match config (current {:.2} GB, config {:.2} GB). \ + "instance disk size does not match config (current {}, config {}). \ disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using the existing disk.", - current_gb, - target_gb + current_size, + configured_size, ); } @@ -386,13 +344,13 @@ type StderrHandle = reload::Handle; fn init_tracing(cwd: &Path) -> Option { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug")); let file_filter = filter.clone(); - let stderr_is_tty = std::io::stderr().is_terminal(); + let stderr_is_tty = io::stderr().is_terminal(); let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err(); let file = instance::ensure_instance_dir(cwd) .ok() .and_then(|instance_dir| { let log_path = instance_dir.join("cli.log"); - std::fs::OpenOptions::new() + fs::OpenOptions::new() .create(true) .write(true) .truncate(true) @@ -406,7 +364,7 @@ fn init_tracing(cwd: &Path) -> Option { .with_target(false) .with_ansi(ansi) .without_time() - .with_writer(std::io::stderr) + .with_writer(io::stderr) .with_filter(stderr_filter); let subscriber = tracing_subscriber::registry().with(stderr_layer); if let Some(file) = file { @@ -424,7 +382,7 @@ fn init_tracing(cwd: &Path) -> Option { let stderr_layer = fmt::layer() .with_target(false) .with_ansi(ansi) - .with_writer(std::io::stderr) + .with_writer(io::stderr) .with_filter(filter); let subscriber = tracing_subscriber::registry().with(stderr_layer); if let Some(file) = file { diff --git a/src/config.rs b/src/config.rs index 4768128..3fdd0f3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,13 +1,14 @@ +use anyhow::{Context, Error, Result, bail}; +use bytesize::ByteSize; +use serde::{Deserialize, Serialize}; use std::{ env, fs, io, path::{Path, PathBuf}, }; -use serde::{Deserialize, Serialize}; - use crate::vm::DirectoryShare; -pub const CONFIG_FILENAME: &str = "vibebox.toml"; +const CONFIG_FILENAME: &str = "vibebox.toml"; pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH"; const DEFAULT_CPU_COUNT: usize = 2; @@ -22,20 +23,83 @@ pub struct Config { pub supervisor: SupervisorConfig, } +const MI_B: u64 = 1024 * 1024; +const GI_B: u64 = 1024 * 1024 * 1024; + +mod serde_mb { + use super::{ByteSize, MI_B}; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(v: &ByteSize, s: S) -> Result + where + S: Serializer, + { + let bytes = v.0; + if !bytes.is_multiple_of(MI_B) { + return Err(serde::ser::Error::custom( + "ram_mb must be an integer number of MB", + )); + } + s.serialize_u64(bytes / MI_B) + } + + pub fn deserialize<'de, D>(d: D) -> Result + where + D: Deserializer<'de>, + { + let mb = u64::deserialize(d)?; + let bytes = mb + .checked_mul(MI_B) + .ok_or_else(|| serde::de::Error::custom("ram_mb overflow"))?; + Ok(ByteSize(bytes)) + } +} + +mod serde_gb { + use super::{ByteSize, GI_B}; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(v: &ByteSize, s: S) -> Result + where + S: Serializer, + { + let bytes = v.0; + if !bytes.is_multiple_of(GI_B) { + return Err(serde::ser::Error::custom( + "disk_gb must be an integer number of GB", + )); + } + s.serialize_u64(bytes / GI_B) + } + + pub fn deserialize<'de, D>(d: D) -> Result + where + D: Deserializer<'de>, + { + let gb = u64::deserialize(d)?; + let bytes = gb + .checked_mul(GI_B) + .ok_or_else(|| serde::de::Error::custom("disk_gb overflow"))?; + Ok(ByteSize(bytes)) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BoxConfig { pub cpu_count: usize, - pub ram_mb: u64, - pub disk_gb: u64, + #[serde(rename = "ram_mb", with = "serde_mb")] + pub ram_size: ByteSize, + #[serde(rename = "disk_gb", with = "serde_gb")] + pub disk_size: ByteSize, pub mounts: Vec, } impl Default for BoxConfig { fn default() -> Self { Self { - cpu_count: default_cpu_count(), - ram_mb: default_ram_mb(), - disk_gb: default_disk_gb(), + cpu_count: DEFAULT_CPU_COUNT, + ram_size: ByteSize::mib(DEFAULT_RAM_MB), + disk_size: ByteSize::gib(DEFAULT_DISK_GB), mounts: default_mounts(), } } @@ -49,23 +113,11 @@ pub struct SupervisorConfig { impl Default for SupervisorConfig { fn default() -> Self { Self { - auto_shutdown_ms: default_auto_shutdown_ms(), + auto_shutdown_ms: DEFAULT_AUTO_SHUTDOWN_MS, } } } -fn default_cpu_count() -> usize { - DEFAULT_CPU_COUNT -} - -fn default_ram_mb() -> u64 { - DEFAULT_RAM_MB -} - -fn default_auto_shutdown_ms() -> u64 { - DEFAULT_AUTO_SHUTDOWN_MS -} - fn default_mounts() -> Vec { vec![ "~/.codex:~/.codex:read-write".into(), @@ -73,19 +125,12 @@ fn default_mounts() -> Vec { ] } -fn default_disk_gb() -> u64 { - DEFAULT_DISK_GB -} - pub fn config_path(project_root: &Path) -> PathBuf { project_root.join(CONFIG_FILENAME) } -pub fn ensure_config_file( - project_root: &Path, - override_path: Option<&Path>, -) -> Result { - let path = resolve_config_path(project_root, override_path); +pub fn ensure_config_file(project_root: &Path, override_path: Option<&Path>) -> Result { + let path = resolve_config_path(project_root, override_path)?; if !path.exists() { let default_config = Config::default(); let contents = toml::to_string_pretty(&default_config).unwrap_or_default(); @@ -95,32 +140,24 @@ pub fn ensure_config_file( Ok(path) } -pub fn load_config(project_root: &Path) -> Config { +pub fn load_config(project_root: &Path) -> Result { load_config_with_path(project_root, None) } -pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Config { - let path = match ensure_config_file(project_root, override_path) { - Ok(path) => path, - Err(err) => die(&format!("failed to create config: {err}")), - }; - let raw = match fs::read_to_string(&path) { - Ok(raw) => raw, - Err(err) => die(&format!("failed to read config: {err}")), - }; +pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Result { + let path = + ensure_config_file(project_root, override_path).context("failed to create config")?; + let raw = fs::read_to_string(&path).context("failed to read config")?; let trimmed = raw.trim(); tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config"); if trimmed.is_empty() { - die(&format!( + bail!(format!( "config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (integer), [box].disk_gb (integer), [box].mounts (array of strings), [supervisor].auto_shutdown_ms (integer)", path.display() )); } - let value: toml::Value = match toml::from_str(trimmed) { - Ok(value) => value, - Err(err) => die(&format!("invalid config: {err}")), - }; + let value: toml::Value = toml::from_str(trimmed).context("invalid config")?; let schema_errors = validate_schema(&value); if !schema_errors.is_empty() { let message = format!( @@ -128,27 +165,28 @@ pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) path.display(), schema_errors.join("\n- ") ); - die(&message); + bail!(message); } - let config: Config = match toml::from_str(trimmed) { - Ok(config) => config, - Err(err) => die(&format!("invalid config: {err}")), - }; - validate_or_exit(&config); - config + let config: Config = toml::from_str(trimmed).context("invalid config")?; + validate_config(&config).map_err(Error::msg)?; + Ok(config) } -fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> PathBuf { - let root = match fs::canonicalize(project_root) { - Ok(root) => root, - Err(err) => die(&format!("failed to resolve project root: {err}")), - }; +fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Result { + let env_override = env::var_os(CONFIG_PATH_ENV).map(PathBuf::from); + resolve_config_path_inner(project_root, override_path, env_override) +} - let override_path = override_path - .map(PathBuf::from) - .or_else(|| env::var_os(CONFIG_PATH_ENV).map(PathBuf::from)); - let raw_path = if let Some(path) = override_path { +fn resolve_config_path_inner( + project_root: &Path, + override_path: Option<&Path>, + env_override: Option, +) -> Result { + let root = fs::canonicalize(project_root).context("failed to resolve project root")?; + + let selected_path = override_path.map(PathBuf::from).or(env_override); + let raw_path = if let Some(path) = selected_path { if path.is_absolute() { path } else { @@ -159,14 +197,50 @@ fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Pat }; let normalized = normalize_path(&raw_path); - if !normalized.starts_with(&root) { - die(&format!( + let resolved = + resolve_path_for_boundary_check(&normalized).context("failed to resolve config path")?; + if !resolved.starts_with(&root) { + bail!( "config path must be within {}: {}", root.display(), - normalized.display() - )); + resolved.display() + ); } - normalized + Ok(normalized) +} + +fn resolve_path_for_boundary_check(path: &Path) -> Result { + if path.exists() { + return fs::canonicalize(path); + } + let (ancestor, missing) = nearest_existing_ancestor(path)?; + let mut resolved = fs::canonicalize(ancestor)?; + for part in missing { + resolved.push(part); + } + Ok(resolved) +} + +fn nearest_existing_ancestor(path: &Path) -> Result<(&Path, Vec), io::Error> { + let mut current = path; + let mut missing = Vec::new(); + while !current.exists() { + let name = current.file_name().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("path has no existing ancestor: {}", path.display()), + ) + })?; + missing.push(name.to_os_string()); + current = current.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("path has no parent: {}", path.display()), + ) + })?; + } + missing.reverse(); + Ok((current, missing)) } fn normalize_path(path: &Path) -> PathBuf { @@ -263,27 +337,343 @@ fn validate_string_array( } } -fn validate_or_exit(config: &Config) { +fn validate_config(config: &Config) -> Result<(), String> { if config.box_cfg.cpu_count == 0 { - die("box.cpu_count must be >= 1"); + return Err("box.cpu_count must be >= 1".to_string()); } - if config.box_cfg.ram_mb == 0 { - die("box.ram_mb must be >= 1"); + if config.box_cfg.ram_size.as_mib() == 0.0 { + return Err("box.ram_mb must be >= 1".to_string()); } - if config.box_cfg.disk_gb == 0 { - die("box.disk_gb must be >= 1"); + if config.box_cfg.disk_size.as_gib() == 0.0 { + return Err("box.disk_gb must be >= 1".to_string()); } if config.supervisor.auto_shutdown_ms == 0 { - die("supervisor.auto_shutdown_ms must be >= 1"); + return Err("supervisor.auto_shutdown_ms must be >= 1".to_string()); } for spec in &config.box_cfg.mounts { if let Err(err) = DirectoryShare::from_mount_spec(spec) { - die(&format!("invalid mount spec '{spec}': {err}")); + return Err(format!("invalid mount spec '{spec}': {err}")); } } + Ok(()) } -fn die(message: &str) -> ! { - tracing::error!("{message}"); - std::process::exit(1); +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + use std::sync::Mutex; + use tempfile::TempDir; + + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + struct EnvGuard { + key: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &Path) -> Self { + let previous = env::var_os(key); + unsafe { + env::set_var(key, value); + } + Self { key, previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => unsafe { + env::set_var(self.key, value); + }, + None => unsafe { + env::remove_var(self.key); + }, + } + } + } + + #[test] + fn default_config_serializes_with_legacy_keys() { + let cfg = Config::default(); + let serialized = toml::to_string(&cfg).expect("default config should serialize"); + + assert!(serialized.contains("ram_mb = 2048")); + assert!(serialized.contains("disk_gb = 5")); + assert!(!serialized.contains("ram_size")); + assert!(!serialized.contains("disk_size")); + } + + #[test] + fn config_deserializes_sizes_from_mb_and_gb() { + let raw = r#" +[box] +cpu_count = 4 +ram_mb = 3072 +disk_gb = 12 +mounts = ["~/src:~/src:read-write"] + +[supervisor] +auto_shutdown_ms = 15000 +"#; + let cfg: Config = toml::from_str(raw).expect("config should deserialize"); + + assert_eq!(cfg.box_cfg.cpu_count, 4); + assert_eq!(cfg.box_cfg.ram_size.as_u64(), ByteSize::mib(3072).as_u64()); + assert_eq!(cfg.box_cfg.disk_size.as_u64(), ByteSize::gib(12).as_u64()); + assert_eq!(cfg.supervisor.auto_shutdown_ms, 15000); + } + + #[test] + fn serialize_rejects_non_integral_mb_or_gb() { + let cfg = Config { + box_cfg: BoxConfig { + cpu_count: 2, + ram_size: ByteSize::b((2 * MI_B) + 1), + disk_size: ByteSize::gib(5), + mounts: default_mounts(), + }, + supervisor: SupervisorConfig::default(), + }; + + let err = toml::to_string(&cfg).expect_err("serialization should reject invalid MB"); + assert!( + err.to_string() + .contains("ram_mb must be an integer number of MB") + ); + + let cfg = Config { + box_cfg: BoxConfig { + cpu_count: 2, + ram_size: ByteSize::mib(2048), + disk_size: ByteSize::b((5 * GI_B) + 1), + mounts: default_mounts(), + }, + supervisor: SupervisorConfig::default(), + }; + + let err = toml::to_string(&cfg).expect_err("serialization should reject invalid GB"); + assert!( + err.to_string() + .contains("disk_gb must be an integer number of GB") + ); + } + + #[test] + fn normalize_path_removes_dot_and_parent_components() { + let normalized = normalize_path(Path::new("/tmp/project/./nested/../config.toml")); + assert_eq!(normalized, PathBuf::from("/tmp/project/config.toml")); + } + + #[test] + fn validate_schema_returns_errors_for_missing_required_fields() { + let value: toml::Value = toml::from_str( + r#" +[box] +cpu_count = 2 +"#, + ) + .expect("toml should parse"); + + let errors = validate_schema(&value); + + assert!(errors.iter().any(|e| e == "missing [supervisor] table")); + assert!(errors.iter().any(|e| e == "missing [box].ram_mb (integer)")); + assert!( + errors + .iter() + .any(|e| e == "missing [box].disk_gb (integer)") + ); + assert!( + errors + .iter() + .any(|e| e == "missing [box].mounts (array of strings)") + ); + } + + #[test] + fn validate_schema_errors_when_supervisor_is_not_table() { + let value: toml::Value = toml::from_str( + r#" +supervisor = 123 + +[box] +cpu_count = 2 +ram_mb = 2048 +disk_gb = 5 +mounts = [] +"#, + ) + .expect("toml should parse"); + + let errors = validate_schema(&value); + assert!(errors.iter().any(|e| e == "[supervisor] must be a table")); + } + + #[test] + fn ensure_config_file_creates_default_config_if_absent() { + let temp = TempDir::new().expect("temp dir should be created"); + let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize"); + + let path = ensure_config_file(&root, None).expect("config should be created"); + let raw = fs::read_to_string(&path).expect("created config should be readable"); + let parsed: Config = toml::from_str(&raw).expect("created config should be valid"); + + assert_eq!(path, root.join("vibebox.toml")); + assert_eq!(parsed.box_cfg.cpu_count, DEFAULT_CPU_COUNT); + assert_eq!( + parsed.box_cfg.ram_size.as_u64(), + ByteSize::mib(DEFAULT_RAM_MB).as_u64() + ); + assert_eq!( + parsed.box_cfg.disk_size.as_u64(), + ByteSize::gib(DEFAULT_DISK_GB).as_u64() + ); + } + + #[test] + fn load_config_creates_and_loads_default_config() { + let _lock = ENV_MUTEX.lock().expect("env lock should be acquired"); + let temp = TempDir::new().expect("temp dir should be created"); + let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize"); + let home = root.join("home"); + fs::create_dir_all(home.join(".codex")).expect("home .codex should be created"); + fs::create_dir_all(home.join(".claude")).expect("home .claude should be created"); + let _home_guard = EnvGuard::set("HOME", &home); + + let cfg = load_config(&root).expect("load_config should succeed"); + + assert_eq!(cfg.box_cfg.cpu_count, DEFAULT_CPU_COUNT); + assert_eq!( + cfg.box_cfg.ram_size.as_u64(), + ByteSize::mib(DEFAULT_RAM_MB).as_u64() + ); + assert_eq!( + cfg.box_cfg.disk_size.as_u64(), + ByteSize::gib(DEFAULT_DISK_GB).as_u64() + ); + assert!(root.join("vibebox.toml").exists()); + } + + #[test] + fn load_config_with_path_uses_override_path() { + let _lock = ENV_MUTEX.lock().expect("env lock should be acquired"); + let temp = TempDir::new().expect("temp dir should be created"); + let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize"); + let home = root.join("home"); + fs::create_dir_all(home.join(".codex")).expect("home .codex should be created"); + fs::create_dir_all(home.join(".claude")).expect("home .claude should be created"); + let _home_guard = EnvGuard::set("HOME", &home); + let override_path = root.join("custom.toml"); + + fs::write( + &override_path, + r#" +[box] +cpu_count = 6 +ram_mb = 4096 +disk_gb = 9 +mounts = ["~/.codex:~/.codex:read-write", "~/.claude:~/.claude:read-write"] + +[supervisor] +auto_shutdown_ms = 12345 +"#, + ) + .expect("override config should be written"); + + let cfg = load_config_with_path(&root, Some(Path::new("custom.toml"))) + .expect("load_config_with_path should succeed"); + + assert_eq!(cfg.box_cfg.cpu_count, 6); + assert_eq!(cfg.box_cfg.ram_size.as_u64(), ByteSize::mib(4096).as_u64()); + assert_eq!(cfg.box_cfg.disk_size.as_u64(), ByteSize::gib(9).as_u64()); + assert_eq!(cfg.supervisor.auto_shutdown_ms, 12345); + assert!(!root.join("vibebox.toml").exists()); + } + + #[test] + fn resolve_config_path_uses_env_override_when_cli_override_missing() { + let temp = TempDir::new().expect("temp dir should be created"); + let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize"); + + let resolved = resolve_config_path_inner(&root, None, Some(PathBuf::from("custom.toml"))) + .expect("env override path should resolve"); + + assert_eq!(resolved, root.join("custom.toml")); + } + + #[test] + fn resolve_config_path_rejects_env_override_outside_project() { + let temp = TempDir::new().expect("temp dir should be created"); + let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize"); + + let err = resolve_config_path_inner(&root, None, Some(PathBuf::from("../escape.toml"))) + .expect_err("outside-project path should be rejected"); + + assert!( + err.to_string().contains("config path must be within"), + "expected bounds-check error, got: {err}" + ); + } + + #[test] + fn validate_config_rejects_invalid_values() { + let cfg = Config { + box_cfg: BoxConfig { + cpu_count: 0, + ram_size: ByteSize::mib(2048), + disk_size: ByteSize::gib(5), + mounts: vec![], + }, + supervisor: SupervisorConfig::default(), + }; + let err = validate_config(&cfg).expect_err("cpu_count=0 should fail"); + assert_eq!(err, "box.cpu_count must be >= 1"); + + let cfg = Config { + box_cfg: BoxConfig { + cpu_count: 2, + ram_size: ByteSize::mib(2048), + disk_size: ByteSize::gib(5), + mounts: vec!["/definitely/missing:/tmp/missing:read-write".to_string()], + }, + supervisor: SupervisorConfig::default(), + }; + let err = validate_config(&cfg).expect_err("invalid mount should fail"); + assert!(err.starts_with("invalid mount spec")); + } + + #[test] + fn resolve_config_path_accepts_symlinked_project_root() { + let temp = TempDir::new().expect("temp dir should be created"); + let actual_root = temp.path().join("actual"); + let link_root = temp.path().join("linked"); + fs::create_dir_all(&actual_root).expect("actual root should exist"); + std::os::unix::fs::symlink(&actual_root, &link_root).expect("symlink should be created"); + + let resolved = resolve_config_path_inner(&link_root, Some(Path::new("vibebox.toml")), None) + .expect("symlinked project root should resolve"); + + assert_eq!(resolved, link_root.join("vibebox.toml")); + } + + #[test] + fn resolve_config_path_rejects_symlink_escape() { + let temp = TempDir::new().expect("temp dir should be created"); + let project_root = temp.path().join("project"); + let outside_root = temp.path().join("outside"); + fs::create_dir_all(&project_root).expect("project root should exist"); + fs::create_dir_all(&outside_root).expect("outside root should exist"); + std::os::unix::fs::symlink(&outside_root, project_root.join("link")) + .expect("escape symlink should be created"); + + let err = resolve_config_path_inner(&project_root, Some(Path::new("link/cfg.toml")), None) + .expect_err("symlink escape should be rejected"); + assert!( + err.to_string().contains("config path must be within"), + "expected bounds-check error, got: {err}" + ); + } } diff --git a/src/error_report.sh b/src/error_report.sh new file mode 100644 index 0000000..6f8a3d7 --- /dev/null +++ b/src/error_report.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +__vibebox_err_reported=0 +__vibebox_report_error() { + local rc="$1" + local line="$2" + local msg="${3:-}" + if [ "$__vibebox_err_reported" -eq 0 ]; then + msg="${msg//$'\n'/ }" + msg="${msg//$'\r'/ }" + if [ -n "$msg" ]; then + echo "VIBEBOX_SCRIPT_ERROR:__LABEL__:${line}:${rc} ${msg}" + else + echo "VIBEBOX_SCRIPT_ERROR:__LABEL__:${line}:${rc}" + fi + __vibebox_err_reported=1 + fi +} +vibebox_fail() { + local msg="${1:-script failed}" + local rc="${2:-1}" + __vibebox_report_error "$rc" "${LINENO}" "$msg" + exit "$rc" +} +trap 'rc="$?"; __vibebox_report_error "$rc" "${LINENO}" "command failed: ${BASH_COMMAND:-unknown}"' ERR +trap 'rc="$?"; if [ "$rc" -ne 0 ]; then __vibebox_report_error "$rc" "${LINENO}" "script exited with code ${rc}"; fi' EXIT + +__SCRIPT_BODY__ diff --git a/src/explain.rs b/src/explain.rs index 519e325..e6ae9b5 100644 --- a/src/explain.rs +++ b/src/explain.rs @@ -1,31 +1,31 @@ +use crate::instance::InstanceConfig; +use crate::utils::relative_to_home; +use crate::{config, instance, session_manager, tui}; +use anyhow::{Context, Result, bail}; use std::{ env, - error::Error, path::{Path, PathBuf}, }; -use crate::{config, instance, session_manager, tui}; - -pub fn build_mount_rows( - cwd: &Path, - config: &config::Config, -) -> Result, Box> { +pub fn build_mount_rows(project: &Path, config: &config::Config) -> Result> { let mut rows = Vec::new(); - rows.extend(default_mounts(cwd)?); - let guest_home = resolve_guest_home(cwd)?; + rows.extend(default_mounts(project)?); + let guest_home = resolve_guest_home(project); for spec in &config.box_cfg.mounts { - rows.push(parse_mount_spec(cwd, spec, false, &guest_home)?); + rows.push(parse_mount_spec(project, spec, false, &guest_home)?); } Ok(rows) } -pub fn build_network_rows( - cwd: &Path, -) -> Result, Box> { - let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME); +pub fn build_network_rows(project_dir: &Path) -> Result> { let mut vm_ip = "-".to_string(); - if let Ok(Some(ip)) = instance::read_instance_vm_ip(&instance_dir) { - vm_ip = ip; + if let Ok(config) = instance::read_instance_config(project_dir) { + match config.vm_ipv4 { + None => {} + Some(ip) => { + vm_ip = ip; + } + } } let host_to_vm = if vm_ip == "-" { "ssh: :22".to_string() @@ -41,13 +41,13 @@ pub fn build_network_rows( Ok(vec![row]) } -fn default_mounts(cwd: &Path) -> Result, Box> { +fn default_mounts(cwd: &Path) -> Result> { let project_name = cwd .file_name() .and_then(|name| name.to_str()) - .unwrap_or("project"); + .with_context(|| "failed to get project name")?; let project_guest = format!("~/{project_name}"); - let project_host = display_path(cwd); + let project_host = relative_to_home(cwd); let mut rows = vec![tui::MountListRow { host: project_host, guest: project_guest, @@ -57,14 +57,14 @@ fn default_mounts(cwd: &Path) -> Result, Box Result> { +) -> Result { let parts: Vec<&str> = spec.split(':').collect(); if parts.len() < 2 || parts.len() > 3 { - return Err(format!("invalid mount spec: {spec}").into()); + bail!["invalid mount spec: {spec}"]; } let host_part = parts[0]; let guest_part = parts[1]; @@ -89,11 +89,10 @@ fn parse_mount_spec( "read-only" => "read-only", "read-write" => "read-write", other => { - return Err(format!( + bail![format!( "invalid mount mode '{}'; expected read-only or read-write", other - ) - .into()); + )]; } } } else { @@ -116,22 +115,22 @@ fn display_host_spec(cwd: &Path, host: &str) -> String { } let host_path = PathBuf::from(host); if host_path.is_absolute() { - return display_path(&host_path); + return relative_to_home(&host_path); } let candidate = cwd.join(&host_path); if candidate.is_absolute() { - display_path(&candidate) + relative_to_home(&candidate) } else { host.to_string() } } -fn resolve_guest_home(cwd: &Path) -> Result> { - let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME); - if let Ok(Some(user)) = instance::read_instance_ssh_user(&instance_dir) { - return Ok(format!("/home/{user}")); +fn resolve_guest_home(project_dir: &Path) -> String { + let config = instance::read_instance_config(project_dir); + match config { + Ok(config) => format!("/home/{}", config.ssh_user), + Err(_) => format!("/home/{}", InstanceConfig::default().ssh_user), } - Ok(format!("/home/{}", instance::DEFAULT_SSH_USER)) } fn resolve_guest_display(guest: &str, guest_home: &str) -> String { @@ -155,17 +154,3 @@ fn resolve_guest_display(guest: &str, guest_home: &str) -> String { format!("/root/{guest}") } } - -fn display_path(path: &Path) -> String { - let Ok(home) = env::var("HOME") else { - return path.display().to_string(); - }; - let home_path = PathBuf::from(home); - if let Ok(stripped) = path.strip_prefix(&home_path) { - if stripped.components().next().is_none() { - return "~".to_string(); - } - return format!("~/{}", stripped.display()); - } - path.display().to_string() -} diff --git a/src/instance.rs b/src/instance.rs index f7b77e6..f8a349e 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -1,82 +1,140 @@ +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use std::os::unix::fs::FileTypeExt; use std::{ env, fs, - io::{self}, + io::{self, IsTerminal, Read}, net::{SocketAddr, TcpStream}, os::unix::{fs::PermissionsExt, net::UnixStream}, path::{Path, PathBuf}, - process::{Command, Stdio}, - sync::{Arc, Mutex}, + process::{Child, Command, Stdio}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + mpsc, + }, thread, time::{Duration, Instant}, }; - -use serde::{Deserialize, Serialize}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use uuid::Uuid; use crate::{ commands, - session_manager::{INSTANCE_DIR_NAME, INSTANCE_FILENAME}, + session_manager::INSTANCE_DIR_NAME, + session_manager::{VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME}, vm::{self, LoginAction}, }; +pub const STATUS_VM_ERROR_PREFIX: &str = "error:"; + const SSH_KEY_NAME: &str = "ssh_key"; -#[cfg_attr(feature = "mock-vm", allow(dead_code))] -pub(crate) const VM_ROOT_LOG_NAME: &str = "vm_root.log"; -pub(crate) const STATUS_FILE_NAME: &str = "status.txt"; -pub(crate) const DEFAULT_SSH_USER: &str = "vibecoder"; -const SSH_CONNECT_RETRIES: usize = 30; +const INSTANCE_FILENAME: &str = "instance.toml"; +const DEFAULT_SSH_USER: &str = "vibecoder"; +const SSH_CONNECT_RETRIES: usize = 10; const SSH_CONNECT_DELAY_MS: u64 = 500; const SSH_SETUP_SCRIPT: &str = include_str!("ssh.sh"); +const STATUS_PREFIX: &str = "status:"; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct InstanceConfig { - #[serde(default)] - id: String, - #[serde(default = "default_ssh_user")] - ssh_user: String, - #[serde(default)] - sudo_password: String, - #[serde(default)] - last_active: Option, - #[serde(default)] - pub(crate) vm_ipv4: Option, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VmLiveness { + RunningWithSocket { pid: u32 }, + RunningWithoutSocket { pid: u32 }, + NotRunningOrMissing, } -impl InstanceConfig { - pub(crate) fn ssh_user_display(&self) -> String { - if self.ssh_user.trim().is_empty() { - DEFAULT_SSH_USER.to_string() - } else { - self.ssh_user.clone() +pub fn vm_liveness(project_root: &Path) -> Result { + let instance_dir = ensure_instance_dir(project_root)?; + let pid_path = instance_dir.join(VM_MANAGER_PID_NAME); + let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME); + fn pid_is_alive(pid: u32) -> bool { + let pid = pid as libc::pid_t; + let result = unsafe { libc::kill(pid, 0) }; + if result == 0 { + return true; + } + match io::Error::last_os_error().raw_os_error() { + Some(code) if code == libc::EPERM => true, + Some(code) if code == libc::ESRCH => false, + _ => false, } } + let Ok(content) = fs::read_to_string(pid_path) else { + return Ok(VmLiveness::NotRunningOrMissing); + }; + let Ok(pid) = content.trim().parse::() else { + return Ok(VmLiveness::NotRunningOrMissing); + }; + if !pid_is_alive(pid) { + return Ok(VmLiveness::NotRunningOrMissing); + } + let has_socket = fs::metadata(socket_path) + .map(|meta| meta.file_type().is_socket()) + .unwrap_or(false); + if has_socket { + Ok(VmLiveness::RunningWithSocket { pid }) + } else { + Ok(VmLiveness::RunningWithoutSocket { pid }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum InstanceError { + #[error("unexpected disconnection from vm manager")] + UnexpectedDisconnection, + #[error("{0}")] + VMError(String), } fn default_ssh_user() -> String { DEFAULT_SSH_USER.to_string() } -pub fn run_with_ssh(manager_conn: UnixStream) -> Result<(), Box> { +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstanceConfig { + #[serde(default)] + pub id: String, + #[serde(default = "default_ssh_user")] + pub ssh_user: String, + #[serde(default)] + sudo_password: String, + #[serde(default)] + pub last_active: Option, + #[serde(default)] + pub vm_ipv4: Option, +} + +impl Default for InstanceConfig { + fn default() -> Self { + Self { + id: Uuid::now_v7().to_string(), + ssh_user: DEFAULT_SSH_USER.to_string(), + sudo_password: Uuid::now_v7().simple().to_string(), + last_active: None, + vm_ipv4: None, + } + } +} + +pub fn run_with_ssh(manager_conn: UnixStream) -> Result<()> { let project_root = env::current_dir()?; tracing::info!(root = %project_root.display(), "starting ssh session"); let instance_dir = ensure_instance_dir(&project_root)?; tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready"); let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?; - let config = load_or_create_instance_config(&instance_dir)?; + let config = load_or_create_instance_config(&project_root)?; let ssh_user = config.ssh_user.clone(); tracing::debug!(ssh_user = %ssh_user, "loaded instance config"); - let _manager_conn = manager_conn; - wait_for_vm_ipv4(&instance_dir, Duration::from_secs(480))?; + wait_for_vm_ipv4(&project_root, Duration::from_secs(480), &manager_conn)?; - let ip = load_or_create_instance_config(&instance_dir)? + let ip = load_or_create_instance_config(&project_root)? .vm_ipv4 - .ok_or("VM IPv4 not available")?; + .with_context(|| "failed to load instance IP address")?; tracing::info!(ip = %ip, "vm ipv4 ready"); - run_ssh_session(ssh_key, ssh_user, ip) + run_ssh_session(ssh_key, ssh_user, ip, manager_conn, project_root) } pub fn ensure_instance_dir(project_root: &Path) -> Result { @@ -85,9 +143,7 @@ pub fn ensure_instance_dir(project_root: &Path) -> Result { Ok(instance_dir) } -pub(crate) fn ensure_ssh_keypair( - instance_dir: &Path, -) -> Result<(PathBuf, PathBuf), Box> { +pub fn ensure_ssh_keypair(instance_dir: &Path) -> Result<(PathBuf, PathBuf)> { let private_key = instance_dir.join(SSH_KEY_NAME); let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub")); @@ -109,7 +165,9 @@ pub(crate) fn ensure_ssh_keypair( "-N", "", "-f", - private_key.to_str().ok_or("ssh key path not utf-8")?, + private_key + .to_str() + .with_context(|| "ssh key path not utf-8")?, "-C", "vibebox", ]) @@ -119,7 +177,7 @@ pub(crate) fn ensure_ssh_keypair( .status()?; if !status.success() { - return Err("ssh-keygen failed".into()); + bail!("ssh-keygen failed"); } fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?; @@ -128,98 +186,66 @@ pub(crate) fn ensure_ssh_keypair( Ok((private_key, public_key)) } -pub(crate) fn load_or_create_instance_config( - instance_dir: &Path, -) -> Result> { - let config_path = instance_dir.join(INSTANCE_FILENAME); - let mut config = if config_path.exists() { - let raw = fs::read_to_string(&config_path)?; - toml::from_str::(&raw)? - } else { - InstanceConfig { - id: String::new(), - ssh_user: default_ssh_user(), - sudo_password: String::new(), - last_active: None, - vm_ipv4: None, - } - }; +pub fn load_or_create_instance_config(project_dir: &Path) -> Result { + let mut exist = true; + let mut config = read_instance_config(project_dir).unwrap_or_else(|_| { + exist = false; + InstanceConfig::default() + }); let mut changed = false; if config.ssh_user.trim().is_empty() { - config.ssh_user = default_ssh_user(); + config.ssh_user = InstanceConfig::default().ssh_user; changed = true; } if config.id.trim().is_empty() { - config.id = Uuid::now_v7().to_string(); + config.id = InstanceConfig::default().id; changed = true; } if config.sudo_password.trim().is_empty() { - config.sudo_password = generate_password(); + config.sudo_password = InstanceConfig::default().sudo_password; changed = true; } - if !config_path.exists() || changed { - write_instance_config(&config_path, &config)?; + if !exist || changed { + write_instance_config(project_dir, &config)?; } Ok(config) } -fn read_instance_config( - instance_dir: &Path, -) -> Result, Box> { - let config_path = instance_dir.join(INSTANCE_FILENAME); +pub fn read_instance_config(project_dir: &Path) -> Result { + // todo maybe verify schema? + let config_path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME); if !config_path.exists() { - return Ok(None); + bail!("instance config file does not exist"); } let raw = fs::read_to_string(&config_path)?; let config = toml::from_str::(&raw)?; - Ok(Some(config)) + Ok(config) } -pub fn read_instance_vm_ip( - instance_dir: &Path, -) -> Result, Box> { - let config = read_instance_config(instance_dir)?; - Ok(config.and_then(|cfg| cfg.vm_ipv4)) -} - -pub fn read_instance_ssh_user( - instance_dir: &Path, -) -> Result, Box> { - let config = read_instance_config(instance_dir)?; - Ok(config - .map(|cfg| cfg.ssh_user) - .filter(|user| !user.trim().is_empty())) -} - -pub fn touch_last_active(instance_dir: &Path) -> Result<(), Box> { - let mut config = load_or_create_instance_config(instance_dir)?; +pub fn touch_last_active(project_dir: &Path) -> Result<()> { + let mut config = load_or_create_instance_config(project_dir)?; let now = OffsetDateTime::now_utc().format(&Rfc3339)?; config.last_active = Some(now); - write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?; + write_instance_config(project_dir, &config)?; Ok(()) } -pub(crate) fn write_instance_config( - path: &Path, - config: &InstanceConfig, -) -> Result<(), Box> { +pub fn write_instance_config(project_dir: &Path, config: &InstanceConfig) -> Result<()> { + let path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME); let data = toml::to_string_pretty(config)?; - fs::write(path, data)?; - fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + fs::create_dir_all(project_dir.join(INSTANCE_DIR_NAME))?; + fs::write(&path, data)?; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; Ok(()) } -fn generate_password() -> String { - Uuid::now_v7().simple().to_string() -} - #[cfg_attr(feature = "mock-vm", allow(dead_code))] -pub(crate) fn extract_ipv4(line: &str) -> Option { +pub fn extract_ipv4(line: &str) -> Option { let mut current = String::new(); let mut best: Option = None; @@ -238,51 +264,68 @@ pub(crate) fn extract_ipv4(line: &str) -> Option { best } +fn handle_manager_line(line: &str, last_status: &mut Option) -> Result<()> { + if let Some(status) = line.strip_prefix(STATUS_PREFIX) { + let status = status.trim(); + if let Some(message) = status.strip_prefix(STATUS_VM_ERROR_PREFIX) { + let message = message.trim(); + if message.is_empty() { + bail!("vm manager reported startup failure"); + } + return Err(InstanceError::VMError(message.to_string()).into()); + } + if !status.is_empty() && last_status.as_deref() != Some(status) { + tracing::info!("[background]: {}", status); + *last_status = Some(status.to_string()); + } + } + Ok(()) +} + fn wait_for_vm_ipv4( - instance_dir: &Path, + project_dir: &Path, timeout: Duration, -) -> Result<(), Box> { + manager_conn: &UnixStream, +) -> Result<()> { let start = Instant::now(); let mut next_log_at = start + Duration::from_secs(10); - let mut next_status_check = start; + let mut stream = manager_conn.try_clone()?; + let _ = stream.set_read_timeout(Some(Duration::from_millis(250))); + let mut read_buf = [0u8; 1024]; + let mut pending = String::new(); tracing::info!("waiting for vm ipv4"); - let status_path = instance_dir.join(STATUS_FILE_NAME); let mut last_status: Option = None; - let mut status_missing = true; let mut once_hint = false; loop { - let config = load_or_create_instance_config(instance_dir)?; + match stream.read(&mut read_buf) { + Ok(0) => { + bail!("vm manager disconnected before VM became ready"); + } + Ok(n) => { + pending.push_str(&String::from_utf8_lossy(&read_buf[..n])); + while let Some(pos) = pending.find('\n') { + let line = pending[..pos].trim().to_string(); + pending.drain(..=pos); + handle_manager_line(&line, &mut last_status)?; + } + } + Err(err) if err.kind() == io::ErrorKind::WouldBlock => {} + Err(err) if err.kind() == io::ErrorKind::TimedOut => {} + Err(err) if err.kind() == io::ErrorKind::Interrupted => {} + Err(err) => { + tracing::warn!(error = %err, "failed to read vm manager status stream"); + } + } + + let config = load_or_create_instance_config(project_dir)?; if config.vm_ipv4.is_some() { - let _ = fs::remove_file(&status_path); return Ok(()); } if start.elapsed() > timeout { - let _ = fs::remove_file(&status_path); - return Err("Timed out waiting for VM IPv4".into()); + bail!("timed out waiting for VM IPv4"); } + let now = Instant::now(); - if now >= next_status_check { - match fs::read_to_string(&status_path) { - Ok(status) => { - status_missing = false; - let status = status.trim().to_string(); - if status.starts_with("error:") { - let _ = fs::remove_file(&status_path); - let message = status.trim_start_matches("error:").trim().to_string(); - return Err(message.into()); - } - if !status.is_empty() && last_status.as_deref() != Some(status.as_str()) { - tracing::info!("[background]: {}", status); - last_status = Some(status); - next_log_at = now + Duration::from_secs(20); - } - } - Err(_) => { - status_missing = true; - } - } - next_status_check = now + Duration::from_millis(500); - } if now >= next_log_at { let waited = start.elapsed(); if waited.as_secs() > 15 && !once_hint { @@ -291,12 +334,9 @@ fn wait_for_vm_ipv4( ); once_hint = true; } - if status_missing { - tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),); - } + tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),); next_log_at += Duration::from_secs(20); } - thread::sleep(Duration::from_millis(200)); } } @@ -304,9 +344,14 @@ fn run_ssh_session( ssh_key: PathBuf, ssh_user: String, ip: String, -) -> Result<(), Box> { + manager_conn: UnixStream, + project_root: PathBuf, +) -> Result<()> { let mut attempts = 0usize; loop { + if matches!(vm_liveness(&project_root)?, VmLiveness::NotRunningOrMissing) { + return Err(InstanceError::UnexpectedDisconnection.into()); + } attempts += 1; if !ssh_port_open(&ip) { tracing::debug!(attempts, "ssh port doesn't open yet"); @@ -318,9 +363,7 @@ fn run_ssh_session( SSH_CONNECT_RETRIES ); if attempts >= SSH_CONNECT_RETRIES { - return Err( - format!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts").into(), - ); + bail!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts"); } thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS)); continue; @@ -334,10 +377,10 @@ fn run_ssh_session( attempts, SSH_CONNECT_RETRIES ); - let status = Command::new("ssh") + let child = Command::new("ssh") .args([ "-i", - ssh_key.to_str().unwrap_or(".vibebox/ssh_key"), + ssh_key.to_str().with_context(|| "invalid path")?, "-o", "IdentitiesOnly=yes", "-o", @@ -362,27 +405,67 @@ fn run_ssh_session( .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) - .status(); + .spawn(); - match status { - Ok(status) if status.success() => { - tracing::info!(status = %status, "ssh exited"); - break; - } - Ok(status) if status.code() == Some(255) => { - tracing::warn!(status = %status, "ssh connection failed"); - if attempts >= SSH_CONNECT_RETRIES { - return Err(format!("ssh failed after {SSH_CONNECT_RETRIES} attempts").into()); + match child { + Ok(mut child) => { + let done = Arc::new(AtomicBool::new(false)); + let done_for_monitor = done.clone(); + let (disconnect_tx, disconnect_rx) = mpsc::channel::<()>(); + let mut manager_stream = manager_conn.try_clone()?; + let _ = manager_stream.set_read_timeout(Some(Duration::from_millis(250))); + thread::spawn(move || { + let mut buf = [0u8; 1]; + while !done_for_monitor.load(Ordering::Relaxed) { + match manager_stream.read(&mut buf) { + Ok(0) => { + let _ = disconnect_tx.send(()); + return; + } + Ok(_) => {} + Err(err) if err.kind() == io::ErrorKind::WouldBlock => {} + Err(err) if err.kind() == io::ErrorKind::TimedOut => {} + Err(err) if err.kind() == io::ErrorKind::Interrupted => {} + Err(_) => { + let _ = disconnect_tx.send(()); + return; + } + } + } + }); + + let status = loop { + if disconnect_rx.try_recv().is_ok() { + done.store(true, Ordering::Relaxed); + terminate_ssh_child(&mut child); + restore_terminal_after_disconnect(); + return Err(InstanceError::UnexpectedDisconnection.into()); + } + if let Some(status) = child.try_wait()? { + done.store(true, Ordering::Relaxed); + break status; + } + thread::sleep(Duration::from_millis(100)); + }; + + if status.success() { + tracing::info!(status = %status, "ssh exited"); + break; + } + if status.code() == Some(255) { + tracing::warn!(status = %status, "ssh connection failed"); + if attempts >= SSH_CONNECT_RETRIES { + bail!("ssh failed after {SSH_CONNECT_RETRIES} attempts"); + } + thread::sleep(Duration::from_millis(500)); + continue; } - thread::sleep(Duration::from_millis(500)); - } - Ok(status) => { tracing::info!(status = %status, "ssh exited"); break; } Err(err) => { tracing::error!(error = %err, "failed to start ssh"); - return Err(format!("failed to start ssh: {err}").into()); + bail!("failed to start ssh: {err}"); } } } @@ -390,6 +473,30 @@ fn run_ssh_session( Ok(()) } +fn terminate_ssh_child(child: &mut Child) { + let pid = child.id() as i32; + unsafe { + libc::kill(pid, libc::SIGTERM); + } + let deadline = Instant::now() + Duration::from_millis(700); + while Instant::now() < deadline { + match child.try_wait() { + Ok(Some(_)) => return, + Ok(None) => thread::sleep(Duration::from_millis(50)), + Err(_) => break, + } + } + let _ = child.kill(); + let _ = child.wait(); +} + +fn restore_terminal_after_disconnect() { + if io::stdin().is_terminal() { + let _ = Command::new("stty").arg("sane").status(); + } + eprintln!(); +} + #[cfg_attr(feature = "mock-vm", allow(dead_code))] fn is_ipv4_candidate(candidate: &str) -> bool { let parts: Vec<&str> = candidate.split('.').collect(); @@ -412,10 +519,10 @@ fn ssh_port_open(ip: &str) -> bool { Ok(addr) => addr, Err(_) => return false, }; - TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok() + TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok() } -pub(crate) fn build_ssh_login_actions( +pub fn build_ssh_login_actions( config: &Arc>, project_name: &str, project_guest_dir: &str, @@ -430,7 +537,7 @@ pub(crate) fn build_ssh_login_actions( let key_path = format!("{guest_dir}/{key_name}.pub"); - let setup_script = SSH_SETUP_SCRIPT + let ssh_script = SSH_SETUP_SCRIPT .replace("__SSH_USER__", &ssh_user) .replace("__SUDO_PASSWORD__", &sudo_password) .replace("__PROJECT_NAME__", project_name) @@ -438,8 +545,36 @@ pub(crate) fn build_ssh_login_actions( .replace("__KEY_PATH__", &key_path) .replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script()) .replace("__VIBEBOX_HOME_LINKS__", home_links_script); - let setup = vm::script_command_from_content("ssh_setup", &setup_script) + let setup = vm::script_command_from_content("ssh.sh", &ssh_script) .expect("ssh setup script contained invalid marker"); vec![LoginAction::Send(setup)] } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handle_manager_line_updates_status() { + let mut last_status = None; + handle_manager_line("status: preparing VM image...", &mut last_status) + .expect("status should be accepted"); + assert_eq!(last_status.as_deref(), Some("preparing VM image...")); + } + + #[test] + fn handle_manager_line_ignores_non_status_lines() { + let mut last_status = None; + handle_manager_line("pid=123", &mut last_status).expect("non-status lines are ignored"); + assert!(last_status.is_none()); + } + + #[test] + fn handle_manager_line_surfaces_error_status() { + let mut last_status = None; + let err = handle_manager_line("status: error: vm failed to boot", &mut last_status) + .expect_err("error status should fail"); + assert_eq!(err.to_string(), "vm failed to boot"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8acb606..be9d45c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,3 +8,4 @@ pub mod vm_manager; pub use session_manager::{SessionError, SessionManager, SessionRecord}; pub mod config; +pub mod utils; diff --git a/src/session_manager.rs b/src/session_manager.rs index af2f2b3..23a2090 100644 --- a/src/session_manager.rs +++ b/src/session_manager.rs @@ -1,19 +1,18 @@ +use anyhow::Result; use std::{ env, fs, io::{self, Write}, - os::unix::fs::FileTypeExt, path::{Path, PathBuf}, }; +use crate::config::config_path; +use crate::instance::{VmLiveness, read_instance_config, vm_liveness}; use serde::{Deserialize, Serialize}; -use crate::config::CONFIG_FILENAME; - pub const INSTANCE_DIR_NAME: &str = ".vibebox"; pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox"; pub const GLOBAL_DIR_NAME: &str = ".vibebox"; -pub const INSTANCE_FILENAME: &str = "instance.toml"; -pub const SESSION_TOML_SUFFIX: &str = ".toml"; +const SESSION_TOML_SUFFIX: &str = ".toml"; pub const VM_MANAGER_SOCKET_NAME: &str = "vm.sock"; pub const VM_MANAGER_PID_NAME: &str = "vm.pid"; const SESSIONS_DIR_NAME: &str = "sessions"; @@ -32,14 +31,6 @@ struct SessionEntry { pub id: String, } -#[derive(Debug, Default, Deserialize)] -struct InstanceMetadata { - #[serde(default)] - id: Option, - #[serde(default)] - last_active: Option, -} - #[derive(Debug)] pub struct SessionManager { sessions_dir: PathBuf, @@ -61,7 +52,7 @@ pub enum SessionError { #[error("Session directory does not exist: {0}")] MissingDirectory(PathBuf), #[error(transparent)] - Io(#[from] std::io::Error), + Io(#[from] io::Error), #[error(transparent)] TomlDe(#[from] toml::de::Error), #[error(transparent)] @@ -94,8 +85,8 @@ impl SessionManager { let mut added = false; if has_config { - let meta = read_instance_metadata(&directory)?; - if let Some(id) = meta.id { + let id = read_instance_config(&directory).map_or(None, |config| Some(config.id)); + if let Some(id) = id { let record = SessionEntry { directory: directory.clone(), id: id.clone(), @@ -110,7 +101,6 @@ impl SessionManager { } else { tracing::warn!( directory = %directory.display(), - file = INSTANCE_FILENAME, "missing session id in instance file" ); } @@ -148,12 +138,13 @@ impl SessionManager { } let mut records = Vec::with_capacity(sessions.len()); for session in sessions { - let meta = read_instance_metadata(&session.directory)?; + let last_active = + read_instance_config(&session.directory).map_or(None, |option| option.last_active); let active = is_session_active(&session.directory); records.push(SessionRecord { directory: session.directory, id: session.id, - last_active: meta.last_active, + last_active, active, }); } @@ -271,43 +262,22 @@ fn is_vibebox_dir(directory: &Path) -> bool { if !directory.is_absolute() { return false; } - directory.join(CONFIG_FILENAME).is_file() + config_path(directory).is_file() } fn is_session_active(directory: &Path) -> bool { let instance_dir = directory.join(INSTANCE_DIR_NAME); let pid_path = instance_dir.join(VM_MANAGER_PID_NAME); - let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME); - - let pid = read_pid(&pid_path); - let is_alive = pid.map(pid_is_alive).unwrap_or(false); - if !is_alive { - let _ = fs::remove_file(&pid_path); - return false; - } - - if let Ok(metadata) = fs::metadata(&socket_path) { - return metadata.file_type().is_socket(); - } - - true -} - -fn read_pid(path: &Path) -> Option { - let content = fs::read_to_string(path).ok()?; - content.trim().parse::().ok() -} - -fn pid_is_alive(pid: u32) -> bool { - let pid = pid as libc::pid_t; - let result = unsafe { libc::kill(pid, 0) }; - if result == 0 { - return true; - } - match std::io::Error::last_os_error().raw_os_error() { - Some(code) if code == libc::EPERM => true, - Some(code) if code == libc::ESRCH => false, - _ => false, + match vm_liveness(directory) { + Ok(liveness) => match liveness { + VmLiveness::RunningWithSocket { .. } => true, + VmLiveness::RunningWithoutSocket { .. } => true, + VmLiveness::NotRunningOrMissing => { + let _ = fs::remove_file(&pid_path); + false + } + }, + Err(_) => false, } } @@ -315,31 +285,11 @@ fn read_session_file(path: &Path) -> Result { let raw = fs::read_to_string(path)?; let record: SessionEntry = toml::from_str(&raw)?; if record.id.trim().is_empty() { - return Err(std::io::Error::new(io::ErrorKind::InvalidData, "session id missing").into()); + return Err(io::Error::new(io::ErrorKind::InvalidData, "session id missing").into()); } Ok(record) } -fn read_instance_metadata(directory: &Path) -> Result { - let instance_path = directory.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME); - if !instance_path.exists() { - return Ok(InstanceMetadata::default()); - } - let raw = fs::read_to_string(&instance_path)?; - let mut meta: InstanceMetadata = toml::from_str(&raw)?; - if let Some(id) = &meta.id - && id.trim().is_empty() - { - meta.id = None; - } - if let Some(last_active) = &meta.last_active - && last_active.trim().is_empty() - { - meta.last_active = None; - } - Ok(meta) -} - fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> { let Some(parent) = path.parent() else { return Err(io::Error::new( @@ -362,6 +312,7 @@ fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> { #[cfg(test)] mod tests { use super::*; + use crate::instance::{InstanceConfig, write_instance_config}; use std::fs; use tempfile::TempDir; @@ -375,11 +326,14 @@ mod tests { dir } - fn write_instance(project_dir: &Path, id: &str, last_active: &str) { - let instance_dir = project_dir.join(INSTANCE_DIR_NAME); - fs::create_dir_all(&instance_dir).unwrap(); - let content = format!("id = \"{id}\"\nlast_active = \"{last_active}\"\n"); - fs::write(instance_dir.join(INSTANCE_FILENAME), content).unwrap(); + fn write_instance(project_dir: &Path, id: &str, last_active: &str) -> Result<()> { + fs::create_dir_all(project_dir)?; + + let mut config = InstanceConfig::default(); + config.id = id.to_string(); + config.last_active = Some(last_active.to_string()); + + write_instance_config(project_dir, &config) } #[test] @@ -387,11 +341,14 @@ mod tests { let temp = TempDir::new().unwrap(); let mgr = manager(&temp); let project_dir = create_project_dir(&temp); - fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap(); - write_instance( - &project_dir, - "019bf290-cccc-7c23-ba1d-dce7e6d40693", - "2026-02-07T05:00:00Z", + fs::write(config_path(project_dir.as_path()), "").unwrap(); + assert!( + write_instance( + &project_dir, + "019bf290-cccc-7c23-ba1d-dce7e6d40693", + "2026-02-07T05:00:00Z", + ) + .is_ok() ); let dirs = mgr.update_global_sessions(&project_dir).unwrap(); @@ -412,15 +369,18 @@ mod tests { let temp = TempDir::new().unwrap(); let mgr = manager(&temp); let project_dir = create_project_dir(&temp); - fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap(); - write_instance( - &project_dir, - "019bf290-cccc-7c23-ba1d-dce7e6d40693", - "2026-02-07T05:00:00Z", + fs::write(config_path(project_dir.as_path()), "").unwrap(); + assert!( + write_instance( + &project_dir, + "019bf290-cccc-7c23-ba1d-dce7e6d40693", + "2026-02-07T05:00:00Z", + ) + .is_ok() ); let _ = mgr.update_global_sessions(&project_dir).unwrap(); - fs::remove_file(project_dir.join(CONFIG_FILENAME)).unwrap(); + fs::remove_file(config_path(project_dir.as_path())).unwrap(); let sessions = mgr.list_sessions().unwrap(); assert!(sessions.is_empty()); @@ -451,11 +411,14 @@ mod tests { let temp = TempDir::new().unwrap(); let mgr = manager(&temp); let project_dir = create_project_dir(&temp); - fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap(); - write_instance( - &project_dir, - "019bf290-cccc-7c23-ba1d-dce7e6d40693", - "2026-02-07T05:00:00Z", + fs::write(config_path(project_dir.as_path()), "").unwrap(); + assert!( + write_instance( + &project_dir, + "019bf290-cccc-7c23-ba1d-dce7e6d40693", + "2026-02-07T05:00:00Z", + ) + .is_ok() ); let _ = mgr.update_global_sessions(&project_dir).unwrap(); diff --git a/src/ssh.sh b/src/ssh.sh index f270a43..84a8695 100644 --- a/src/ssh.sh +++ b/src/ssh.sh @@ -114,8 +114,8 @@ mise_ok() { command -v mise >/dev/null 2>&1 || [ -x "$MISE_BIN" ]; } mise_install() { if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then if ! curl https://mise.run | HOME="$USER_HOME" sh; then - mise_warn "mise install script failed (continuing)" - return 0 + mise_warn "mise install script failed" + return 1 fi fi echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc" @@ -141,18 +141,21 @@ MISE touch "${USER_HOME}/.config/mise/mise.lock" if [ -x "$MISE_BIN" ]; then if ! HOME="$USER_HOME" "$MISE_BIN" install; then - mise_warn "mise install failed (continuing)" - return 0 + mise_warn "mise install failed" + return 1 fi else if ! HOME="$USER_HOME" mise install; then - mise_warn "mise install failed (continuing)" - return 0 + mise_warn "mise install failed" + return 1 fi fi } -mise_install || true +if ! mise_install; then + diag "mise installation failed" + vibebox_fail "mise installation failed" 1 +fi # 3) start ssh (don't swallow failures) # If ssh is already active, don't force start/restart. @@ -160,7 +163,7 @@ if ! systemctl is-active --quiet ssh; then if ! systemctl start ssh; then diag "systemctl start ssh failed" dump_diag - exit 1 + vibebox_fail "failed to start ssh service" 1 fi fi @@ -198,7 +201,7 @@ done if [ -z "$dev" ] || [ -z "$ip" ]; then diag "no stable IPv4 on default route interface" dump_diag - exit 1 + vibebox_fail "no stable ipv4 route on default interface" 1 fi # 5) strong verify: ssh must listen externally (0.0.0.0:22 or $ip:22 or [::]:22) @@ -217,7 +220,7 @@ done if ! listens_ok; then diag "sshd not listening on 0.0.0.0:22 / ${ip}:22" dump_diag - exit 1 + vibebox_fail "sshd is not listening on the expected address" 1 fi ip a diff --git a/src/tui.rs b/src/tui.rs index c43baa0..f649035 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,10 +1,4 @@ -use std::{ - io::{self, Write}, - os::unix::io::OwnedFd, - path::PathBuf, - sync::{Arc, Mutex}, -}; - +use bytesize::ByteSize; use color_eyre::Result; use crossterm::{ cursor::{MoveTo, Show}, @@ -22,6 +16,12 @@ use ratatui::{ text::{Line, Span, Text}, widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Widget}, }; +use std::{ + io::{self, Write}, + os::unix::io::OwnedFd, + path::PathBuf, + sync::{Arc, Mutex}, +}; use crate::vm; @@ -39,9 +39,9 @@ const INFO_LINE_COUNT: u16 = 5; #[derive(Debug, Clone)] pub struct VmInfo { - pub max_memory_mb: u64, + pub max_memory: ByteSize, pub cpu_cores: usize, - pub max_disk_gb: f32, + pub max_disk: ByteSize, pub system_name: String, pub auto_shutdown_ms: u64, } @@ -474,27 +474,27 @@ fn write_buffer_with_style(buffer: &Buffer, out: &mut impl Write) -> io::Result< Ok(()) } -fn map_color(color: ratatui::style::Color) -> CrosstermColor { +fn map_color(color: Color) -> CrosstermColor { match color { - ratatui::style::Color::Reset => CrosstermColor::Reset, - ratatui::style::Color::Black => CrosstermColor::Black, - ratatui::style::Color::Red => CrosstermColor::DarkRed, - ratatui::style::Color::Green => CrosstermColor::DarkGreen, - ratatui::style::Color::Yellow => CrosstermColor::DarkYellow, - ratatui::style::Color::Blue => CrosstermColor::DarkBlue, - ratatui::style::Color::Magenta => CrosstermColor::DarkMagenta, - ratatui::style::Color::Cyan => CrosstermColor::DarkCyan, - ratatui::style::Color::Gray => CrosstermColor::Grey, - ratatui::style::Color::DarkGray => CrosstermColor::DarkGrey, - ratatui::style::Color::LightRed => CrosstermColor::Red, - ratatui::style::Color::LightGreen => CrosstermColor::Green, - ratatui::style::Color::LightYellow => CrosstermColor::Yellow, - ratatui::style::Color::LightBlue => CrosstermColor::Blue, - ratatui::style::Color::LightMagenta => CrosstermColor::Magenta, - ratatui::style::Color::LightCyan => CrosstermColor::Cyan, - ratatui::style::Color::White => CrosstermColor::White, - ratatui::style::Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b }, - ratatui::style::Color::Indexed(i) => CrosstermColor::AnsiValue(i), + Color::Reset => CrosstermColor::Reset, + Color::Black => CrosstermColor::Black, + Color::Red => CrosstermColor::DarkRed, + Color::Green => CrosstermColor::DarkGreen, + Color::Yellow => CrosstermColor::DarkYellow, + Color::Blue => CrosstermColor::DarkBlue, + Color::Magenta => CrosstermColor::DarkMagenta, + Color::Cyan => CrosstermColor::DarkCyan, + Color::Gray => CrosstermColor::Grey, + Color::DarkGray => CrosstermColor::DarkGrey, + Color::LightRed => CrosstermColor::Red, + Color::LightGreen => CrosstermColor::Green, + Color::LightYellow => CrosstermColor::Yellow, + Color::LightBlue => CrosstermColor::Blue, + Color::LightMagenta => CrosstermColor::Magenta, + Color::LightCyan => CrosstermColor::Cyan, + Color::White => CrosstermColor::White, + Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b }, + Color::Indexed(i) => CrosstermColor::AnsiValue(i), } } @@ -574,8 +574,8 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) { Span::raw("CPU / Memory / Disk: "), Span::styled( format!( - "{} cores / {} MB / {} GB", - app.vm_info.cpu_cores, app.vm_info.max_memory_mb, app.vm_info.max_disk_gb + "{} cores / {} / {}", + app.vm_info.cpu_cores, app.vm_info.max_memory, app.vm_info.max_disk ), Style::default().fg(Color::Green), ), diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..fa39c6a --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,18 @@ +use std::{ + env, + path::{Path, PathBuf}, +}; + +pub fn relative_to_home(directory: &Path) -> String { + let Ok(home) = env::var("HOME") else { + return directory.display().to_string(); + }; + let home_path = PathBuf::from(home); + if let Ok(stripped) = directory.strip_prefix(&home_path) { + if stripped.components().next().is_none() { + return "~".to_string(); + } + return format!("~/{}", stripped.display()); + } + directory.display().to_string() +} diff --git a/src/vm.rs b/src/vm.rs index e425a69..185bedb 100644 --- a/src/vm.rs +++ b/src/vm.rs @@ -1,5 +1,5 @@ -use crate::instance::STATUS_FILE_NAME; use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME}; +use anyhow::{Context, Error, Result, bail}; use std::{ env, fs, io::{self, Write}, @@ -23,6 +23,7 @@ use std::{ }; use block2::RcBlock; +use bytesize::ByteSize; use dispatch2::DispatchQueue; use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject}; use objc2_foundation::*; @@ -34,49 +35,19 @@ const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576; const SHARED_DIRECTORIES_TAG: &str = "shared"; pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts"; -const BYTES_PER_MB: u64 = 1024 * 1024; -const DEFAULT_CPU_COUNT: usize = 2; -const DEFAULT_RAM_MB: u64 = 2048; -const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB; const START_TIMEOUT: Duration = Duration::from_secs(60); const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120); const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900); -struct StatusFile { - path: PathBuf, - cleared: AtomicBool, -} - -impl StatusFile { - fn new(path: PathBuf) -> Self { - Self { - path, - cleared: AtomicBool::new(false), - } - } - - fn update(&self, message: &str) { - let _ = fs::write(&self.path, message); - } -} - -impl Drop for StatusFile { - fn drop(&mut self) { - if !self.cleared.load(Ordering::SeqCst) { - let _ = fs::remove_file(&self.path); - self.cleared.store(true, Ordering::SeqCst); - } - } -} +const ERROR_REPORT_SCRIPT: &str = include_str!("error_report.sh"); const PROVISION_SCRIPT: &str = include_str!("provision.sh"); -const PROVISION_SCRIPT_NAME: &str = "provision.sh"; const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh"); const DEFAULT_RAW_NAME: &str = "default.raw"; const INSTANCE_RAW_NAME: &str = "instance.raw"; const BASE_DISK_RAW_NAME: &str = "disk.raw"; #[derive(Clone)] -pub(crate) enum LoginAction { +pub enum LoginAction { Expect { text: String, timeout: Duration, @@ -88,23 +59,20 @@ pub(crate) enum LoginAction { }, Send(String), } +use crate::config::BoxConfig; use LoginAction::*; #[derive(Clone)] -pub(crate) struct DirectoryShare { +pub struct DirectoryShare { host: PathBuf, guest: PathBuf, read_only: bool, } impl DirectoryShare { - pub(crate) fn new( - host: PathBuf, - mut guest: PathBuf, - read_only: bool, - ) -> Result> { + pub fn new(host: PathBuf, mut guest: PathBuf, read_only: bool) -> Result { if !host.exists() { - return Err(format!("Host path does not exist: {}", host.display()).into()); + bail!(format!("host path does not exist: {}", host.display())); } if !guest.is_absolute() { guest = PathBuf::from("/root").join(guest); @@ -116,10 +84,10 @@ impl DirectoryShare { }) } - pub(crate) fn from_mount_spec(spec: &str) -> Result> { + pub fn from_mount_spec(spec: &str) -> Result { let parts: Vec<&str> = spec.split(':').collect(); if parts.len() < 2 || parts.len() > 3 { - return Err(format!("Invalid mount spec: {spec}").into()); + bail!(format!("invalid mount spec: {spec}")); } let host = expand_tilde_path(parts[0]); let guest = PathBuf::from(parts[1]); @@ -128,11 +96,10 @@ impl DirectoryShare { "read-only" => true, "read-write" => false, _ => { - return Err(format!( + bail!(format!( "Invalid mount mode '{}'; expected read-only or read-write", parts[2] - ) - .into()); + )); } } } else { @@ -176,19 +143,28 @@ pub struct VmArg { pub mounts: Vec, } -pub fn run_with_args(args: VmArg, io_handler: F) -> Result<(), Box> +type StatusEmitter<'a> = dyn Fn(&str) + std::marker::Send + Sync + 'a; + +fn emit_status(status: Option<&StatusEmitter<'_>>, message: &str) { + if let Some(status) = status { + status(message); + } +} + +pub fn run_with_args(args: VmArg, io_handler: F) -> Result<()> where F: FnOnce(Arc, OwnedFd, OwnedFd) -> IoContext, { - run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new()) + run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new(), None) } -pub(crate) fn run_with_args_and_extras( +pub fn run_with_args_and_extras( args: VmArg, io_handler: F, extra_login_actions: Vec, extra_directory_shares: Vec, -) -> Result<(), Box> + status: Option<&StatusEmitter<'_>>, +) -> Result<()> where F: FnOnce(Arc, OwnedFd, OwnedFd) -> IoContext, { @@ -197,7 +173,7 @@ where let project_root = env::current_dir()?; let project_name = project_root .file_name() - .ok_or("Project directory has no name")? + .with_context(|| "Project directory has no name")? .to_string_lossy() .into_owned(); @@ -210,8 +186,8 @@ where let instance_dir = project_root.join(INSTANCE_DIR_NAME); fs::create_dir_all(&instance_dir)?; - let status_file = StatusFile::new(instance_dir.join(STATUS_FILE_NAME)); - status_file.update("preparing VM image..."); + emit_status(status, "preparing VM image..."); + tracing::info!("preparing VM image..."); let provision_log = instance_dir.join("provision.log"); let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap(); @@ -236,14 +212,14 @@ where &base_compressed, &default_raw, std::slice::from_ref(&mise_directory_share), - Some(&status_file), Some(&provision_log), + status, )?; let _ = ensure_instance_disk( &instance_raw, &default_raw, - args.disk_bytes, - Some(&status_file), + ByteSize(args.disk_bytes), + status, )?; let base_size = fs::metadata(&default_raw)?.len(); let instance_size = fs::metadata(&instance_raw)?.len(); @@ -273,7 +249,7 @@ where } if needs_resize { - let resize_cmd = script_command_from_content("resize_disk", RESIZE_DISK_SCRIPT)?; + let resize_cmd = script_command_from_content("resize_disk.sh", RESIZE_DISK_SCRIPT)?; login_actions.push(Send(resize_cmd)); } @@ -289,25 +265,29 @@ where &directory_shares[..], args.cpu_count, args.ram_bytes, - Some(&status_file), + status, io_handler, ) } -pub(crate) fn script_command_from_content( - label: &str, - script: &str, -) -> Result> { +pub fn script_command_from_content(label: &str, script: &str) -> Result { let marker = "VIBE_SCRIPT_EOF"; let guest_dir = "/tmp/vibe-scripts"; let guest_path = format!("{guest_dir}/{label}.sh"); + let script_body = match script.split_once('\n') { + Some((first, rest)) if first.starts_with("#!") => rest, + _ => script, + }; + let wrapped_script = ERROR_REPORT_SCRIPT + .replace("__LABEL__", label) + .replace("__SCRIPT_BODY__", script_body); let command = format!( - "mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{script}\n{marker}\nchmod +x {guest_path}\n{guest_path}" + "mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{wrapped_script}\n{marker}\nchmod +x {guest_path}\n{guest_path}" ); if script.contains(marker) { - return Err( - format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(), - ); + bail!(format!( + "Script '{label}' contains marker '{marker}', cannot safely upload" + )); } Ok(command) } @@ -504,18 +484,16 @@ impl IoControl { fn ensure_base_image( base_raw: &Path, base_compressed: &Path, - status: Option<&StatusFile>, -) -> Result<(), Box> { + status: Option<&StatusEmitter<'_>>, +) -> Result<()> { if base_raw.exists() { return Ok(()); } if !base_compressed.exists() - || std::fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES + || fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES { - if let Some(status) = status { - status.update("downloading base image..."); - } + emit_status(status, "downloading base image..."); tracing::info!("downloading base image"); let status = Command::new("curl") .args([ @@ -530,15 +508,14 @@ fn ensure_base_image( ]) .status()?; if !status.success() { - return Err("Failed to download base image".into()); + bail!("failed to download base image"); } } // Check SHA { - if let Some(status) = status { - status.update("verifying base image..."); - } + emit_status(status, "verifying base image..."); + tracing::info!("verifying base image..."); let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display()); let mut child = Command::new("/usr/bin/shasum") @@ -556,25 +533,25 @@ fn ensure_base_image( let status = child.wait().expect("failed to wait on child"); if !status.success() { - return Err(format!("SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}").into()); + bail!(format!( + "SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}" + )); } } - if let Some(status) = status { - status.update("decompressing base image..."); - } - tracing::info!("decompressing base image"); + emit_status(status, "decompressing base image..."); + tracing::info!("decompressing base image..."); let status = Command::new("tar") .args([ "-xOf", &base_compressed.to_string_lossy(), BASE_DISK_RAW_NAME, ]) - .stdout(std::fs::File::create(base_raw)?) + .stdout(fs::File::create(base_raw)?) .status()?; if !status.success() { - return Err("Failed to decompress base image".into()); + bail!("Failed to decompress base image"); } Ok(()) @@ -585,22 +562,20 @@ fn ensure_default_image( base_compressed: &Path, default_raw: &Path, directory_shares: &[DirectoryShare], - status: Option<&StatusFile>, provision_log: Option<&Path>, -) -> Result<(), Box> { + status: Option<&StatusEmitter<'_>>, +) -> Result<()> { if default_raw.exists() { return Ok(()); } ensure_base_image(base_raw, base_compressed, status)?; - if let Some(status) = status { - status.update("configuring base image..."); - } - tracing::info!("configuring base image"); + emit_status(status, "configuring base image..."); + tracing::info!("configuring base image..."); fs::copy(base_raw, default_raw)?; - let provision_command = script_command_from_content(PROVISION_SCRIPT_NAME, PROVISION_SCRIPT)?; + let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?; let provision_actions = [ Send(provision_command), ExpectEither { @@ -615,9 +590,9 @@ fn ensure_default_image( default_raw, &provision_actions, directory_shares, - DEFAULT_CPU_COUNT, - DEFAULT_RAM_BYTES, - None, + BoxConfig::default().cpu_count, + BoxConfig::default().ram_size.as_u64(), + status, move |output_monitor, vm_output_fd, vm_input_fd| { spawn_vm_io_with_log(output_monitor, vm_output_fd, vm_input_fd, log_path) }, @@ -627,9 +602,9 @@ fn ensure_default_image( default_raw, &provision_actions, directory_shares, - DEFAULT_CPU_COUNT, - DEFAULT_RAM_BYTES, - None, + BoxConfig::default().cpu_count, + BoxConfig::default().ram_size.as_u64(), + status, ) }; @@ -644,18 +619,16 @@ fn ensure_default_image( fn ensure_instance_disk( instance_raw: &Path, template_raw: &Path, - target_bytes: u64, - status: Option<&StatusFile>, -) -> Result> { + target_bytes: ByteSize, + status: Option<&StatusEmitter<'_>>, +) -> Result { if instance_raw.exists() { - let current_size = fs::metadata(instance_raw)?.len(); + let current_size = ByteSize(fs::metadata(instance_raw)?.len()); if current_size != target_bytes { - let current_gb = current_size as f64 / (1024.0 * 1024.0 * 1024.0); - let target_gb = target_bytes as f64 / (1024.0 * 1024.0 * 1024.0); + let current_gb = current_size; + let target_gb = target_bytes; tracing::warn!( - current_bytes = current_size, - target_bytes, - "instance disk size does not match config (current {:.2} GB, config {:.2} GB); disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using existing disk.", + "instance disk size does not match config (current {}, config {}); disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using existing disk.", current_gb, target_gb ); @@ -663,30 +636,28 @@ fn ensure_instance_disk( return Ok(false); } - let template_size = fs::metadata(template_raw)?.len(); + let template_size = ByteSize(fs::metadata(template_raw)?.len()); if target_bytes < template_size { - return Err(format!( - "Requested disk size {} bytes is smaller than base image size {} bytes", + bail!(format!( + "Requested disk size {} is smaller than base image size {}", target_bytes, template_size - ) - .into()); + )); } let target_size = target_bytes; let needs_resize = target_size > template_size; - if let Some(status) = status { - status.update("creating instance disk..."); - } + emit_status(status, "creating instance disk..."); + tracing::info!("creating instance disk..."); tracing::info!(path = %template_raw.display(), "creating instance disk"); - std::fs::create_dir_all(instance_raw.parent().unwrap())?; + fs::create_dir_all(instance_raw.parent().unwrap())?; if target_size == template_size { fs::copy(template_raw, instance_raw)?; return Ok(needs_resize); } - let mut dst = std::fs::File::create(instance_raw)?; - dst.set_len(target_size)?; - let mut src = std::fs::File::open(template_raw)?; + let mut dst = fs::File::create(instance_raw)?; + dst.set_len(target_size.as_u64())?; + let mut src = fs::File::open(template_raw)?; std::io::copy(&mut src, &mut dst)?; Ok(needs_resize) } @@ -704,18 +675,17 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) { (read_stream.into(), write_stream.into()) } -pub fn spawn_vm_io_with_hooks( +pub fn spawn_vm_io_with_hooks< + F: FnMut(&str) -> bool + std::marker::Send + 'static, + G: FnMut(&[u8]) + std::marker::Send + 'static, +>( output_monitor: Arc, vm_output_fd: OwnedFd, vm_input_fd: OwnedFd, io_control: Arc, mut on_line: F, mut on_output: G, -) -> IoContext -where - F: FnMut(&str) -> bool + ::std::marker::Send + 'static, - G: FnMut(&[u8]) + ::std::marker::Send + 'static, -{ +) -> IoContext { let (input_tx, input_rx): (Sender, Receiver) = mpsc::channel(); // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM. @@ -863,14 +833,14 @@ where PollResult::Spurious => continue, PollResult::Ready(bytes) => { if io_control.forward_output() { - // enable raw mode, if we haven't already + // enable raw mode if we haven't already if raw_guard.lock().unwrap().is_none() && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO) { *raw_guard.lock().unwrap() = Some(guard); } - let mut stdout = std::io::stdout().lock(); + let mut stdout = io::stdout().lock(); if stdout.write_all(bytes).is_err() { break; } @@ -886,7 +856,7 @@ where // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts. let mux_thread = thread::spawn(move || { - let mut vm_writer = std::fs::File::from(vm_input_fd); + let mut vm_writer = fs::File::from(vm_input_fd); loop { match input_rx.recv() { Ok(VmInput::Bytes(data)) => { @@ -916,7 +886,7 @@ pub fn spawn_vm_io_with_line_handler( on_line: F, ) -> IoContext where - F: FnMut(&str) -> bool + ::std::marker::Send + 'static, + F: FnMut(&str) -> bool + std::marker::Send + 'static, { spawn_vm_io_with_hooks( output_monitor, @@ -983,7 +953,7 @@ fn create_vm_configuration( vm_writes_to_fd: OwnedFd, cpu_count: usize, ram_bytes: u64, -) -> Result, Box> { +) -> Result> { unsafe { let platform = VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc()); @@ -1017,7 +987,7 @@ fn create_vm_configuration( false, VZDiskImageCachingMode::Automatic, VZDiskImageSynchronizationMode::Full, - ).unwrap(); + )?; let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment( VZVirtioBlockDeviceConfiguration::alloc(), @@ -1104,7 +1074,7 @@ fn create_vm_configuration( // Validate config.validateWithError().map_err(|e| { io::Error::other(format!( - "Invalid VM configuration: {:?}", + "invalid VM configuration: {:?}", e.localizedDescription() )) })?; @@ -1113,9 +1083,9 @@ fn create_vm_configuration( } } -fn load_efi_variable_store() -> Result, Box> { +fn load_efi_variable_store() -> Result> { unsafe { - let temp_dir = std::env::temp_dir(); + let temp_dir = env::temp_dir(); let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id())); let url = nsurl_from_path(&temp_path)?; let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite; @@ -1131,8 +1101,8 @@ fn load_efi_variable_store() -> Result, Box, output_monitor: Arc, - input_tx: mpsc::Sender, - vm_output_tx: mpsc::Sender, + input_tx: Sender, + vm_output_tx: Sender, ) -> thread::JoinHandle<()> { thread::spawn(move || { for a in login_actions { @@ -1182,9 +1152,9 @@ fn run_vm_with_io( directory_shares: &[DirectoryShare], cpu_count: usize, ram_bytes: u64, - status: Option<&StatusFile>, + status: Option<&StatusEmitter<'_>>, io_handler: F, -) -> Result<(), Box> +) -> Result<()> where F: FnOnce(Arc, OwnedFd, OwnedFd) -> IoContext, { @@ -1231,23 +1201,22 @@ where match rx.try_recv() { Ok(result) => { - result.map_err(|e| format!("Failed to start VM: {}", e))?; + result.map_err(|e| Error::msg(format!("Failed to start VM: {}", e)))?; break; } Err(mpsc::TryRecvError::Empty) => continue, Err(mpsc::TryRecvError::Disconnected) => { - return Err("VM start channel disconnected".into()); + bail!("VM start channel disconnected"); } } } if Instant::now() >= start_deadline { - return Err("Timed out waiting for VM to start".into()); + bail!("Timed out waiting for VM to start"); } - if let Some(status) = status { - status.update("vm booting... go vibecoder!"); - } + emit_status(status, "vm booting... go vibecoder!"); + tracing::info!("vm booting... go vibecoder!"); tracing::info!("vm booting"); let output_monitor = Arc::new(OutputMonitor::default()); @@ -1293,7 +1262,7 @@ where ); let mut last_state = None; - let mut exit_result = Ok(()); + let mut exit_result: Result<(), String> = Ok(()); loop { unsafe { NSRunLoop::mainRunLoop().runMode_beforeDate( @@ -1312,8 +1281,7 @@ where exit_result = Err(format!( "Login action ({}) timed out after {:?}; shutting down.", action, timeout - ) - .into()); + )); unsafe { if vm.canRequestStop() { if let Err(err) = vm.requestStopWithError() { @@ -1330,8 +1298,7 @@ where exit_result = Err(format!( "Login action ({}) failed: {}; shutting down.", action, reason - ) - .into()); + )); unsafe { if vm.canRequestStop() { if let Err(err) = vm.requestStopWithError() { @@ -1347,7 +1314,7 @@ where Err(mpsc::TryRecvError::Empty) => {} Err(mpsc::TryRecvError::Disconnected) => {} } - if state != objc2_virtualization::VZVirtualMachineState::Running { + if state != VZVirtualMachineState::Running { //eprintln!("VM stopped with state: {:?}", state); break; } @@ -1357,7 +1324,7 @@ where io_ctx.shutdown(); - exit_result + exit_result.map_err(Error::msg) } fn run_vm( @@ -1366,8 +1333,8 @@ fn run_vm( directory_shares: &[DirectoryShare], cpu_count: usize, ram_bytes: u64, - status: Option<&StatusFile>, -) -> Result<(), Box> { + status: Option<&StatusEmitter<'_>>, +) -> Result<()> { run_vm_with_io( disk_path, login_actions, @@ -1379,7 +1346,7 @@ fn run_vm( ) } -fn nsurl_from_path(path: &Path) -> Result, Box> { +fn nsurl_from_path(path: &Path) -> Result> { let abs_path = if path.is_absolute() { path.to_path_buf() } else { @@ -1388,7 +1355,7 @@ fn nsurl_from_path(path: &Path) -> Result, Box io::Result { let original = attributes; // Disable translation of carriage return to newline on input - attributes.c_iflag &= !(libc::ICRNL); + attributes.c_iflag &= !libc::ICRNL; // Disable canonical mode (line buffering), echo, and signal generation attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG); attributes.c_cc[libc::VMIN] = 0; @@ -1431,10 +1398,10 @@ impl Drop for RawModeGuard { // Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching. pub fn ensure_signed() { - if std::env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") { + if env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") { return; } - let exe = std::env::current_exe().expect("failed to get current exe path"); + let exe = env::current_exe().expect("failed to get current exe path"); let exe_str = exe.to_str().expect("exe path not valid utf-8"); let has_required_entitlements = { @@ -1456,8 +1423,8 @@ pub fn ensure_signed() { } const ENTITLEMENTS: &str = include_str!("entitlements.plist"); - let entitlements_path = std::env::temp_dir().join("entitlements.plist"); - std::fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements"); + let entitlements_path = env::temp_dir().join("entitlements.plist"); + fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements"); let output = Command::new("codesign") .args([ @@ -1470,7 +1437,7 @@ pub fn ensure_signed() { ]) .output(); - let _ = std::fs::remove_file(&entitlements_path); + let _ = fs::remove_file(&entitlements_path); match output { Ok(o) if o.status.success() => { @@ -1478,7 +1445,7 @@ pub fn ensure_signed() { if !stderr.trim().is_empty() { tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output"); } - let err = Command::new(&exe).args(std::env::args_os().skip(1)).exec(); + let err = Command::new(&exe).args(env::args_os().skip(1)).exec(); tracing::error!(error = %err, "failed to re-exec after signing"); std::process::exit(1); } diff --git a/src/vm_manager.rs b/src/vm_manager.rs index ae65bdf..e6d73ed 100644 --- a/src/vm_manager.rs +++ b/src/vm_manager.rs @@ -1,12 +1,22 @@ +use crate::instance::{VmLiveness, vm_liveness}; +use crate::session_manager::INSTANCE_DIR_NAME; +use crate::{ + config::CONFIG_PATH_ENV, + instance::{ + InstanceConfig, STATUS_VM_ERROR_PREFIX, build_ssh_login_actions, ensure_instance_dir, + ensure_ssh_keypair, extract_ipv4, load_or_create_instance_config, write_instance_config, + }, + session_manager::{GLOBAL_DIR_NAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME}, + vm::{self, DirectoryShare, LoginAction, PROJECT_GUEST_BASE, VmInput}, +}; +use anyhow::{Error, Result, anyhow, bail}; use std::{ env, fs, io::{Read, Write}, os::unix::{ - fs::FileTypeExt, fs::PermissionsExt, io::AsRawFd, net::{UnixListener, UnixStream}, - process::CommandExt, }, path::{Path, PathBuf}, process::{Command, Stdio}, @@ -15,20 +25,8 @@ use std::{ time::{Duration, Instant}, }; -use crate::{ - config::CONFIG_PATH_ENV, - instance::STATUS_FILE_NAME, - instance::VM_ROOT_LOG_NAME, - instance::{ - DEFAULT_SSH_USER, InstanceConfig, build_ssh_login_actions, ensure_instance_dir, - ensure_ssh_keypair, extract_ipv4, load_or_create_instance_config, write_instance_config, - }, - session_manager::{ - GLOBAL_DIR_NAME, INSTANCE_FILENAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME, - }, - vm::{self, DirectoryShare, LoginAction, PROJECT_GUEST_BASE, VmInput}, -}; - +#[cfg_attr(feature = "mock-vm", allow(dead_code))] +const VM_ROOT_LOG_NAME: &str = "vm_root.log"; const VM_MANAGER_LOCK_NAME: &str = "vm.lock"; const VM_MANAGER_LOG_NAME: &str = "vm_manager.log"; const SHUTDOWN_RETRY_MS: u64 = 500; @@ -36,6 +34,10 @@ const SHUTDOWN_RETRY_MS: u64 = 500; const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 1_000; #[cfg(not(test))] const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000; +const STATUS_PREFIX: &str = "status:"; + +type ClientStreams = Arc>>; +type SharedStatus = Arc>; #[cfg(not(test))] fn force_exit(_reason: &str) -> ! { @@ -51,16 +53,16 @@ pub fn ensure_manager( raw_args: &[std::ffi::OsString], auto_shutdown_ms: u64, config_path: Option<&Path>, -) -> Result> { +) -> Result { let project_root = env::current_dir()?; tracing::debug!(root = %project_root.display(), "ensure vm manager"); let instance_dir = ensure_instance_dir(&project_root)?; - cleanup_stale_manager(&instance_dir); + cleanup_stale_manager(&project_root)?; let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME); if let Ok(stream) = UnixStream::connect(&socket_path) { send_client_pid(&stream); - tracing::info!(path = %socket_path.display(), "connected to existing vm manager"); + tracing::info!(path = %socket_path.display(), "connected to an existing vm manager"); return Ok(stream); } @@ -98,12 +100,11 @@ pub fn ensure_manager( drop(lock_file.take()); let _ = fs::remove_file(&lock_path); } - return Err(format!( - "Timed out waiting for vm manager socket: {} ({})", + bail!(format!( + "timed out waiting for vm manager socket: {} ({})", socket_path.display(), err - ) - .into()); + )); } thread::sleep(Duration::from_millis(100)); } @@ -111,12 +112,19 @@ pub fn ensure_manager( } } -pub fn run_manager( - args: vm::VmArg, - auto_shutdown_ms: u64, -) -> Result<(), Box> { +pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> { let project_root = env::current_dir()?; tracing::info!(root = %project_root.display(), "vm manager starting"); + #[cfg(not(feature = "mock-vm"))] + { + unsafe { + env::remove_var("VIBEBOX_SKIP_CODESIGN"); + } + vm::ensure_signed(); + unsafe { + env::set_var("VIBEBOX_SKIP_CODESIGN", "1"); + } + } let _pid_guard = ensure_pid_file(&project_root)?; #[cfg(feature = "mock-vm")] tracing::info!("vm manager using mock executor"); @@ -134,7 +142,6 @@ pub fn run_manager( #[cfg(feature = "mock-vm")] { ManagerOptions { - ensure_signed: false, detach: true, prepare_vm: false, } @@ -142,7 +149,6 @@ pub fn run_manager( #[cfg(not(feature = "mock-vm"))] { ManagerOptions { - ensure_signed: true, detach: true, prepare_vm: true, } @@ -156,25 +162,22 @@ fn spawn_manager_process( auto_shutdown_ms: u64, instance_dir: &Path, config_path: Option<&Path>, -) -> Result<(), Box> { +) -> Result<()> { let exe = env::current_exe()?; let mut supervisor_exe = exe.clone(); supervisor_exe.set_file_name("vibebox-supervisor"); - let use_supervisor = supervisor_exe.exists(); - let mut cmd = if use_supervisor { - Command::new(supervisor_exe) - } else { - let mut cmd = Command::new(exe); - cmd.arg0("vibebox-supervisor"); - cmd - }; + // intentional + if !supervisor_exe.exists() { + bail!(format!( + "vibebox-supervisor not found at {}", + supervisor_exe.display() + )); + } + let mut cmd = Command::new(supervisor_exe); if raw_args.len() > 1 { cmd.args(&raw_args[1..]); } cmd.env("VIBEBOX_INTERNAL", "1"); - if !use_supervisor { - cmd.env("VIBEBOX_VM_MANAGER", "1"); - } cmd.env("VIBEBOX_LOG_NO_COLOR", "1"); cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string()); if let Some(path) = config_path { @@ -202,22 +205,22 @@ fn spawn_manager_process( Ok(()) } -fn ensure_pid_file(project_root: &Path) -> Result> { +fn ensure_pid_file(project_root: &Path) -> Result { let instance_dir = ensure_instance_dir(project_root)?; let pid_path = instance_dir.join(VM_MANAGER_PID_NAME); let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME); - if let Ok(content) = fs::read_to_string(&pid_path) - && let Ok(pid) = content.trim().parse::() - && pid_is_alive(pid) - { - if is_socket_path(&socket_path) { - return Err(format!("vm manager already running (pid {pid})").into()); + match vm_liveness(project_root)? { + VmLiveness::RunningWithSocket { pid } => { + bail!("vm manager already running (pid {pid})"); } - tracing::warn!( - pid, - path = %socket_path.display(), - "stale pid file detected with missing socket" - ); + VmLiveness::RunningWithoutSocket { pid } => { + tracing::warn!( + pid, + path = %socket_path.display(), + "stale pid file detected with missing socket" + ); + } + VmLiveness::NotRunningOrMissing => {} } let _ = fs::remove_file(&pid_path); fs::write(&pid_path, format!("{}\n", std::process::id()))?; @@ -225,15 +228,18 @@ fn ensure_pid_file(project_root: &Path) -> Result() - && pid_is_alive(pid) - { - return; +fn cleanup_stale_manager(project_root: &Path) -> Result<()> { + let pid_path = project_root + .join(INSTANCE_DIR_NAME) + .join(VM_MANAGER_PID_NAME); + if matches!( + vm_liveness(project_root)?, + VmLiveness::RunningWithSocket { .. } | VmLiveness::RunningWithoutSocket { .. } + ) { + return Ok(()); } let _ = fs::remove_file(&pid_path); + Ok(()) } fn inject_project_mount( @@ -260,12 +266,6 @@ fn inject_project_mount( mounts.insert(0, format!("{host}:{guest_tilde}:read-write")); } -fn is_socket_path(path: &Path) -> bool { - fs::metadata(path) - .map(|meta| meta.file_type().is_socket()) - .unwrap_or(false) -} - fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) { let mut links = Vec::new(); let mut mounts = Vec::with_capacity(args.mounts.len()); @@ -409,6 +409,30 @@ fn wait_for_disconnect(mut stream: UnixStream) { } } +fn remove_client(streams: &ClientStreams, fd: std::os::fd::RawFd) { + if let Ok(mut clients) = streams.lock() { + clients.retain(|stream| stream.as_raw_fd() != fd); + } +} + +fn send_status_line(stream: &mut UnixStream, status: &str) -> bool { + let mut payload = String::with_capacity(STATUS_PREFIX.len() + status.len() + 1); + payload.push_str(STATUS_PREFIX); + payload.push_str(status); + payload.push('\n'); + stream.write_all(payload.as_bytes()).is_ok() +} + +#[cfg_attr(feature = "mock-vm", allow(dead_code))] +fn broadcast_status(streams: &ClientStreams, latest_status: &SharedStatus, status: &str) { + if let Ok(mut current) = latest_status.lock() { + *current = status.to_string(); + } + if let Ok(mut clients) = streams.lock() { + clients.retain_mut(|stream| send_status_line(stream, status)); + } +} + fn send_client_pid(stream: &UnixStream) { let pid = std::process::id(); let payload = format!("pid={pid}\n"); @@ -418,7 +442,7 @@ fn send_client_pid(stream: &UnixStream) { } } -fn acquire_spawn_lock(lock_path: &Path) -> Result, Box> { +fn acquire_spawn_lock(lock_path: &Path) -> Result> { match fs::OpenOptions::new() .write(true) .create_new(true) @@ -452,6 +476,12 @@ fn is_lock_stale(lock_path: &Path) -> bool { } } +fn read_lock_pid(lock_path: &Path) -> Option { + let content = fs::read_to_string(lock_path).ok()?; + let line = content.lines().next()?; + line.strip_prefix("pid=")?.trim().parse::().ok() +} + fn pid_is_alive(pid: u32) -> bool { let pid = pid as libc::pid_t; let result = unsafe { libc::kill(pid, 0) }; @@ -465,12 +495,6 @@ fn pid_is_alive(pid: u32) -> bool { } } -fn read_lock_pid(lock_path: &Path) -> Option { - let content = fs::read_to_string(lock_path).ok()?; - let line = content.lines().next()?; - line.strip_prefix("pid=")?.trim().parse::().ok() -} - fn read_client_pid(stream: &UnixStream) -> Option { let mut stream = stream.try_clone().ok()?; let _ = stream.set_read_timeout(Some(Duration::from_millis(200))); @@ -506,12 +530,14 @@ fn read_client_pid(stream: &UnixStream) -> Option { #[cfg_attr(feature = "mock-vm", allow(dead_code))] fn spawn_manager_io( config: Arc>, - instance_dir: PathBuf, + project_dir: PathBuf, + clients: ClientStreams, + latest_status: SharedStatus, output_monitor: Arc, vm_output_fd: std::os::unix::io::OwnedFd, vm_input_fd: std::os::unix::io::OwnedFd, ) -> vm::IoContext { - let log_path = instance_dir.join(VM_ROOT_LOG_NAME); + let log_path = project_dir.join(INSTANCE_DIR_NAME).join(VM_ROOT_LOG_NAME); let log_file = fs::OpenOptions::new() .create(true) .write(true) @@ -520,7 +546,6 @@ fn spawn_manager_io( .ok() .map(|file| Arc::new(Mutex::new(file))); - let instance_path = instance_dir.join(INSTANCE_FILENAME); let config_for_output = config.clone(); let log_for_output = log_file.clone(); let mut line_buf = String::new(); @@ -543,6 +568,17 @@ fn spawn_manager_io( line_buf.drain(..=pos); let cleaned = line.trim_start_matches(['\r', ' ']); + if let Some(script_failure) = cleaned.strip_prefix("VIBEBOX_SCRIPT_ERROR:") { + let failure = script_failure.trim(); + if !failure.is_empty() { + tracing::error!(script_failure = %failure, "[vm] script reported failure"); + broadcast_status( + &clients, + &latest_status, + &format!("{STATUS_VM_ERROR_PREFIX} {failure}"), + ); + } + } if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") { let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..]; let ip = extract_ipv4(ip_raw).unwrap_or_default(); @@ -551,7 +587,7 @@ fn spawn_manager_io( && cfg.vm_ipv4.as_deref() != Some(ip.as_str()) { cfg.vm_ipv4 = Some(ip.clone()); - let _ = write_instance_config(&instance_path, &cfg); + let _ = write_instance_config(&project_dir, &cfg); } } } @@ -574,12 +610,12 @@ enum ManagerEvent { } struct ManagerOptions { - ensure_signed: bool, detach: bool, prepare_vm: bool, } trait VmExecutor { + #[allow(clippy::too_many_arguments)] fn run_vm( &self, args: vm::VmArg, @@ -587,8 +623,10 @@ trait VmExecutor { extra_shares: Vec, config: Arc>, instance_dir: PathBuf, + clients: ClientStreams, + latest_status: SharedStatus, vm_input_tx: Arc>>>, - ) -> Result<(), Box>; + ) -> Result<()>; } #[cfg_attr(feature = "mock-vm", allow(dead_code))] @@ -601,15 +639,22 @@ impl VmExecutor for RealVmExecutor { extra_login_actions: Vec, extra_shares: Vec, config: Arc>, - instance_dir: PathBuf, + project_dir: PathBuf, + clients: ClientStreams, + latest_status: SharedStatus, vm_input_tx: Arc>>>, - ) -> Result<(), Box> { + ) -> Result<()> { + let status_callback = |status: &str| { + broadcast_status(&clients, &latest_status, status); + }; vm::run_with_args_and_extras( args, |output_monitor, vm_output_fd, vm_input_fd| { let io_ctx = spawn_manager_io( config.clone(), - instance_dir.clone(), + project_dir.clone(), + clients.clone(), + latest_status.clone(), output_monitor, vm_output_fd, vm_input_fd, @@ -619,6 +664,7 @@ impl VmExecutor for RealVmExecutor { }, extra_login_actions, extra_shares, + Some(&status_callback), ) } } @@ -635,8 +681,10 @@ impl VmExecutor for MockVmExecutor { _extra_shares: Vec, _config: Arc>, _instance_dir: PathBuf, + _clients: ClientStreams, + _latest_status: SharedStatus, vm_input_tx: Arc>>>, - ) -> Result<(), Box> { + ) -> Result<()> { let (tx, rx) = mpsc::channel::(); *vm_input_tx.lock().unwrap() = Some(tx); tracing::info!("mock vm executor running"); @@ -662,24 +710,14 @@ fn run_manager_with( auto_shutdown_ms: u64, executor: &dyn VmExecutor, options: ManagerOptions, -) -> Result<(), Box> { - if options.ensure_signed { - let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok(); - unsafe { - env::remove_var("VIBEBOX_SKIP_CODESIGN"); - } - vm::ensure_signed(); - unsafe { - env::set_var("VIBEBOX_SKIP_CODESIGN", "1"); - } - } +) -> Result<()> { if options.detach { detach_from_terminal(); } let project_name = project_root .file_name() - .ok_or("Project directory has no name")? + .ok_or_else(|| anyhow!("Project directory has no name"))? .to_string_lossy() .into_owned(); let instance_dir = ensure_instance_dir(project_root)?; @@ -687,16 +725,16 @@ fn run_manager_with( let _ = ensure_ssh_keypair(&instance_dir)?; } - let mut config = load_or_create_instance_config(&instance_dir)?; + let mut config = load_or_create_instance_config(project_root)?; if config.vm_ipv4.is_some() { config.vm_ipv4 = None; - write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?; + write_instance_config(project_root, &config)?; } let config = Arc::new(Mutex::new(config)); let ssh_user = config .lock() - .map(|cfg| cfg.ssh_user_display()) - .unwrap_or_else(|_| DEFAULT_SSH_USER.to_string()); + .map(|cfg| cfg.ssh_user.clone()) + .map_err(|_| anyhow!("failed to acquire ssh user display"))?; if !args.no_default_mounts { inject_project_mount(&mut args.mounts, project_root, &ssh_user, &project_name); } @@ -704,11 +742,10 @@ fn run_manager_with( let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}"); let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME); - let extra_shares = vec![DirectoryShare::new( - instance_dir.clone(), - ssh_guest_dir.clone().into(), - true, - )?]; + let extra_shares = vec![ + DirectoryShare::new(instance_dir.clone(), ssh_guest_dir.clone().into(), true) + .map_err(|err| anyhow!(err.to_string()))?, + ]; let extra_login_actions = build_ssh_login_actions( &config, &project_name, @@ -731,17 +768,43 @@ fn run_manager_with( let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600)); tracing::info!(path = %socket_path.display(), "vm manager socket bound"); + let clients: ClientStreams = Arc::new(Mutex::new(Vec::new())); + let latest_status: SharedStatus = Arc::new(Mutex::new(String::new())); let (event_tx, event_rx) = mpsc::channel::(); let event_tx_accept = event_tx.clone(); + let clients_accept = clients.clone(); + let latest_status_accept = latest_status.clone(); thread::spawn(move || { for stream in listener.incoming() { match stream { Ok(stream) => { + let mut client_fd: Option = None; + let latest_status_snapshot = latest_status_accept + .lock() + .ok() + .map(|status| status.clone()); + if let Ok(writer) = stream.try_clone() { + let writer_fd = writer.as_raw_fd(); + if let Ok(mut connected) = clients_accept.lock() { + connected.push(writer); + client_fd = Some(writer_fd); + if let Some(last) = connected.last_mut() + && let Some(status) = latest_status_snapshot.as_deref() + && !status.is_empty() + { + let _ = send_status_line(last, status); + } + } + } let event_tx_conn = event_tx_accept.clone(); + let clients_conn = clients_accept.clone(); thread::spawn(move || { let pid = read_client_pid(&stream); let _ = event_tx_conn.send(ManagerEvent::Inc(pid)); wait_for_disconnect(stream); + if let Some(fd) = client_fd { + remove_client(&clients_conn, fd); + } let _ = event_tx_conn.send(ManagerEvent::Dec(pid)); }); } @@ -761,38 +824,43 @@ fn run_manager_with( extra_login_actions, extra_shares, config.clone(), - instance_dir.clone(), + project_root.to_path_buf(), + clients.clone(), + latest_status.clone(), vm_input_tx.clone(), ); tracing::info!("vm manager vm run completed"); let vm_err = vm_result.err().map(|e| e.to_string()); if let Some(err) = &vm_err { - let status_path = instance_dir.join(STATUS_FILE_NAME); - let _ = fs::write(&status_path, format!("error: {err}")); + broadcast_status( + &clients, + &latest_status, + &format!("{STATUS_VM_ERROR_PREFIX} {err}"), + ); } let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone())); - let event_loop_result: Result<(), String> = event_loop_handle + let event_loop_result = event_loop_handle .join() - .unwrap_or_else(|_| Err("vm manager event loop panicked".into())) + .unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked"))) .map_err(|err| err.to_string()); let _ = fs::remove_file(&socket_path); if let Err(err) = &event_loop_result { tracing::error!(error = %err, "vm manager exiting due to event loop error"); - return Err(err.to_string().into()); + bail!(err.to_string()); } if let Some(err) = vm_err { tracing::error!(error = %err, "vm manager exiting due to vm error"); - return Err(err.into()); + bail!(err); } tracing::info!("vm manager exiting"); - Ok(event_loop_result?) + event_loop_result.map_err(Error::msg) } fn manager_event_loop( event_rx: mpsc::Receiver, vm_input_tx: Arc>>>, auto_shutdown_ms: u64, -) -> Result<(), String> { +) -> Result<()> { let mut ref_count: usize = 0; let mut shutdown_deadline: Option = None; let mut shutdown_sent = false; @@ -902,7 +970,7 @@ fn manager_event_loop( #[cfg(test)] mod tests { use super::*; - use std::{sync::mpsc, thread, time::Duration}; + use std::{fs, sync::mpsc, thread, time::Duration}; #[test] fn manager_powers_off_after_grace_when_no_refs() { @@ -984,4 +1052,26 @@ mod tests { let _ = event_tx.send(ManagerEvent::VmExited(None)); let _ = manager_thread.join(); } + + #[test] + fn lock_is_not_stale_when_owner_pid_is_alive() { + let temp = tempfile::Builder::new() + .prefix("vb") + .tempdir_in("/tmp") + .expect("tempdir"); + let lock_path = temp.path().join("vm.lock"); + fs::write(&lock_path, format!("pid={}\n", std::process::id())).expect("write lock"); + assert!(!is_lock_stale(&lock_path)); + } + + #[test] + fn lock_is_stale_when_owner_pid_is_missing() { + let temp = tempfile::Builder::new() + .prefix("vb") + .tempdir_in("/tmp") + .expect("tempdir"); + let lock_path = temp.path().join("vm.lock"); + fs::write(&lock_path, "pid=999999\n").expect("write lock"); + assert!(is_lock_stale(&lock_path)); + } }