@@ -1,15 +1,3 @@
1−use crate::instance::{VmLiveness, vm_liveness};
2−use crate::session_manager::INSTANCE_DIR_NAME;
4− config::CONFIG_PATH_ENV,
6− InstanceConfig, STATUS_VM_ERROR_PREFIX, build_ssh_login_actions, ensure_instance_dir,
7− ensure_ssh_keypair, extract_ipv4, load_or_create_instance_config, write_instance_config,
9− session_manager::{GLOBAL_DIR_NAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
10− vm::{self, DirectoryShare, LoginAction, PROJECT_GUEST_BASE, VmInput},
12−use anyhow::{Error, Result, anyhow, bail};
15 io::{Read, Write},
3 io::{Read, Write},
@@ -17,6 +5,7 @@ use std::{
17 fs::PermissionsExt,
5 fs::PermissionsExt,
18 io::AsRawFd,
6 io::AsRawFd,
19 net::{UnixListener, UnixStream},
7 net::{UnixListener, UnixStream},
21 path::{Path, PathBuf},
10 path::{Path, PathBuf},
22 process::{Command, Stdio},
11 process::{Command, Stdio},
@@ -25,44 +14,32 @@ use std::{
25 time::{Duration, Instant},
14 time::{Duration, Instant},
28−#[cfg_attr(feature = "mock-vm", allow(dead_code))]
17+use crate::{
29−const VM_ROOT_LOG_NAME: &str = "vm_root.log";
18+ instance::SERIAL_LOG_NAME,
20+ InstanceConfig, build_ssh_login_actions, ensure_instance_dir, ensure_ssh_keypair,
21+ extract_ipv4, load_or_create_instance_config, write_instance_config,
23+ session_manager::{GLOBAL_DIR_NAME, INSTANCE_FILENAME},
24+ vm::{self, DirectoryShare, LoginAction, VmInput},
27+const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
30const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
28const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
31const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
29const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
32−const SHUTDOWN_RETRY_MS: u64 = 500;
34−const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 1_000;
36−const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000;
37−const STATUS_PREFIX: &str = "status:";
39−type ClientStreams = Arc<Mutex<Vec<UnixStream>>>;
40−type SharedStatus = Arc<Mutex<String>>;
43−fn force_exit(_reason: &str) -> ! {
44− std::process::exit(1);
48−fn force_exit(reason: &str) -> ! {
52pub fn ensure_manager(
31pub fn ensure_manager(
53 raw_args: &[std::ffi::OsString],
32 raw_args: &[std::ffi::OsString],
54 auto_shutdown_ms: u64,
33 auto_shutdown_ms: u64,
55− config_path: Option<&Path>,
56−) -> Result<UnixStream> {
34+) -> Result<UnixStream, Box<dyn std::error::Error>> {
57 let project_root = env::current_dir()?;
35 let project_root = env::current_dir()?;
58 tracing::debug!(root = %project_root.display(), "ensure vm manager");
36 tracing::debug!(root = %project_root.display(), "ensure vm manager");
59 let instance_dir = ensure_instance_dir(&project_root)?;
37 let instance_dir = ensure_instance_dir(&project_root)?;
60− cleanup_stale_manager(&project_root)?;
61 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
38 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
63 if let Ok(stream) = UnixStream::connect(&socket_path) {
40 if let Ok(stream) = UnixStream::connect(&socket_path) {
64 send_client_pid(&stream);
41 send_client_pid(&stream);
65− tracing::info!(path = %socket_path.display(), "connected to an existing vm manager");
42+ tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
66 return Ok(stream);
43 return Ok(stream);
@@ -70,7 +47,7 @@ pub fn ensure_manager(
70 let mut lock_file = acquire_spawn_lock(&lock_path)?;
47 let mut lock_file = acquire_spawn_lock(&lock_path)?;
71 if lock_file.is_some() {
48 if lock_file.is_some() {
72 tracing::info!(path = %socket_path.display(), "spawning vm manager");
49 tracing::info!(path = %socket_path.display(), "spawning vm manager");
73− spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir, config_path)?;
50+ spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir)?;
75 tracing::info!(
52 tracing::info!(
76 path = %socket_path.display(),
53 path = %socket_path.display(),
@@ -100,11 +77,12 @@ pub fn ensure_manager(
100 drop(lock_file.take());
77 drop(lock_file.take());
101 let _ = fs::remove_file(&lock_path);
78 let _ = fs::remove_file(&lock_path);
103− bail!(format!(
80+ return Err(format!(
104− "timed out waiting for vm manager socket: {} ({})",
81+ "Timed out waiting for vm manager socket: {} ({})",
105 socket_path.display(),
82 socket_path.display(),
109 thread::sleep(Duration::from_millis(100));
87 thread::sleep(Duration::from_millis(100));
@@ -112,78 +90,50 @@ pub fn ensure_manager(
115−pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> {
95+ auto_shutdown_ms: u64,
96+) -> Result<(), Box<dyn std::error::Error>> {
116 let project_root = env::current_dir()?;
97 let project_root = env::current_dir()?;
117 tracing::info!(root = %project_root.display(), "vm manager starting");
98 tracing::info!(root = %project_root.display(), "vm manager starting");
118− #[cfg(not(feature = "mock-vm"))]
121− env::remove_var("VIBEBOX_SKIP_CODESIGN");
123− vm::ensure_signed();
125− env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
128− let _pid_guard = ensure_pid_file(&project_root)?;
129− #[cfg(feature = "mock-vm")]
130− tracing::info!("vm manager using mock executor");
131− let executor: &dyn VmExecutor = {
132− #[cfg(feature = "mock-vm")]
136− #[cfg(not(feature = "mock-vm"))]
142− #[cfg(feature = "mock-vm")]
149− #[cfg(not(feature = "mock-vm"))]
157− run_manager_with(&project_root, args, auto_shutdown_ms, executor, options)
99+ run_manager_with(
105+ ensure_signed: true,
160fn spawn_manager_process(
112fn spawn_manager_process(
161 raw_args: &[std::ffi::OsString],
113 raw_args: &[std::ffi::OsString],
162 auto_shutdown_ms: u64,
114 auto_shutdown_ms: u64,
163 instance_dir: &Path,
115 instance_dir: &Path,
164− config_path: Option<&Path>,
116+) -> Result<(), Box<dyn std::error::Error>> {
166 let exe = env::current_exe()?;
117 let exe = env::current_exe()?;
167 let mut supervisor_exe = exe.clone();
118 let mut supervisor_exe = exe.clone();
168 supervisor_exe.set_file_name("vibebox-supervisor");
119 supervisor_exe.set_file_name("vibebox-supervisor");
169− // intentional
120+ let use_supervisor = supervisor_exe.exists();
121+ let mut cmd = if use_supervisor {
170− if !supervisor_exe.exists() {
122+ Command::new(supervisor_exe)
171− bail!(format!(
123+ } else {
124+ let mut cmd = Command::new(exe);
172− "vibebox-supervisor not found at {}",
125+ cmd.arg0("vibebox-supervisor");
173− supervisor_exe.display()
126+ cmd
176− let mut cmd = Command::new(supervisor_exe);
177 if raw_args.len() > 1 {
128 if raw_args.len() > 1 {
178 cmd.args(&raw_args[1..]);
129 cmd.args(&raw_args[1..]);
180− cmd.env("VIBEBOX_INTERNAL", "1");
131+ if !use_supervisor {
132+ cmd.env("VIBEBOX_VM_MANAGER", "1");
181 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
134 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
182 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
135 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
183− if let Some(path) = config_path {
136+ tracing::info!(auto_shutdown_ms, "vm manager process spawn requested");
184− cmd.env(CONFIG_PATH_ENV, path);
186− tracing::debug!(auto_shutdown_ms, "vm manager process spawn requested");
187 let log_path = instance_dir.join(VM_MANAGER_LOG_NAME);
137 let log_path = instance_dir.join(VM_MANAGER_LOG_NAME);
188 let log_file = fs::OpenOptions::new()
138 let log_file = fs::OpenOptions::new()
189 .create(true)
139 .create(true)
@@ -205,184 +155,6 @@ fn spawn_manager_process(
208−fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard> {
209− let instance_dir = ensure_instance_dir(project_root)?;
210− let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
211− let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
212− match vm_liveness(project_root)? {
213− VmLiveness::RunningWithSocket { pid } => {
214− bail!("vm manager already running (pid {pid})");
216− VmLiveness::RunningWithoutSocket { pid } => {
219− path = %socket_path.display(),
220− "stale pid file detected with missing socket"
223− VmLiveness::NotRunningOrMissing => {}
225− let _ = fs::remove_file(&pid_path);
226− fs::write(&pid_path, format!("{}\n", std::process::id()))?;
227− let _ = fs::set_permissions(&pid_path, fs::Permissions::from_mode(0o600));
228− Ok(PidFileGuard { path: pid_path })
231−fn cleanup_stale_manager(project_root: &Path) -> Result<()> {
232− let pid_path = project_root
233− .join(INSTANCE_DIR_NAME)
234− .join(VM_MANAGER_PID_NAME);
236− vm_liveness(project_root)?,
237− VmLiveness::RunningWithSocket { .. } | VmLiveness::RunningWithoutSocket { .. }
241− let _ = fs::remove_file(&pid_path);
245−fn inject_project_mount(
246− mounts: &mut Vec<String>,
247− project_root: &Path,
251− let guest_tilde = format!("~/{project_name}");
252− let guest_home = format!("/home/{ssh_user}/{project_name}");
253− let guest_base = format!("{PROJECT_GUEST_BASE}/{project_name}");
254− let already_mapped = mounts.iter().any(|spec| {
255− let parts: Vec<&str> = spec.split(':').collect();
256− if parts.len() < 2 {
259− let guest = parts[1];
260− guest == guest_tilde || guest == guest_home || guest == guest_base
265− let host = project_root.display();
266− mounts.insert(0, format!("{host}:{guest_tilde}:read-write"));
269−fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
270− let mut links = Vec::new();
271− let mut mounts = Vec::with_capacity(args.mounts.len());
272− for spec in args.mounts {
273− let (rewritten, link) = rewrite_mount_spec(&spec, ssh_user);
274− if let Some(link) = link {
277− mounts.push(rewritten);
279− args.mounts = mounts;
280− let script = render_home_links_script(&links, ssh_user);
289−fn rewrite_mount_spec(spec: &str, ssh_user: &str) -> (String, Option<HomeLink>) {
290− let parts: Vec<&str> = spec.split(':').collect();
291− if parts.len() < 2 || parts.len() > 3 {
292− return (spec.to_string(), None);
294− let host = parts[0];
295− let guest = parts[1];
296− let mode = parts.get(2).copied();
298− let home_prefix = format!("/home/{ssh_user}");
299− let (rel, is_home) = if guest == "~" {
300− (String::new(), true)
301− } else if let Some(stripped) = guest.strip_prefix("~/") {
302− (stripped.to_string(), true)
303− } else if guest == home_prefix {
304− (String::new(), true)
305− } else if let Some(stripped) = guest.strip_prefix(&(home_prefix.clone() + "/")) {
306− (stripped.to_string(), true)
308− (String::new(), false)
312− return (spec.to_string(), None);
315− let root_base = PROJECT_GUEST_BASE;
316− let root_path = if rel.is_empty() {
317− root_base.to_string()
319− format!("{root_base}/{rel}")
321− let target = if rel.is_empty() {
324− format!("{home_prefix}/{rel}")
327− let rewritten = match mode {
328− Some(mode) => format!("{host}:{root_path}:{mode}"),
329− None => format!("{host}:{root_path}"),
341−fn render_home_links_script(links: &[HomeLink], ssh_user: &str) -> String {
342− if links.is_empty() {
343− return String::new();
345− let mut lines = Vec::new();
346− lines.push("link_home() {".to_string());
347− lines.push(" src=\"$1\"".to_string());
348− lines.push(" dest=\"$2\"".to_string());
349− lines.push(" if [ -L \"$dest\" ]; then".to_string());
350− lines.push(" current=\"$(readlink \"$dest\" || true)\"".to_string());
351− lines.push(" if [ \"$current\" != \"$src\" ]; then".to_string());
352− lines.push(" rm -f \"$dest\"".to_string());
353− lines.push(" fi".to_string());
354− lines.push(" fi".to_string());
355− lines.push(" if [ ! -e \"$dest\" ]; then".to_string());
356− lines.push(" mkdir -p \"$(dirname \"$dest\")\"".to_string());
357− lines.push(" ln -s \"$src\" \"$dest\"".to_string());
358− lines.push(" fi".to_string());
360− " chown -h \"{ssh_user}:{ssh_user}\" \"$dest\" 2>/dev/null || true"
362− lines.push("}".to_string());
364− let src = shell_escape(&link.source);
365− let dest = shell_escape(&link.target);
366− lines.push(format!("link_home {src} {dest}"));
371−fn shell_escape(value: &str) -> String {
372− let escaped = value.replace('\'', "'\"'\"'");
373− format!("'{escaped}'")
376−struct PidFileGuard {
380−impl Drop for PidFileGuard {
381− fn drop(&mut self) {
382− let _ = fs::remove_file(&self.path);
386fn detach_from_terminal() {
158fn detach_from_terminal() {
388 libc::setsid();
160 libc::setsid();
@@ -409,30 +181,6 @@ fn wait_for_disconnect(mut stream: UnixStream) {
412−fn remove_client(streams: &ClientStreams, fd: std::os::fd::RawFd) {
413− if let Ok(mut clients) = streams.lock() {
414− clients.retain(|stream| stream.as_raw_fd() != fd);
418−fn send_status_line(stream: &mut UnixStream, status: &str) -> bool {
419− let mut payload = String::with_capacity(STATUS_PREFIX.len() + status.len() + 1);
420− payload.push_str(STATUS_PREFIX);
421− payload.push_str(status);
423− stream.write_all(payload.as_bytes()).is_ok()
426−#[cfg_attr(feature = "mock-vm", allow(dead_code))]
427−fn broadcast_status(streams: &ClientStreams, latest_status: &SharedStatus, status: &str) {
428− if let Ok(mut current) = latest_status.lock() {
429− *current = status.to_string();
431− if let Ok(mut clients) = streams.lock() {
432− clients.retain_mut(|stream| send_status_line(stream, status));
436fn send_client_pid(stream: &UnixStream) {
184fn send_client_pid(stream: &UnixStream) {
437 let pid = std::process::id();
185 let pid = std::process::id();
438 let payload = format!("pid={pid}\n");
186 let payload = format!("pid={pid}\n");
@@ -442,7 +190,7 @@ fn send_client_pid(stream: &UnixStream) {
445−fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>> {
193+fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>, Box<dyn std::error::Error>> {
446 match fs::OpenOptions::new()
194 match fs::OpenOptions::new()
447 .write(true)
195 .write(true)
448 .create_new(true)
196 .create_new(true)
@@ -476,12 +224,6 @@ fn is_lock_stale(lock_path: &Path) -> bool {
479−fn read_lock_pid(lock_path: &Path) -> Option<u32> {
480− let content = fs::read_to_string(lock_path).ok()?;
481− let line = content.lines().next()?;
482− line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
485fn pid_is_alive(pid: u32) -> bool {
227fn pid_is_alive(pid: u32) -> bool {
486 let pid = pid as libc::pid_t;
228 let pid = pid as libc::pid_t;
487 let result = unsafe { libc::kill(pid, 0) };
229 let result = unsafe { libc::kill(pid, 0) };
@@ -495,6 +237,12 @@ fn pid_is_alive(pid: u32) -> bool {
240+fn read_lock_pid(lock_path: &Path) -> Option<u32> {
241+ let content = fs::read_to_string(lock_path).ok()?;
242+ let line = content.lines().next()?;
243+ line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
498fn read_client_pid(stream: &UnixStream) -> Option<u32> {
246fn read_client_pid(stream: &UnixStream) -> Option<u32> {
499 let mut stream = stream.try_clone().ok()?;
247 let mut stream = stream.try_clone().ok()?;
500 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
248 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
@@ -505,7 +253,7 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
505 Ok(0) => break,
253 Ok(0) => break,
506 Ok(n) => {
254 Ok(n) => {
507 len += n;
255 len += n;
508− if buf[..len].contains(&b'\n') || len == buf.len() {
256+ if buf[..len].iter().any(|b| *b == b'\n') || len == buf.len() {
@@ -527,17 +275,14 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
530−#[cfg_attr(feature = "mock-vm", allow(dead_code))]
531fn spawn_manager_io(
278fn spawn_manager_io(
532 config: Arc<Mutex<InstanceConfig>>,
279 config: Arc<Mutex<InstanceConfig>>,
533− project_dir: PathBuf,
280+ instance_dir: PathBuf,
534− clients: ClientStreams,
535− latest_status: SharedStatus,
536 output_monitor: Arc<vm::OutputMonitor>,
281 output_monitor: Arc<vm::OutputMonitor>,
537 vm_output_fd: std::os::unix::io::OwnedFd,
282 vm_output_fd: std::os::unix::io::OwnedFd,
538 vm_input_fd: std::os::unix::io::OwnedFd,
283 vm_input_fd: std::os::unix::io::OwnedFd,
539) -> vm::IoContext {
284) -> vm::IoContext {
540− let log_path = project_dir.join(INSTANCE_DIR_NAME).join(VM_ROOT_LOG_NAME);
285+ let log_path = instance_dir.join(SERIAL_LOG_NAME);
541 let log_file = fs::OpenOptions::new()
286 let log_file = fs::OpenOptions::new()
542 .create(true)
287 .create(true)
543 .write(true)
288 .write(true)
@@ -546,15 +291,16 @@ fn spawn_manager_io(
547 .map(|file| Arc::new(Mutex::new(file)));
292 .map(|file| Arc::new(Mutex::new(file)));
294+ let instance_path = instance_dir.join(INSTANCE_FILENAME);
549 let config_for_output = config.clone();
295 let config_for_output = config.clone();
550 let log_for_output = log_file.clone();
296 let log_for_output = log_file.clone();
551 let mut line_buf = String::new();
297 let mut line_buf = String::new();
553 let on_output = move |bytes: &[u8]| {
299 let on_output = move |bytes: &[u8]| {
554− if let Some(log) = &log_for_output
300+ if let Some(log) = &log_for_output {
555− && let Ok(mut file) = log.lock()
301+ if let Ok(mut file) = log.lock() {
556− {
302+ let _ = file.write_all(bytes);
557− let _ = file.write_all(bytes);
303+ }
560 let text = String::from_utf8_lossy(bytes);
306 let text = String::from_utf8_lossy(bytes);
@@ -567,27 +313,17 @@ fn spawn_manager_io(
568 line_buf.drain(..=pos);
314 line_buf.drain(..=pos);
570− let cleaned = line.trim_start_matches(['\r', ' ']);
316+ let cleaned = line.trim_start_matches(|c: char| c == '\r' || c == ' ');
571− if let Some(script_failure) = cleaned.strip_prefix("VIBEBOX_SCRIPT_ERROR:") {
572− let failure = script_failure.trim();
573− if !failure.is_empty() {
574− tracing::error!(script_failure = %failure, "[vm] script reported failure");
578− &format!("{STATUS_VM_ERROR_PREFIX} {failure}"),
582 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
317 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
583 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
318 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
584 let ip = extract_ipv4(ip_raw).unwrap_or_default();
319 let ip = extract_ipv4(ip_raw).unwrap_or_default();
585− if !ip.is_empty()
320+ if !ip.is_empty() {
586− && let Ok(mut cfg) = config_for_output.lock()
321+ if let Ok(mut cfg) = config_for_output.lock() {
587− && cfg.vm_ipv4.as_deref() != Some(ip.as_str())
322+ if cfg.vm_ipv4.as_deref() != Some(ip.as_str()) {
588− {
323+ cfg.vm_ipv4 = Some(ip.clone());
589− cfg.vm_ipv4 = Some(ip.clone());
324+ let _ = write_instance_config(&instance_path, &cfg);
590− let _ = write_instance_config(&project_dir, &cfg);
325+ }
@@ -609,13 +345,51 @@ enum ManagerEvent {
609 VmExited(Option<String>),
345 VmExited(Option<String>),
351+ use std::{sync::mpsc, time::Duration};
354+ fn manager_powers_off_after_grace_when_no_refs() {
355+ let _temp = tempfile::Builder::new()
360+ let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
361+ let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
362+ let vm_input_tx = Arc::new(Mutex::new(Some(vm_tx)));
364+ let manager_thread = thread::spawn(move || {
365+ manager_event_loop(event_rx, vm_input_tx, 50).expect("event loop");
368+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
369+ assert!(vm_rx.recv_timeout(Duration::from_millis(100)).is_err());
371+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
373+ .recv_timeout(Duration::from_secs(2))
374+ .expect("poweroff");
376+ VmInput::Bytes(data) => {
377+ assert_eq!(data, b"systemctl poweroff\n");
379+ _ => panic!("unexpected vm input"),
381+ let _ = event_tx.send(ManagerEvent::VmExited(None));
382+ let _ = manager_thread.join();
612struct ManagerOptions {
386struct ManagerOptions {
387+ ensure_signed: bool,
613 detach: bool,
388 detach: bool,
614 prepare_vm: bool,
389 prepare_vm: bool,
617trait VmExecutor {
392trait VmExecutor {
618− #[allow(clippy::too_many_arguments)]
619 fn run_vm(
393 fn run_vm(
621 args: vm::VmArg,
395 args: vm::VmArg,
@@ -623,13 +397,10 @@ trait VmExecutor {
623 extra_shares: Vec<DirectoryShare>,
397 extra_shares: Vec<DirectoryShare>,
624 config: Arc<Mutex<InstanceConfig>>,
398 config: Arc<Mutex<InstanceConfig>>,
625 instance_dir: PathBuf,
399 instance_dir: PathBuf,
626− clients: ClientStreams,
627− latest_status: SharedStatus,
628 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
400 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
629− ) -> Result<()>;
401+ ) -> Result<(), Box<dyn std::error::Error>>;
632−#[cfg_attr(feature = "mock-vm", allow(dead_code))]
633struct RealVmExecutor;
404struct RealVmExecutor;
635impl VmExecutor for RealVmExecutor {
406impl VmExecutor for RealVmExecutor {
@@ -639,22 +410,15 @@ impl VmExecutor for RealVmExecutor {
639 extra_login_actions: Vec<LoginAction>,
410 extra_login_actions: Vec<LoginAction>,
640 extra_shares: Vec<DirectoryShare>,
411 extra_shares: Vec<DirectoryShare>,
641 config: Arc<Mutex<InstanceConfig>>,
412 config: Arc<Mutex<InstanceConfig>>,
642− project_dir: PathBuf,
413+ instance_dir: PathBuf,
643− clients: ClientStreams,
644− latest_status: SharedStatus,
645 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
414 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
646− ) -> Result<()> {
415+ ) -> Result<(), Box<dyn std::error::Error>> {
647− let status_callback = |status: &str| {
416+ let result = vm::run_with_args_and_extras(
648− broadcast_status(&clients, &latest_status, status);
650− vm::run_with_args_and_extras(
652 |output_monitor, vm_output_fd, vm_input_fd| {
418 |output_monitor, vm_output_fd, vm_input_fd| {
653 let io_ctx = spawn_manager_io(
419 let io_ctx = spawn_manager_io(
654 config.clone(),
420 config.clone(),
655− project_dir.clone(),
421+ instance_dir.clone(),
657− latest_status.clone(),
658 output_monitor,
422 output_monitor,
659 vm_output_fd,
423 vm_output_fd,
660 vm_input_fd,
424 vm_input_fd,
@@ -664,60 +428,35 @@ impl VmExecutor for RealVmExecutor {
665 extra_login_actions,
429 extra_login_actions,
666 extra_shares,
430 extra_shares,
667− Some(&status_callback),
431+ );
672−#[cfg(feature = "mock-vm")]
673−struct MockVmExecutor;
675−#[cfg(feature = "mock-vm")]
676−impl VmExecutor for MockVmExecutor {
680− _extra_login_actions: Vec<LoginAction>,
681− _extra_shares: Vec<DirectoryShare>,
682− _config: Arc<Mutex<InstanceConfig>>,
683− _instance_dir: PathBuf,
684− _clients: ClientStreams,
685− _latest_status: SharedStatus,
686− vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
688− let (tx, rx) = mpsc::channel::<VmInput>();
689− *vm_input_tx.lock().unwrap() = Some(tx);
690− tracing::info!("mock vm executor running");
691− while let Ok(input) = rx.recv() {
693− VmInput::Shutdown => break,
694− VmInput::Bytes(bytes) => {
695− let text = String::from_utf8_lossy(&bytes);
696− if text.contains("systemctl poweroff") {
702− tracing::info!("mock vm executor exiting");
707fn run_manager_with(
436fn run_manager_with(
708 project_root: &Path,
437 project_root: &Path,
709− mut args: vm::VmArg,
438+ args: vm::VmArg,
710 auto_shutdown_ms: u64,
439 auto_shutdown_ms: u64,
711 executor: &dyn VmExecutor,
440 executor: &dyn VmExecutor,
712 options: ManagerOptions,
441 options: ManagerOptions,
713−) -> Result<()> {
442+) -> Result<(), Box<dyn std::error::Error>> {
443+ if options.ensure_signed {
444+ let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok();
446+ env::remove_var("VIBEBOX_SKIP_CODESIGN");
448+ vm::ensure_signed();
450+ env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
714 if options.detach {
453 if options.detach {
715 detach_from_terminal();
454 detach_from_terminal();
718 let project_name = project_root
457 let project_name = project_root
719 .file_name()
458 .file_name()
720− .ok_or_else(|| anyhow!("Project directory has no name"))?
459+ .ok_or("Project directory has no name")?
721 .to_string_lossy()
460 .to_string_lossy()
722 .into_owned();
461 .into_owned();
723 let instance_dir = ensure_instance_dir(project_root)?;
462 let instance_dir = ensure_instance_dir(project_root)?;
@@ -725,35 +464,21 @@ fn run_manager_with(
725 let _ = ensure_ssh_keypair(&instance_dir)?;
464 let _ = ensure_ssh_keypair(&instance_dir)?;
728− let mut config = load_or_create_instance_config(project_root)?;
467+ let mut config = load_or_create_instance_config(&instance_dir)?;
729 if config.vm_ipv4.is_some() {
468 if config.vm_ipv4.is_some() {
730 config.vm_ipv4 = None;
469 config.vm_ipv4 = None;
731− write_instance_config(project_root, &config)?;
470+ write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
733 let config = Arc::new(Mutex::new(config));
472 let config = Arc::new(Mutex::new(config));
734− let ssh_user = config
736− .map(|cfg| cfg.ssh_user.clone())
737− .map_err(|_| anyhow!("failed to acquire ssh user display"))?;
738− if !args.no_default_mounts {
739− inject_project_mount(&mut args.mounts, project_root, &ssh_user, &project_name);
741− let (args, home_links_script) = prepare_mounts_and_links(args, &ssh_user);
743− let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
744 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
474 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
745− let extra_shares = vec![
475+ let extra_shares = vec![DirectoryShare::new(
746− DirectoryShare::new(instance_dir.clone(), ssh_guest_dir.clone().into(), true)
476+ instance_dir.clone(),
747− .map_err(|err| anyhow!(err.to_string()))?,
477+ ssh_guest_dir.clone().into(),
749− let extra_login_actions = build_ssh_login_actions(
479+ )?];
750− &config,
480+ let extra_login_actions =
751− &project_name,
481+ build_ssh_login_actions(&config, &project_name, ssh_guest_dir.as_str(), "ssh_key");
753− ssh_guest_dir.as_str(),
758 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
483 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
759 if let Ok(stream) = UnixStream::connect(&socket_path) {
484 if let Ok(stream) = UnixStream::connect(&socket_path) {
@@ -768,43 +493,17 @@ fn run_manager_with(
768 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
493 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
769 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
494 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
771− let clients: ClientStreams = Arc::new(Mutex::new(Vec::new()));
772− let latest_status: SharedStatus = Arc::new(Mutex::new(String::new()));
773 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
496 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
774 let event_tx_accept = event_tx.clone();
497 let event_tx_accept = event_tx.clone();
775− let clients_accept = clients.clone();
776− let latest_status_accept = latest_status.clone();
777 thread::spawn(move || {
498 thread::spawn(move || {
778 for stream in listener.incoming() {
499 for stream in listener.incoming() {
779 match stream {
500 match stream {
780 Ok(stream) => {
501 Ok(stream) => {
781− let mut client_fd: Option<std::os::fd::RawFd> = None;
782− let latest_status_snapshot = latest_status_accept
785− .map(|status| status.clone());
786− if let Ok(writer) = stream.try_clone() {
787− let writer_fd = writer.as_raw_fd();
788− if let Ok(mut connected) = clients_accept.lock() {
789− connected.push(writer);
790− client_fd = Some(writer_fd);
791− if let Some(last) = connected.last_mut()
792− && let Some(status) = latest_status_snapshot.as_deref()
793− && !status.is_empty()
795− let _ = send_status_line(last, status);
799 let event_tx_conn = event_tx_accept.clone();
502 let event_tx_conn = event_tx_accept.clone();
800− let clients_conn = clients_accept.clone();
801 thread::spawn(move || {
503 thread::spawn(move || {
802 let pid = read_client_pid(&stream);
504 let pid = read_client_pid(&stream);
803 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
505 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
804 wait_for_disconnect(stream);
506 wait_for_disconnect(stream);
805− if let Some(fd) = client_fd {
806− remove_client(&clients_conn, fd);
808 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
507 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
@@ -824,59 +523,43 @@ fn run_manager_with(
824 extra_login_actions,
523 extra_login_actions,
825 extra_shares,
524 extra_shares,
826 config.clone(),
525 config.clone(),
827− project_root.to_path_buf(),
526+ instance_dir.clone(),
829− latest_status.clone(),
830 vm_input_tx.clone(),
527 vm_input_tx.clone(),
832 tracing::info!("vm manager vm run completed");
529 tracing::info!("vm manager vm run completed");
833 let vm_err = vm_result.err().map(|e| e.to_string());
530 let vm_err = vm_result.err().map(|e| e.to_string());
834− if let Some(err) = &vm_err {
838− &format!("{STATUS_VM_ERROR_PREFIX} {err}"),
841 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
531 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
842− let event_loop_result = event_loop_handle
532+ let event_loop_result: Result<(), String> = event_loop_handle
844− .unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked")))
534+ .unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
845 .map_err(|err| err.to_string());
535 .map_err(|err| err.to_string());
846 let _ = fs::remove_file(&socket_path);
536 let _ = fs::remove_file(&socket_path);
847 if let Err(err) = &event_loop_result {
537 if let Err(err) = &event_loop_result {
848 tracing::error!(error = %err, "vm manager exiting due to event loop error");
538 tracing::error!(error = %err, "vm manager exiting due to event loop error");
849− bail!(err.to_string());
539+ return Err(err.to_string().into());
851 if let Some(err) = vm_err {
541 if let Some(err) = vm_err {
852 tracing::error!(error = %err, "vm manager exiting due to vm error");
542 tracing::error!(error = %err, "vm manager exiting due to vm error");
853− bail!(err);
543+ return Err(err.into());
855 tracing::info!("vm manager exiting");
545 tracing::info!("vm manager exiting");
856− event_loop_result.map_err(Error::msg)
546+ Ok(event_loop_result?)
859fn manager_event_loop(
549fn manager_event_loop(
860 event_rx: mpsc::Receiver<ManagerEvent>,
550 event_rx: mpsc::Receiver<ManagerEvent>,
861 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
551 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
862 auto_shutdown_ms: u64,
552 auto_shutdown_ms: u64,
863−) -> Result<()> {
553+) -> Result<(), String> {
864 let mut ref_count: usize = 0;
554 let mut ref_count: usize = 0;
865 let mut shutdown_deadline: Option<Instant> = None;
555 let mut shutdown_deadline: Option<Instant> = None;
866 let mut shutdown_sent = false;
556 let mut shutdown_sent = false;
867− let mut hard_deadline: Option<Instant> = None;
868 let grace = Duration::from_millis(auto_shutdown_ms.max(1));
557 let grace = Duration::from_millis(auto_shutdown_ms.max(1));
869− let hard_timeout = Duration::from_millis(HARD_SHUTDOWN_TIMEOUT_MS);
872− let timeout = match (shutdown_deadline, hard_deadline) {
560+ let timeout = match shutdown_deadline {
873− (Some(shutdown), Some(hard)) => {
561+ Some(deadline) => deadline.saturating_duration_since(Instant::now()),
874− let next = if shutdown <= hard { shutdown } else { hard };
562+ None => Duration::from_secs(1),
875− next.saturating_duration_since(Instant::now())
877− (Some(shutdown), None) => shutdown.saturating_duration_since(Instant::now()),
878− (None, Some(hard)) => hard.saturating_duration_since(Instant::now()),
879− (None, None) => Duration::from_secs(1),
882 match event_rx.recv_timeout(timeout) {
565 match event_rx.recv_timeout(timeout) {
@@ -890,10 +573,11 @@ fn manager_event_loop(
891 shutdown_deadline = None;
574 shutdown_deadline = None;
892 shutdown_sent = false;
575 shutdown_sent = false;
893− hard_deadline = None;
895 Ok(ManagerEvent::Dec(pid)) => {
577 Ok(ManagerEvent::Dec(pid)) => {
896− ref_count = ref_count.saturating_sub(1);
578+ if ref_count > 0 {
897 tracing::info!(
581 tracing::info!(
898 ref_count,
582 ref_count,
899 pid = pid.unwrap_or(0),
583 pid = pid.unwrap_or(0),
@@ -912,52 +596,15 @@ fn manager_event_loop(
914 Err(mpsc::RecvTimeoutError::Timeout) => {
598 Err(mpsc::RecvTimeoutError::Timeout) => {
915− if let Some(deadline) = shutdown_deadline
599+ if let Some(deadline) = shutdown_deadline {
916− && Instant::now() >= deadline
600+ if Instant::now() >= deadline && !shutdown_sent {
917− && !shutdown_sent
601+ if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
918− {
602+ let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
919− if hard_deadline.is_none() {
920− hard_deadline = Some(Instant::now() + hard_timeout);
922− let mut sent = false;
923− if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
925− .send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()))
930− tracing::warn!("shutdown command failed to send");
933− tracing::warn!("shutdown command deferred; vm input not ready");
936 tracing::info!("shutdown command sent");
604 tracing::info!("shutdown command sent");
937 shutdown_sent = true;
605 shutdown_sent = true;
938 shutdown_deadline = None;
606 shutdown_deadline = None;
939− hard_deadline = Some(Instant::now() + hard_timeout);
942− Some(Instant::now() + Duration::from_millis(SHUTDOWN_RETRY_MS));
946− && let Some(deadline) = hard_deadline
947− && Instant::now() >= deadline
951− timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
952− "force exiting: VM did not stop after shutdown timeout"
956− timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
957− "force exiting: VM input not ready after shutdown timeout"
960− force_exit("vm manager forced exit");
963 Err(mpsc::RecvTimeoutError::Disconnected) => break,
610 Err(mpsc::RecvTimeoutError::Disconnected) => break,
@@ -966,112 +613,3 @@ fn manager_event_loop(
973− use std::{fs, sync::mpsc, thread, time::Duration};
976− fn manager_powers_off_after_grace_when_no_refs() {
977− let _temp = tempfile::Builder::new()
982− let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
983− let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
984− let vm_input_tx = Arc::new(Mutex::new(Some(vm_tx)));
986− let manager_thread = thread::spawn(move || {
987− manager_event_loop(event_rx, vm_input_tx, 50).expect("event loop");
990− event_tx.send(ManagerEvent::Inc(None)).unwrap();
991− assert!(vm_rx.recv_timeout(Duration::from_millis(100)).is_err());
993− event_tx.send(ManagerEvent::Dec(None)).unwrap();
995− .recv_timeout(Duration::from_secs(2))
996− .expect("poweroff");
998− VmInput::Bytes(data) => {
999− assert_eq!(data, b"systemctl poweroff\n");
1001− _ => panic!("unexpected vm input"),
1003− let _ = event_tx.send(ManagerEvent::VmExited(None));
1004− let _ = manager_thread.join();
1008− fn manager_force_exits_when_vm_input_never_ready() {
1009− let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
1010− let vm_input_tx = Arc::new(Mutex::new(None));
1012− let manager_thread = thread::spawn(move || {
1013− let _ = manager_event_loop(event_rx, vm_input_tx, 10);
1016− event_tx.send(ManagerEvent::Inc(None)).unwrap();
1017− event_tx.send(ManagerEvent::Dec(None)).unwrap();
1019− let join_result = manager_thread.join();
1021− join_result.is_err(),
1022− "expected manager to force-exit when vm input never becomes ready"
1027− fn manager_sends_shutdown_after_vm_input_becomes_ready() {
1028− let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
1029− let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
1030− let vm_input_tx = Arc::new(Mutex::new(None));
1031− let vm_input_for_thread = vm_input_tx.clone();
1033− let manager_thread = thread::spawn(move || {
1034− manager_event_loop(event_rx, vm_input_for_thread, 10).expect("event loop");
1037− event_tx.send(ManagerEvent::Inc(None)).unwrap();
1038− event_tx.send(ManagerEvent::Dec(None)).unwrap();
1040− thread::sleep(Duration::from_millis(100));
1041− *vm_input_tx.lock().unwrap() = Some(vm_tx);
1044− .recv_timeout(Duration::from_secs(2))
1045− .expect("poweroff");
1047− VmInput::Bytes(data) => {
1048− assert_eq!(data, b"systemctl poweroff\n");
1050− _ => panic!("unexpected vm input"),
1052− let _ = event_tx.send(ManagerEvent::VmExited(None));
1053− let _ = manager_thread.join();
1057− fn lock_is_not_stale_when_owner_pid_is_alive() {
1058− let temp = tempfile::Builder::new()
1060− .tempdir_in("/tmp")
1061− .expect("tempdir");
1062− let lock_path = temp.path().join("vm.lock");
1063− fs::write(&lock_path, format!("pid={}\n", std::process::id())).expect("write lock");
1064− assert!(!is_lock_stale(&lock_path));
1068− fn lock_is_stale_when_owner_pid_is_missing() {
1069− let temp = tempfile::Builder::new()
1071− .tempdir_in("/tmp")
1072− .expect("tempdir");
1073− let lock_path = temp.path().join("vm.lock");
1074− fs::write(&lock_path, "pid=999999\n").expect("write lock");
1075− assert!(is_lock_stale(&lock_path));