@@ -1,12 +1,22 @@
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};
3 io::{Read, Write},
15 io::{Read, Write},
4 os::unix::{
16 os::unix::{
6 fs::PermissionsExt,
17 fs::PermissionsExt,
7 io::AsRawFd,
18 io::AsRawFd,
8 net::{UnixListener, UnixStream},
19 net::{UnixListener, UnixStream},
11 path::{Path, PathBuf},
21 path::{Path, PathBuf},
12 process::{Command, Stdio},
22 process::{Command, Stdio},
@@ -15,36 +25,44 @@ use std::{
15 time::{Duration, Instant},
25 time::{Duration, Instant},
18−use crate::{
28+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
19− config::CONFIG_PATH_ENV,
29+const VM_ROOT_LOG_NAME: &str = "vm_root.log";
20− instance::VM_ROOT_LOG_NAME,
22− DEFAULT_SSH_USER, InstanceConfig, build_ssh_login_actions, ensure_instance_dir,
23− ensure_ssh_keypair, extract_ipv4, load_or_create_instance_config, write_instance_config,
26− GLOBAL_DIR_NAME, INSTANCE_FILENAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME,
28− vm::{self, DirectoryShare, LoginAction, VmInput},
31const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
30const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
32const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
31const 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) -> ! {
34pub fn ensure_manager(
52pub fn ensure_manager(
35 raw_args: &[std::ffi::OsString],
53 raw_args: &[std::ffi::OsString],
36 auto_shutdown_ms: u64,
54 auto_shutdown_ms: u64,
37 config_path: Option<&Path>,
55 config_path: Option<&Path>,
38−) -> Result<UnixStream, Box<dyn std::error::Error>> {
56+) -> Result<UnixStream> {
39 let project_root = env::current_dir()?;
57 let project_root = env::current_dir()?;
40 tracing::debug!(root = %project_root.display(), "ensure vm manager");
58 tracing::debug!(root = %project_root.display(), "ensure vm manager");
41 let instance_dir = ensure_instance_dir(&project_root)?;
59 let instance_dir = ensure_instance_dir(&project_root)?;
42− cleanup_stale_manager(&instance_dir);
60+ cleanup_stale_manager(&project_root)?;
43 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
61 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
45 if let Ok(stream) = UnixStream::connect(&socket_path) {
63 if let Ok(stream) = UnixStream::connect(&socket_path) {
46 send_client_pid(&stream);
64 send_client_pid(&stream);
47− tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
65+ tracing::info!(path = %socket_path.display(), "connected to an existing vm manager");
48 return Ok(stream);
66 return Ok(stream);
@@ -82,12 +100,11 @@ pub fn ensure_manager(
82 drop(lock_file.take());
100 drop(lock_file.take());
83 let _ = fs::remove_file(&lock_path);
101 let _ = fs::remove_file(&lock_path);
85− return Err(format!(
103+ bail!(format!(
86− "Timed out waiting for vm manager socket: {} ({})",
104+ "timed out waiting for vm manager socket: {} ({})",
87 socket_path.display(),
105 socket_path.display(),
92 thread::sleep(Duration::from_millis(100));
109 thread::sleep(Duration::from_millis(100));
@@ -95,24 +112,49 @@ pub fn ensure_manager(
100− auto_shutdown_ms: u64,
115+pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> {
101−) -> Result<(), Box<dyn std::error::Error>> {
102 let project_root = env::current_dir()?;
116 let project_root = env::current_dir()?;
103 tracing::info!(root = %project_root.display(), "vm manager starting");
117 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");
104 let _pid_guard = ensure_pid_file(&project_root)?;
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"))]
105− run_manager_with(
157+ run_manager_with(&project_root, args, auto_shutdown_ms, executor, options)
111− ensure_signed: true,
118fn spawn_manager_process(
160fn spawn_manager_process(
@@ -120,30 +162,28 @@ fn spawn_manager_process(
120 auto_shutdown_ms: u64,
162 auto_shutdown_ms: u64,
121 instance_dir: &Path,
163 instance_dir: &Path,
122 config_path: Option<&Path>,
164 config_path: Option<&Path>,
123−) -> Result<(), Box<dyn std::error::Error>> {
165+) -> Result<()> {
124 let exe = env::current_exe()?;
166 let exe = env::current_exe()?;
125 let mut supervisor_exe = exe.clone();
167 let mut supervisor_exe = exe.clone();
126 supervisor_exe.set_file_name("vibebox-supervisor");
168 supervisor_exe.set_file_name("vibebox-supervisor");
127− let use_supervisor = supervisor_exe.exists();
169+ // intentional
128− let mut cmd = if use_supervisor {
170+ if !supervisor_exe.exists() {
129− Command::new(supervisor_exe)
171+ bail!(format!(
131− let mut cmd = Command::new(exe);
132− cmd.arg0("vibebox-supervisor");
172+ "vibebox-supervisor not found at {}",
133− cmd
173+ supervisor_exe.display()
176+ let mut cmd = Command::new(supervisor_exe);
135 if raw_args.len() > 1 {
177 if raw_args.len() > 1 {
136 cmd.args(&raw_args[1..]);
178 cmd.args(&raw_args[1..]);
138− if !use_supervisor {
180+ cmd.env("VIBEBOX_INTERNAL", "1");
139− cmd.env("VIBEBOX_VM_MANAGER", "1");
141 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
181 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
142 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
182 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
143 if let Some(path) = config_path {
183 if let Some(path) = config_path {
144 cmd.env(CONFIG_PATH_ENV, path);
184 cmd.env(CONFIG_PATH_ENV, path);
146− tracing::info!(auto_shutdown_ms, "vm manager process spawn requested");
186+ tracing::debug!(auto_shutdown_ms, "vm manager process spawn requested");
147 let log_path = instance_dir.join(VM_MANAGER_LOG_NAME);
187 let log_path = instance_dir.join(VM_MANAGER_LOG_NAME);
148 let log_file = fs::OpenOptions::new()
188 let log_file = fs::OpenOptions::new()
149 .create(true)
189 .create(true)
@@ -165,46 +205,65 @@ fn spawn_manager_process(
168−fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard, Box<dyn std::error::Error>> {
208+fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard> {
169 let instance_dir = ensure_instance_dir(project_root)?;
209 let instance_dir = ensure_instance_dir(project_root)?;
170 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
210 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
171 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
211 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
172− if let Ok(content) = fs::read_to_string(&pid_path) {
212+ match vm_liveness(project_root)? {
173− if let Ok(pid) = content.trim().parse::<u32>() {
213+ VmLiveness::RunningWithSocket { pid } => {
174− if pid_is_alive(pid) {
175− if is_socket_path(&socket_path) {
176− return Err(format!("vm manager already running (pid {pid})").into());
214+ bail!("vm manager already running (pid {pid})");
216+ VmLiveness::RunningWithoutSocket { pid } => {
178− tracing::warn!(
217+ tracing::warn!(
180− path = %socket_path.display(),
219+ path = %socket_path.display(),
181− "stale pid file detected with missing socket"
220+ "stale pid file detected with missing socket"
185− let _ = fs::remove_file(&pid_path);
223+ VmLiveness::NotRunningOrMissing => {}
225+ let _ = fs::remove_file(&pid_path);
187 fs::write(&pid_path, format!("{}\n", std::process::id()))?;
226 fs::write(&pid_path, format!("{}\n", std::process::id()))?;
188 let _ = fs::set_permissions(&pid_path, fs::Permissions::from_mode(0o600));
227 let _ = fs::set_permissions(&pid_path, fs::Permissions::from_mode(0o600));
189 Ok(PidFileGuard { path: pid_path })
228 Ok(PidFileGuard { path: pid_path })
192−fn cleanup_stale_manager(instance_dir: &Path) {
231+fn cleanup_stale_manager(project_root: &Path) -> Result<()> {
232+ let pid_path = project_root
233+ .join(INSTANCE_DIR_NAME)
193− let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
234+ .join(VM_MANAGER_PID_NAME);
194− if let Ok(content) = fs::read_to_string(&pid_path) {
235+ if matches!(
195− if let Ok(pid) = content.trim().parse::<u32>() {
236+ vm_liveness(project_root)?,
196− if pid_is_alive(pid) {
237+ VmLiveness::RunningWithSocket { .. } | VmLiveness::RunningWithoutSocket { .. }
197− return;
239+ return Ok(());
201 let _ = fs::remove_file(&pid_path);
241 let _ = fs::remove_file(&pid_path);
204−fn is_socket_path(path: &Path) -> bool {
245+fn inject_project_mount(
205− fs::metadata(path)
246+ mounts: &mut Vec<String>,
206− .map(|meta| meta.file_type().is_socket())
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 {
207− .unwrap_or(false)
257+ return false;
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"));
210fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
269fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
@@ -253,7 +312,7 @@ fn rewrite_mount_spec(spec: &str, ssh_user: &str) -> (String, Option<HomeLink>)
253 return (spec.to_string(), None);
312 return (spec.to_string(), None);
256− let root_base = "/usr/local/vibebox-mounts";
315+ let root_base = PROJECT_GUEST_BASE;
257 let root_path = if rel.is_empty() {
316 let root_path = if rel.is_empty() {
258 root_base.to_string()
317 root_base.to_string()
@@ -350,6 +409,30 @@ 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));
353fn send_client_pid(stream: &UnixStream) {
436fn send_client_pid(stream: &UnixStream) {
354 let pid = std::process::id();
437 let pid = std::process::id();
355 let payload = format!("pid={pid}\n");
438 let payload = format!("pid={pid}\n");
@@ -359,7 +442,7 @@ fn send_client_pid(stream: &UnixStream) {
362−fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>, Box<dyn std::error::Error>> {
445+fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>> {
363 match fs::OpenOptions::new()
446 match fs::OpenOptions::new()
364 .write(true)
447 .write(true)
365 .create_new(true)
448 .create_new(true)
@@ -393,6 +476,12 @@ 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()
396fn pid_is_alive(pid: u32) -> bool {
485fn pid_is_alive(pid: u32) -> bool {
397 let pid = pid as libc::pid_t;
486 let pid = pid as libc::pid_t;
398 let result = unsafe { libc::kill(pid, 0) };
487 let result = unsafe { libc::kill(pid, 0) };
@@ -406,12 +495,6 @@ fn pid_is_alive(pid: u32) -> bool {
409−fn read_lock_pid(lock_path: &Path) -> Option<u32> {
410− let content = fs::read_to_string(lock_path).ok()?;
411− let line = content.lines().next()?;
412− line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
415fn read_client_pid(stream: &UnixStream) -> Option<u32> {
498fn read_client_pid(stream: &UnixStream) -> Option<u32> {
416 let mut stream = stream.try_clone().ok()?;
499 let mut stream = stream.try_clone().ok()?;
417 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
500 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
@@ -422,7 +505,7 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
422 Ok(0) => break,
505 Ok(0) => break,
423 Ok(n) => {
506 Ok(n) => {
424 len += n;
507 len += n;
425− if buf[..len].iter().any(|b| *b == b'\n') || len == buf.len() {
508+ if buf[..len].contains(&b'\n') || len == buf.len() {
@@ -444,14 +527,17 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
530+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
447fn spawn_manager_io(
531fn spawn_manager_io(
448 config: Arc<Mutex<InstanceConfig>>,
532 config: Arc<Mutex<InstanceConfig>>,
449− instance_dir: PathBuf,
533+ project_dir: PathBuf,
534+ clients: ClientStreams,
535+ latest_status: SharedStatus,
450 output_monitor: Arc<vm::OutputMonitor>,
536 output_monitor: Arc<vm::OutputMonitor>,
451 vm_output_fd: std::os::unix::io::OwnedFd,
537 vm_output_fd: std::os::unix::io::OwnedFd,
452 vm_input_fd: std::os::unix::io::OwnedFd,
538 vm_input_fd: std::os::unix::io::OwnedFd,
453) -> vm::IoContext {
539) -> vm::IoContext {
454− let log_path = instance_dir.join(VM_ROOT_LOG_NAME);
540+ let log_path = project_dir.join(INSTANCE_DIR_NAME).join(VM_ROOT_LOG_NAME);
455 let log_file = fs::OpenOptions::new()
541 let log_file = fs::OpenOptions::new()
456 .create(true)
542 .create(true)
457 .write(true)
543 .write(true)
@@ -460,16 +546,15 @@ fn spawn_manager_io(
461 .map(|file| Arc::new(Mutex::new(file)));
547 .map(|file| Arc::new(Mutex::new(file)));
463− let instance_path = instance_dir.join(INSTANCE_FILENAME);
464 let config_for_output = config.clone();
549 let config_for_output = config.clone();
465 let log_for_output = log_file.clone();
550 let log_for_output = log_file.clone();
466 let mut line_buf = String::new();
551 let mut line_buf = String::new();
468 let on_output = move |bytes: &[u8]| {
553 let on_output = move |bytes: &[u8]| {
469− if let Some(log) = &log_for_output {
554+ if let Some(log) = &log_for_output
470− if let Ok(mut file) = log.lock() {
555+ && let Ok(mut file) = log.lock()
471− let _ = file.write_all(bytes);
557+ let _ = file.write_all(bytes);
475 let text = String::from_utf8_lossy(bytes);
560 let text = String::from_utf8_lossy(bytes);
@@ -482,17 +567,27 @@ fn spawn_manager_io(
483 line_buf.drain(..=pos);
568 line_buf.drain(..=pos);
485− let cleaned = line.trim_start_matches(|c: char| c == '\r' || c == ' ');
570+ let cleaned = line.trim_start_matches(['\r', ' ']);
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}"),
486 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
582 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
487 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
583 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
488 let ip = extract_ipv4(ip_raw).unwrap_or_default();
584 let ip = extract_ipv4(ip_raw).unwrap_or_default();
489− if !ip.is_empty() {
585+ if !ip.is_empty()
490− if let Ok(mut cfg) = config_for_output.lock() {
586+ && let Ok(mut cfg) = config_for_output.lock()
491− if cfg.vm_ipv4.as_deref() != Some(ip.as_str()) {
587+ && cfg.vm_ipv4.as_deref() != Some(ip.as_str())
492− cfg.vm_ipv4 = Some(ip.clone());
589+ cfg.vm_ipv4 = Some(ip.clone());
493− let _ = write_instance_config(&instance_path, &cfg);
590+ let _ = write_instance_config(&project_dir, &cfg);
@@ -514,51 +609,13 @@ enum ManagerEvent {
514 VmExited(Option<String>),
609 VmExited(Option<String>),
520− use std::{sync::mpsc, time::Duration};
523− fn manager_powers_off_after_grace_when_no_refs() {
524− let _temp = tempfile::Builder::new()
529− let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
530− let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
531− let vm_input_tx = Arc::new(Mutex::new(Some(vm_tx)));
533− let manager_thread = thread::spawn(move || {
534− manager_event_loop(event_rx, vm_input_tx, 50).expect("event loop");
537− event_tx.send(ManagerEvent::Inc(None)).unwrap();
538− assert!(vm_rx.recv_timeout(Duration::from_millis(100)).is_err());
540− event_tx.send(ManagerEvent::Dec(None)).unwrap();
542− .recv_timeout(Duration::from_secs(2))
543− .expect("poweroff");
545− VmInput::Bytes(data) => {
546− assert_eq!(data, b"systemctl poweroff\n");
548− _ => panic!("unexpected vm input"),
550− let _ = event_tx.send(ManagerEvent::VmExited(None));
551− let _ = manager_thread.join();
555struct ManagerOptions {
612struct ManagerOptions {
556− ensure_signed: bool,
557 detach: bool,
613 detach: bool,
558 prepare_vm: bool,
614 prepare_vm: bool,
561trait VmExecutor {
617trait VmExecutor {
618+ #[allow(clippy::too_many_arguments)]
562 fn run_vm(
619 fn run_vm(
564 args: vm::VmArg,
621 args: vm::VmArg,
@@ -566,10 +623,13 @@ trait VmExecutor {
566 extra_shares: Vec<DirectoryShare>,
623 extra_shares: Vec<DirectoryShare>,
567 config: Arc<Mutex<InstanceConfig>>,
624 config: Arc<Mutex<InstanceConfig>>,
568 instance_dir: PathBuf,
625 instance_dir: PathBuf,
626+ clients: ClientStreams,
627+ latest_status: SharedStatus,
569 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
628 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
570− ) -> Result<(), Box<dyn std::error::Error>>;
629+ ) -> Result<()>;
632+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
573struct RealVmExecutor;
633struct RealVmExecutor;
575impl VmExecutor for RealVmExecutor {
635impl VmExecutor for RealVmExecutor {
@@ -579,15 +639,22 @@ impl VmExecutor for RealVmExecutor {
579 extra_login_actions: Vec<LoginAction>,
639 extra_login_actions: Vec<LoginAction>,
580 extra_shares: Vec<DirectoryShare>,
640 extra_shares: Vec<DirectoryShare>,
581 config: Arc<Mutex<InstanceConfig>>,
641 config: Arc<Mutex<InstanceConfig>>,
582− instance_dir: PathBuf,
642+ project_dir: PathBuf,
643+ clients: ClientStreams,
644+ latest_status: SharedStatus,
583 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
645 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
584− ) -> Result<(), Box<dyn std::error::Error>> {
646+ ) -> Result<()> {
647+ let status_callback = |status: &str| {
648+ broadcast_status(&clients, &latest_status, status);
585− let result = vm::run_with_args_and_extras(
650+ vm::run_with_args_and_extras(
587 |output_monitor, vm_output_fd, vm_input_fd| {
652 |output_monitor, vm_output_fd, vm_input_fd| {
588 let io_ctx = spawn_manager_io(
653 let io_ctx = spawn_manager_io(
589 config.clone(),
654 config.clone(),
590− instance_dir.clone(),
655+ project_dir.clone(),
657+ latest_status.clone(),
591 output_monitor,
658 output_monitor,
592 vm_output_fd,
659 vm_output_fd,
593 vm_input_fd,
660 vm_input_fd,
@@ -597,35 +664,60 @@ impl VmExecutor for RealVmExecutor {
598 extra_login_actions,
665 extra_login_actions,
599 extra_shares,
666 extra_shares,
600− );
667+ Some(&status_callback),
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");
605fn run_manager_with(
707fn run_manager_with(
606 project_root: &Path,
708 project_root: &Path,
607− args: vm::VmArg,
709+ mut args: vm::VmArg,
608 auto_shutdown_ms: u64,
710 auto_shutdown_ms: u64,
609 executor: &dyn VmExecutor,
711 executor: &dyn VmExecutor,
610 options: ManagerOptions,
712 options: ManagerOptions,
611−) -> Result<(), Box<dyn std::error::Error>> {
713+) -> Result<()> {
612− if options.ensure_signed {
613− let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok();
615− env::remove_var("VIBEBOX_SKIP_CODESIGN");
617− vm::ensure_signed();
619− env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
622 if options.detach {
714 if options.detach {
623 detach_from_terminal();
715 detach_from_terminal();
626 let project_name = project_root
718 let project_name = project_root
627 .file_name()
719 .file_name()
628− .ok_or("Project directory has no name")?
720+ .ok_or_else(|| anyhow!("Project directory has no name"))?
629 .to_string_lossy()
721 .to_string_lossy()
630 .into_owned();
722 .into_owned();
631 let instance_dir = ensure_instance_dir(project_root)?;
723 let instance_dir = ensure_instance_dir(project_root)?;
@@ -633,27 +725,31 @@ fn run_manager_with(
633 let _ = ensure_ssh_keypair(&instance_dir)?;
725 let _ = ensure_ssh_keypair(&instance_dir)?;
636− let mut config = load_or_create_instance_config(&instance_dir)?;
728+ let mut config = load_or_create_instance_config(project_root)?;
637 if config.vm_ipv4.is_some() {
729 if config.vm_ipv4.is_some() {
638 config.vm_ipv4 = None;
730 config.vm_ipv4 = None;
639− write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
731+ write_instance_config(project_root, &config)?;
641 let config = Arc::new(Mutex::new(config));
733 let config = Arc::new(Mutex::new(config));
642 let ssh_user = config
734 let ssh_user = config
644− .map(|cfg| cfg.ssh_user_display())
736+ .map(|cfg| cfg.ssh_user.clone())
645− .unwrap_or_else(|_| DEFAULT_SSH_USER.to_string());
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);
646 let (args, home_links_script) = prepare_mounts_and_links(args, &ssh_user);
741 let (args, home_links_script) = prepare_mounts_and_links(args, &ssh_user);
743+ let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
648 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
744 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
649− let extra_shares = vec![DirectoryShare::new(
745+ let extra_shares = vec![
650− instance_dir.clone(),
651− ssh_guest_dir.clone().into(),
746+ DirectoryShare::new(instance_dir.clone(), ssh_guest_dir.clone().into(), true)
652− true,
747+ .map_err(|err| anyhow!(err.to_string()))?,
654 let extra_login_actions = build_ssh_login_actions(
749 let extra_login_actions = build_ssh_login_actions(
656 &project_name,
751 &project_name,
657 ssh_guest_dir.as_str(),
753 ssh_guest_dir.as_str(),
658 "ssh_key",
754 "ssh_key",
659 &home_links_script,
755 &home_links_script,
@@ -672,17 +768,43 @@ fn run_manager_with(
672 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
768 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
673 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
769 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()));
675 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
773 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
676 let event_tx_accept = event_tx.clone();
774 let event_tx_accept = event_tx.clone();
775+ let clients_accept = clients.clone();
776+ let latest_status_accept = latest_status.clone();
677 thread::spawn(move || {
777 thread::spawn(move || {
678 for stream in listener.incoming() {
778 for stream in listener.incoming() {
679 match stream {
779 match stream {
680 Ok(stream) => {
780 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);
681 let event_tx_conn = event_tx_accept.clone();
799 let event_tx_conn = event_tx_accept.clone();
800+ let clients_conn = clients_accept.clone();
682 thread::spawn(move || {
801 thread::spawn(move || {
683 let pid = read_client_pid(&stream);
802 let pid = read_client_pid(&stream);
684 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
803 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
685 wait_for_disconnect(stream);
804 wait_for_disconnect(stream);
805+ if let Some(fd) = client_fd {
806+ remove_client(&clients_conn, fd);
686 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
808 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
@@ -702,43 +824,59 @@ fn run_manager_with(
702 extra_login_actions,
824 extra_login_actions,
703 extra_shares,
825 extra_shares,
704 config.clone(),
826 config.clone(),
705− instance_dir.clone(),
827+ project_root.to_path_buf(),
829+ latest_status.clone(),
706 vm_input_tx.clone(),
830 vm_input_tx.clone(),
708 tracing::info!("vm manager vm run completed");
832 tracing::info!("vm manager vm run completed");
709 let vm_err = vm_result.err().map(|e| e.to_string());
833 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}"),
710 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
841 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
711− let event_loop_result: Result<(), String> = event_loop_handle
842+ let event_loop_result = event_loop_handle
713− .unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
844+ .unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked")))
714 .map_err(|err| err.to_string());
845 .map_err(|err| err.to_string());
715 let _ = fs::remove_file(&socket_path);
846 let _ = fs::remove_file(&socket_path);
716 if let Err(err) = &event_loop_result {
847 if let Err(err) = &event_loop_result {
717 tracing::error!(error = %err, "vm manager exiting due to event loop error");
848 tracing::error!(error = %err, "vm manager exiting due to event loop error");
718− return Err(err.to_string().into());
849+ bail!(err.to_string());
720 if let Some(err) = vm_err {
851 if let Some(err) = vm_err {
721 tracing::error!(error = %err, "vm manager exiting due to vm error");
852 tracing::error!(error = %err, "vm manager exiting due to vm error");
722− return Err(err.into());
853+ bail!(err);
724 tracing::info!("vm manager exiting");
855 tracing::info!("vm manager exiting");
725− Ok(event_loop_result?)
856+ event_loop_result.map_err(Error::msg)
728fn manager_event_loop(
859fn manager_event_loop(
729 event_rx: mpsc::Receiver<ManagerEvent>,
860 event_rx: mpsc::Receiver<ManagerEvent>,
730 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
861 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
731 auto_shutdown_ms: u64,
862 auto_shutdown_ms: u64,
732−) -> Result<(), String> {
863+) -> Result<()> {
733 let mut ref_count: usize = 0;
864 let mut ref_count: usize = 0;
734 let mut shutdown_deadline: Option<Instant> = None;
865 let mut shutdown_deadline: Option<Instant> = None;
735 let mut shutdown_sent = false;
866 let mut shutdown_sent = false;
867+ let mut hard_deadline: Option<Instant> = None;
736 let grace = Duration::from_millis(auto_shutdown_ms.max(1));
868 let grace = Duration::from_millis(auto_shutdown_ms.max(1));
869+ let hard_timeout = Duration::from_millis(HARD_SHUTDOWN_TIMEOUT_MS);
739− let timeout = match shutdown_deadline {
872+ let timeout = match (shutdown_deadline, hard_deadline) {
873+ (Some(shutdown), Some(hard)) => {
874+ let next = if shutdown <= hard { shutdown } else { hard };
875+ next.saturating_duration_since(Instant::now())
740− Some(deadline) => deadline.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()),
741− None => Duration::from_secs(1),
879+ (None, None) => Duration::from_secs(1),
744 match event_rx.recv_timeout(timeout) {
882 match event_rx.recv_timeout(timeout) {
@@ -752,11 +890,10 @@ fn manager_event_loop(
753 shutdown_deadline = None;
891 shutdown_deadline = None;
754 shutdown_sent = false;
892 shutdown_sent = false;
893+ hard_deadline = None;
756 Ok(ManagerEvent::Dec(pid)) => {
895 Ok(ManagerEvent::Dec(pid)) => {
758− ref_count -= 1;
896+ ref_count = ref_count.saturating_sub(1);
760 tracing::info!(
897 tracing::info!(
761 ref_count,
898 ref_count,
762 pid = pid.unwrap_or(0),
899 pid = pid.unwrap_or(0),
@@ -775,15 +912,52 @@ fn manager_event_loop(
777 Err(mpsc::RecvTimeoutError::Timeout) => {
914 Err(mpsc::RecvTimeoutError::Timeout) => {
778− if let Some(deadline) = shutdown_deadline {
915+ if let Some(deadline) = shutdown_deadline
779− if Instant::now() >= deadline && !shutdown_sent {
916+ && Instant::now() >= deadline
919+ if hard_deadline.is_none() {
920+ hard_deadline = Some(Instant::now() + hard_timeout);
922+ let mut sent = false;
780− if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
923+ if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
781− let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
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");
783 tracing::info!("shutdown command sent");
936 tracing::info!("shutdown command sent");
784 shutdown_sent = true;
937 shutdown_sent = true;
785 shutdown_deadline = None;
938 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");
789 Err(mpsc::RecvTimeoutError::Disconnected) => break,
963 Err(mpsc::RecvTimeoutError::Disconnected) => break,
@@ -792,3 +966,112 @@ 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));