@@ -1,3 +1,15 @@
1+use crate::instance::{VmLiveness, vm_liveness};
2+use crate::session_manager::INSTANCE_DIR_NAME;
3+use crate::{
4+ config::CONFIG_PATH_ENV,
5+ instance::{
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,
8+ },
9+ session_manager::{GLOBAL_DIR_NAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
10+ vm::{self, DirectoryShare, LoginAction, PROJECT_GUEST_BASE, VmInput},
11+};
12+use anyhow::{Error, Result, anyhow, bail};
113use std::{
214 env, fs,
315 io::{Read, Write},
@@ -5,7 +17,6 @@ use std::{
517 fs::PermissionsExt,
618 io::AsRawFd,
719 net::{UnixListener, UnixStream},
8− process::CommandExt,
920 },
1021 path::{Path, PathBuf},
1122 process::{Command, Stdio},
@@ -14,34 +25,60 @@ use std::{
1425 time::{Duration, Instant},
1526};
1627
17−use crate::{
18− instance::{
19− InstanceConfig, build_ssh_login_actions, ensure_instance_dir, ensure_ssh_keypair,
20− extract_ipv4, load_or_create_instance_config, write_instance_config,
21− },
22− session_manager::GLOBAL_DIR_NAME,
23− vm::{self, DirectoryShare, LoginAction, VmInput},
24−};
28+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
29+const VM_ROOT_LOG_NAME: &str = "vm_root.log";
30+const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
31+const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
32+const SHUTDOWN_RETRY_MS: u64 = 500;
33+#[cfg(test)]
34+const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 1_000;
35+#[cfg(not(test))]
36+const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000;
37+const STATUS_PREFIX: &str = "status:";
2538
26−const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
39+type ClientStreams = Arc<Mutex<Vec<UnixStream>>>;
40+type SharedStatus = Arc<Mutex<String>>;
41+
42+#[cfg(not(test))]
43+fn force_exit(_reason: &str) -> ! {
44+ std::process::exit(1);
45+}
46+
47+#[cfg(test)]
48+fn force_exit(reason: &str) -> ! {
49+ panic!("{reason}");
50+}
2751
2852pub fn ensure_manager(
2953 raw_args: &[std::ffi::OsString],
3054 auto_shutdown_ms: u64,
31−) -> Result<UnixStream, Box<dyn std::error::Error>> {
55+ config_path: Option<&Path>,
56+) -> Result<UnixStream> {
3257 let project_root = env::current_dir()?;
3358 tracing::debug!(root = %project_root.display(), "ensure vm manager");
3459 let instance_dir = ensure_instance_dir(&project_root)?;
60+ cleanup_stale_manager(&project_root)?;
3561 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
3662
3763 if let Ok(stream) = UnixStream::connect(&socket_path) {
3864 send_client_pid(&stream);
39− tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
65+ tracing::info!(path = %socket_path.display(), "connected to an existing vm manager");
4066 return Ok(stream);
4167 }
4268
43− tracing::info!(path = %socket_path.display(), "spawning vm manager");
44− spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir)?;
69+ let lock_path = instance_dir.join(VM_MANAGER_LOCK_NAME);
70+ let mut lock_file = acquire_spawn_lock(&lock_path)?;
71+ if lock_file.is_some() {
72+ tracing::info!(path = %socket_path.display(), "spawning vm manager");
73+ spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir, config_path)?;
74+ } else {
75+ tracing::info!(
76+ path = %socket_path.display(),
77+ lock = %lock_path.display(),
78+ lock_pid = read_lock_pid(&lock_path).unwrap_or(0),
79+ "waiting for vm manager spawn lock"
80+ );
81+ }
4582
4683 let start = Instant::now();
4784 let timeout = Duration::from_secs(10);
@@ -50,17 +87,24 @@ pub fn ensure_manager(
5087 Ok(stream) => {
5188 send_client_pid(&stream);
5289 tracing::info!(path = %socket_path.display(), "connected to vm manager");
90+ if lock_file.is_some() {
91+ drop(lock_file.take());
92+ let _ = fs::remove_file(&lock_path);
93+ }
5394 return Ok(stream);
5495 }
5596 Err(err) => {
5697 tracing::debug!(error = %err, "waiting for vm manager socket");
5798 if start.elapsed() > timeout {
58− return Err(format!(
59− "Timed out waiting for vm manager socket: {} ({})",
99+ if lock_file.is_some() {
100+ drop(lock_file.take());
101+ let _ = fs::remove_file(&lock_path);
102+ }
103+ bail!(format!(
104+ "timed out waiting for vm manager socket: {} ({})",
60105 socket_path.display(),
61106 err
62− )
63− .into());
107+ ));
64108 }
65109 thread::sleep(Duration::from_millis(100));
66110 }
@@ -68,54 +112,83 @@ pub fn ensure_manager(
68112 }
69113}
70114
71−pub fn run_manager(
72− args: vm::CliArgs,
73− auto_shutdown_ms: u64,
74−) -> Result<(), Box<dyn std::error::Error>> {
115+pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> {
75116 let project_root = env::current_dir()?;
76117 tracing::info!(root = %project_root.display(), "vm manager starting");
77− run_manager_with(
78− &project_root,
79− args,
80− auto_shutdown_ms,
81− &RealVmExecutor,
82− ManagerOptions {
83− ensure_signed: true,
84− detach: true,
85− prepare_vm: true,
86− },
87− )
118+ #[cfg(not(feature = "mock-vm"))]
119+ {
120+ unsafe {
121+ env::remove_var("VIBEBOX_SKIP_CODESIGN");
122+ }
123+ vm::ensure_signed();
124+ unsafe {
125+ env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
126+ }
127+ }
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")]
133+ {
134+ &MockVmExecutor
135+ }
136+ #[cfg(not(feature = "mock-vm"))]
137+ {
138+ &RealVmExecutor
139+ }
140+ };
141+ let options = {
142+ #[cfg(feature = "mock-vm")]
143+ {
144+ ManagerOptions {
145+ detach: true,
146+ prepare_vm: false,
147+ }
148+ }
149+ #[cfg(not(feature = "mock-vm"))]
150+ {
151+ ManagerOptions {
152+ detach: true,
153+ prepare_vm: true,
154+ }
155+ }
156+ };
157+ run_manager_with(&project_root, args, auto_shutdown_ms, executor, options)
88158}
89159
90160fn spawn_manager_process(
91161 raw_args: &[std::ffi::OsString],
92162 auto_shutdown_ms: u64,
93163 instance_dir: &Path,
94−) -> Result<(), Box<dyn std::error::Error>> {
164+ config_path: Option<&Path>,
165+) -> Result<()> {
95166 let exe = env::current_exe()?;
96167 let mut supervisor_exe = exe.clone();
97168 supervisor_exe.set_file_name("vibebox-supervisor");
98− let use_supervisor = supervisor_exe.exists();
99− let mut cmd = if use_supervisor {
100− Command::new(supervisor_exe)
101− } else {
102− let mut cmd = Command::new(exe);
103− cmd.arg0("vibebox-supervisor");
104− cmd
105− };
169+ // intentional
170+ if !supervisor_exe.exists() {
171+ bail!(format!(
172+ "vibebox-supervisor not found at {}",
173+ supervisor_exe.display()
174+ ));
175+ }
176+ let mut cmd = Command::new(supervisor_exe);
106177 if raw_args.len() > 1 {
107178 cmd.args(&raw_args[1..]);
108179 }
109− if !use_supervisor {
110− cmd.env("VIBEBOX_VM_MANAGER", "1");
111− }
180+ cmd.env("VIBEBOX_INTERNAL", "1");
112181 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
113182 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
114− tracing::info!(auto_shutdown_ms, "vm manager process spawn requested");
115− let log_path = instance_dir.join("vm_manager.log");
183+ if let Some(path) = config_path {
184+ cmd.env(CONFIG_PATH_ENV, path);
185+ }
186+ tracing::debug!(auto_shutdown_ms, "vm manager process spawn requested");
187+ let log_path = instance_dir.join(VM_MANAGER_LOG_NAME);
116188 let log_file = fs::OpenOptions::new()
117189 .create(true)
118− .append(true)
190+ .write(true)
191+ .truncate(true)
119192 .open(&log_path)
120193 .ok();
121194 if let Some(file) = log_file {
@@ -132,6 +205,184 @@ fn spawn_manager_process(
132205 Ok(())
133206}
134207
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})");
215+ }
216+ VmLiveness::RunningWithoutSocket { pid } => {
217+ tracing::warn!(
218+ pid,
219+ path = %socket_path.display(),
220+ "stale pid file detected with missing socket"
221+ );
222+ }
223+ VmLiveness::NotRunningOrMissing => {}
224+ }
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 })
229+}
230+
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);
235+ if matches!(
236+ vm_liveness(project_root)?,
237+ VmLiveness::RunningWithSocket { .. } | VmLiveness::RunningWithoutSocket { .. }
238+ ) {
239+ return Ok(());
240+ }
241+ let _ = fs::remove_file(&pid_path);
242+ Ok(())
243+}
244+
245+fn inject_project_mount(
246+ mounts: &mut Vec<String>,
247+ project_root: &Path,
248+ ssh_user: &str,
249+ project_name: &str,
250+) {
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 {
257+ return false;
258+ }
259+ let guest = parts[1];
260+ guest == guest_tilde || guest == guest_home || guest == guest_base
261+ });
262+ if already_mapped {
263+ return;
264+ }
265+ let host = project_root.display();
266+ mounts.insert(0, format!("{host}:{guest_tilde}:read-write"));
267+}
268+
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 {
275+ links.push(link);
276+ }
277+ mounts.push(rewritten);
278+ }
279+ args.mounts = mounts;
280+ let script = render_home_links_script(&links, ssh_user);
281+ (args, script)
282+}
283+
284+struct HomeLink {
285+ source: String,
286+ target: String,
287+}
288+
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);
293+ }
294+ let host = parts[0];
295+ let guest = parts[1];
296+ let mode = parts.get(2).copied();
297+
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)
307+ } else {
308+ (String::new(), false)
309+ };
310+
311+ if !is_home {
312+ return (spec.to_string(), None);
313+ }
314+
315+ let root_base = PROJECT_GUEST_BASE;
316+ let root_path = if rel.is_empty() {
317+ root_base.to_string()
318+ } else {
319+ format!("{root_base}/{rel}")
320+ };
321+ let target = if rel.is_empty() {
322+ home_prefix
323+ } else {
324+ format!("{home_prefix}/{rel}")
325+ };
326+
327+ let rewritten = match mode {
328+ Some(mode) => format!("{host}:{root_path}:{mode}"),
329+ None => format!("{host}:{root_path}"),
330+ };
331+
332+ (
333+ rewritten,
334+ Some(HomeLink {
335+ source: root_path,
336+ target,
337+ }),
338+ )
339+}
340+
341+fn render_home_links_script(links: &[HomeLink], ssh_user: &str) -> String {
342+ if links.is_empty() {
343+ return String::new();
344+ }
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());
359+ lines.push(format!(
360+ " chown -h \"{ssh_user}:{ssh_user}\" \"$dest\" 2>/dev/null || true"
361+ ));
362+ lines.push("}".to_string());
363+ for link in links {
364+ let src = shell_escape(&link.source);
365+ let dest = shell_escape(&link.target);
366+ lines.push(format!("link_home {src} {dest}"));
367+ }
368+ lines.join("\n")
369+}
370+
371+fn shell_escape(value: &str) -> String {
372+ let escaped = value.replace('\'', "'\"'\"'");
373+ format!("'{escaped}'")
374+}
375+
376+struct PidFileGuard {
377+ path: PathBuf,
378+}
379+
380+impl Drop for PidFileGuard {
381+ fn drop(&mut self) {
382+ let _ = fs::remove_file(&self.path);
383+ }
384+}
385+
135386fn detach_from_terminal() {
136387 unsafe {
137388 libc::setsid();
@@ -158,6 +409,30 @@ fn wait_for_disconnect(mut stream: UnixStream) {
158409 }
159410}
160411
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);
415+ }
416+}
417+
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);
422+ payload.push('\n');
423+ stream.write_all(payload.as_bytes()).is_ok()
424+}
425+
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();
430+ }
431+ if let Ok(mut clients) = streams.lock() {
432+ clients.retain_mut(|stream| send_status_line(stream, status));
433+ }
434+}
435+
161436fn send_client_pid(stream: &UnixStream) {
162437 let pid = std::process::id();
163438 let payload = format!("pid={pid}\n");
@@ -167,6 +442,59 @@ fn send_client_pid(stream: &UnixStream) {
167442 }
168443}
169444
445+fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>> {
446+ match fs::OpenOptions::new()
447+ .write(true)
448+ .create_new(true)
449+ .open(lock_path)
450+ {
451+ Ok(mut file) => {
452+ let pid = std::process::id();
453+ let _ = writeln!(file, "pid={pid}");
454+ Ok(Some(file))
455+ }
456+ Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
457+ if is_lock_stale(lock_path) {
458+ tracing::warn!(
459+ lock = %lock_path.display(),
460+ lock_pid = read_lock_pid(lock_path).unwrap_or(0),
461+ "stale vm manager lock removed"
462+ );
463+ let _ = fs::remove_file(lock_path);
464+ return acquire_spawn_lock(lock_path);
465+ }
466+ Ok(None)
467+ }
468+ Err(err) => Err(err.into()),
469+ }
470+}
471+
472+fn is_lock_stale(lock_path: &Path) -> bool {
473+ match read_lock_pid(lock_path) {
474+ Some(pid) => !pid_is_alive(pid),
475+ None => true,
476+ }
477+}
478+
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()
483+}
484+
485+fn pid_is_alive(pid: u32) -> bool {
486+ let pid = pid as libc::pid_t;
487+ let result = unsafe { libc::kill(pid, 0) };
488+ if result == 0 {
489+ return true;
490+ }
491+ match std::io::Error::last_os_error().raw_os_error() {
492+ Some(code) if code == libc::EPERM => true,
493+ Some(code) if code == libc::ESRCH => false,
494+ _ => false,
495+ }
496+}
497+
170498fn read_client_pid(stream: &UnixStream) -> Option<u32> {
171499 let mut stream = stream.try_clone().ok()?;
172500 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
@@ -177,7 +505,7 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
177505 Ok(0) => break,
178506 Ok(n) => {
179507 len += n;
180− if buf[..len].iter().any(|b| *b == b'\n') || len == buf.len() {
508+ if buf[..len].contains(&b'\n') || len == buf.len() {
181509 break;
182510 }
183511 }
@@ -199,31 +527,34 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
199527 }
200528}
201529
530+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
202531fn spawn_manager_io(
203532 config: Arc<Mutex<InstanceConfig>>,
204− instance_dir: PathBuf,
533+ project_dir: PathBuf,
534+ clients: ClientStreams,
535+ latest_status: SharedStatus,
205536 output_monitor: Arc<vm::OutputMonitor>,
206537 vm_output_fd: std::os::unix::io::OwnedFd,
207538 vm_input_fd: std::os::unix::io::OwnedFd,
208539) -> vm::IoContext {
209− let log_path = instance_dir.join("serial.log");
540+ let log_path = project_dir.join(INSTANCE_DIR_NAME).join(VM_ROOT_LOG_NAME);
210541 let log_file = fs::OpenOptions::new()
211542 .create(true)
212− .append(true)
543+ .write(true)
544+ .truncate(true)
213545 .open(&log_path)
214546 .ok()
215547 .map(|file| Arc::new(Mutex::new(file)));
216548
217− let instance_path = instance_dir.join("instance.toml");
218549 let config_for_output = config.clone();
219550 let log_for_output = log_file.clone();
220551 let mut line_buf = String::new();
221552
222553 let on_output = move |bytes: &[u8]| {
223− if let Some(log) = &log_for_output {
224− if let Ok(mut file) = log.lock() {
225− let _ = file.write_all(bytes);
226− }
554+ if let Some(log) = &log_for_output
555+ && let Ok(mut file) = log.lock()
556+ {
557+ let _ = file.write_all(bytes);
227558 }
228559
229560 let text = String::from_utf8_lossy(bytes);
@@ -236,17 +567,27 @@ fn spawn_manager_io(
236567 }
237568 line_buf.drain(..=pos);
238569
239− 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");
575+ broadcast_status(
576+ &clients,
577+ &latest_status,
578+ &format!("{STATUS_VM_ERROR_PREFIX} {failure}"),
579+ );
580+ }
581+ }
240582 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
241583 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
242584 let ip = extract_ipv4(ip_raw).unwrap_or_default();
243− if !ip.is_empty() {
244− if let Ok(mut cfg) = config_for_output.lock() {
245− if cfg.vm_ipv4.as_deref() != Some(ip.as_str()) {
246− cfg.vm_ipv4 = Some(ip.clone());
247− let _ = write_instance_config(&instance_path, &cfg);
248− }
249− }
585+ if !ip.is_empty()
586+ && let Ok(mut cfg) = config_for_output.lock()
587+ && cfg.vm_ipv4.as_deref() != Some(ip.as_str())
588+ {
589+ cfg.vm_ipv4 = Some(ip.clone());
590+ let _ = write_instance_config(&project_dir, &cfg);
250591 }
251592 }
252593 }
@@ -268,80 +609,52 @@ enum ManagerEvent {
268609 VmExited(Option<String>),
269610}
270611
271−#[cfg(test)]
272−mod tests {
273− use super::*;
274− use std::{sync::mpsc, time::Duration};
275−
276− #[test]
277− fn manager_powers_off_after_grace_when_no_refs() {
278− let _temp = tempfile::Builder::new()
279− .prefix("vb")
280− .tempdir_in("/tmp")
281− .expect("tempdir");
282−
283− let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
284− let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
285− let vm_input_tx = Arc::new(Mutex::new(Some(vm_tx)));
286−
287− let manager_thread = thread::spawn(move || {
288− manager_event_loop(event_rx, vm_input_tx, 50).expect("event loop");
289− });
290−
291− event_tx.send(ManagerEvent::Inc(None)).unwrap();
292− assert!(vm_rx.recv_timeout(Duration::from_millis(100)).is_err());
293−
294− event_tx.send(ManagerEvent::Dec(None)).unwrap();
295− let msg = vm_rx
296− .recv_timeout(Duration::from_secs(2))
297− .expect("poweroff");
298− match msg {
299− VmInput::Bytes(data) => {
300− assert_eq!(data, b"systemctl poweroff\n");
301− }
302− _ => panic!("unexpected vm input"),
303− }
304− let _ = event_tx.send(ManagerEvent::VmExited(None));
305− let _ = manager_thread.join();
306− }
307−}
308−
309612struct ManagerOptions {
310− ensure_signed: bool,
311613 detach: bool,
312614 prepare_vm: bool,
313615}
314616
315617trait VmExecutor {
618+ #[allow(clippy::too_many_arguments)]
316619 fn run_vm(
317620 &self,
318− args: vm::CliArgs,
621+ args: vm::VmArg,
319622 extra_login_actions: Vec<LoginAction>,
320623 extra_shares: Vec<DirectoryShare>,
321624 config: Arc<Mutex<InstanceConfig>>,
322625 instance_dir: PathBuf,
626+ clients: ClientStreams,
627+ latest_status: SharedStatus,
323628 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
324− ) -> Result<(), Box<dyn std::error::Error>>;
629+ ) -> Result<()>;
325630}
326631
632+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
327633struct RealVmExecutor;
328634
329635impl VmExecutor for RealVmExecutor {
330636 fn run_vm(
331637 &self,
332− args: vm::CliArgs,
638+ args: vm::VmArg,
333639 extra_login_actions: Vec<LoginAction>,
334640 extra_shares: Vec<DirectoryShare>,
335641 config: Arc<Mutex<InstanceConfig>>,
336− instance_dir: PathBuf,
642+ project_dir: PathBuf,
643+ clients: ClientStreams,
644+ latest_status: SharedStatus,
337645 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
338− ) -> Result<(), Box<dyn std::error::Error>> {
339− let result = vm::run_with_args_and_extras(
646+ ) -> Result<()> {
647+ let status_callback = |status: &str| {
648+ broadcast_status(&clients, &latest_status, status);
649+ };
650+ vm::run_with_args_and_extras(
340651 args,
341652 |output_monitor, vm_output_fd, vm_input_fd| {
342653 let io_ctx = spawn_manager_io(
343654 config.clone(),
344− instance_dir.clone(),
655+ project_dir.clone(),
656+ clients.clone(),
657+ latest_status.clone(),
345658 output_monitor,
346659 vm_output_fd,
347660 vm_input_fd,
@@ -351,35 +664,60 @@ impl VmExecutor for RealVmExecutor {
351664 },
352665 extra_login_actions,
353666 extra_shares,
354− );
355− result
667+ Some(&status_callback),
668+ )
669+ }
670+}
671+
672+#[cfg(feature = "mock-vm")]
673+struct MockVmExecutor;
674+
675+#[cfg(feature = "mock-vm")]
676+impl VmExecutor for MockVmExecutor {
677+ fn run_vm(
678+ &self,
679+ _args: vm::VmArg,
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>>>>,
687+ ) -> Result<()> {
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() {
692+ match input {
693+ VmInput::Shutdown => break,
694+ VmInput::Bytes(bytes) => {
695+ let text = String::from_utf8_lossy(&bytes);
696+ if text.contains("systemctl poweroff") {
697+ break;
698+ }
699+ }
700+ }
701+ }
702+ tracing::info!("mock vm executor exiting");
703+ Ok(())
356704 }
357705}
358706
359707fn run_manager_with(
360708 project_root: &Path,
361− args: vm::CliArgs,
709+ mut args: vm::VmArg,
362710 auto_shutdown_ms: u64,
363711 executor: &dyn VmExecutor,
364712 options: ManagerOptions,
365−) -> Result<(), Box<dyn std::error::Error>> {
366− if options.ensure_signed {
367− let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok();
368− unsafe {
369− env::remove_var("VIBEBOX_SKIP_CODESIGN");
370− }
371− vm::ensure_signed();
372− unsafe {
373− env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
374− }
375− }
713+) -> Result<()> {
376714 if options.detach {
377715 detach_from_terminal();
378716 }
379717
380718 let project_name = project_root
381719 .file_name()
382− .ok_or("Project directory has no name")?
720+ .ok_or_else(|| anyhow!("Project directory has no name"))?
383721 .to_string_lossy()
384722 .into_owned();
385723 let instance_dir = ensure_instance_dir(project_root)?;
@@ -387,21 +725,35 @@ fn run_manager_with(
387725 let _ = ensure_ssh_keypair(&instance_dir)?;
388726 }
389727
390− let mut config = load_or_create_instance_config(&instance_dir)?;
728+ let mut config = load_or_create_instance_config(project_root)?;
391729 if config.vm_ipv4.is_some() {
392730 config.vm_ipv4 = None;
393− write_instance_config(&instance_dir.join("instance.toml"), &config)?;
731+ write_instance_config(project_root, &config)?;
394732 }
395733 let config = Arc::new(Mutex::new(config));
734+ let ssh_user = config
735+ .lock()
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);
740+ }
741+ let (args, home_links_script) = prepare_mounts_and_links(args, &ssh_user);
396742
743+ let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
397744 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
398− let extra_shares = vec![DirectoryShare::new(
399− instance_dir.clone(),
400− ssh_guest_dir.clone().into(),
401− true,
402− )?];
403− let extra_login_actions =
404− build_ssh_login_actions(&config, &project_name, ssh_guest_dir.as_str(), "ssh_key");
745+ let extra_shares = vec![
746+ DirectoryShare::new(instance_dir.clone(), ssh_guest_dir.clone().into(), true)
747+ .map_err(|err| anyhow!(err.to_string()))?,
748+ ];
749+ let extra_login_actions = build_ssh_login_actions(
750+ &config,
751+ &project_name,
752+ &project_guest_dir,
753+ ssh_guest_dir.as_str(),
754+ "ssh_key",
755+ &home_links_script,
756+ );
405757
406758 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
407759 if let Ok(stream) = UnixStream::connect(&socket_path) {
@@ -416,17 +768,43 @@ fn run_manager_with(
416768 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
417769 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
418770
771+ let clients: ClientStreams = Arc::new(Mutex::new(Vec::new()));
772+ let latest_status: SharedStatus = Arc::new(Mutex::new(String::new()));
419773 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
420774 let event_tx_accept = event_tx.clone();
775+ let clients_accept = clients.clone();
776+ let latest_status_accept = latest_status.clone();
421777 thread::spawn(move || {
422778 for stream in listener.incoming() {
423779 match stream {
424780 Ok(stream) => {
781+ let mut client_fd: Option<std::os::fd::RawFd> = None;
782+ let latest_status_snapshot = latest_status_accept
783+ .lock()
784+ .ok()
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()
794+ {
795+ let _ = send_status_line(last, status);
796+ }
797+ }
798+ }
425799 let event_tx_conn = event_tx_accept.clone();
800+ let clients_conn = clients_accept.clone();
426801 thread::spawn(move || {
427802 let pid = read_client_pid(&stream);
428803 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
429804 wait_for_disconnect(stream);
805+ if let Some(fd) = client_fd {
806+ remove_client(&clients_conn, fd);
807+ }
430808 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
431809 });
432810 }
@@ -446,43 +824,59 @@ fn run_manager_with(
446824 extra_login_actions,
447825 extra_shares,
448826 config.clone(),
449− instance_dir.clone(),
827+ project_root.to_path_buf(),
828+ clients.clone(),
829+ latest_status.clone(),
450830 vm_input_tx.clone(),
451831 );
452832 tracing::info!("vm manager vm run completed");
453833 let vm_err = vm_result.err().map(|e| e.to_string());
834+ if let Some(err) = &vm_err {
835+ broadcast_status(
836+ &clients,
837+ &latest_status,
838+ &format!("{STATUS_VM_ERROR_PREFIX} {err}"),
839+ );
840+ }
454841 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
455− let event_loop_result: Result<(), String> = event_loop_handle
842+ let event_loop_result = event_loop_handle
456843 .join()
457− .unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
844+ .unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked")))
458845 .map_err(|err| err.to_string());
459846 let _ = fs::remove_file(&socket_path);
460847 if let Err(err) = &event_loop_result {
461848 tracing::error!(error = %err, "vm manager exiting due to event loop error");
462− return Err(err.to_string().into());
849+ bail!(err.to_string());
463850 }
464851 if let Some(err) = vm_err {
465852 tracing::error!(error = %err, "vm manager exiting due to vm error");
466− return Err(err.into());
853+ bail!(err);
467854 }
468855 tracing::info!("vm manager exiting");
469− Ok(event_loop_result?)
856+ event_loop_result.map_err(Error::msg)
470857}
471858
472859fn manager_event_loop(
473860 event_rx: mpsc::Receiver<ManagerEvent>,
474861 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
475862 auto_shutdown_ms: u64,
476−) -> Result<(), String> {
863+) -> Result<()> {
477864 let mut ref_count: usize = 0;
478865 let mut shutdown_deadline: Option<Instant> = None;
479866 let mut shutdown_sent = false;
867+ let mut hard_deadline: Option<Instant> = None;
480868 let grace = Duration::from_millis(auto_shutdown_ms.max(1));
869+ let hard_timeout = Duration::from_millis(HARD_SHUTDOWN_TIMEOUT_MS);
481870
482871 loop {
483− let timeout = match shutdown_deadline {
484− Some(deadline) => deadline.saturating_duration_since(Instant::now()),
485− None => Duration::from_secs(1),
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())
876+ }
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),
486880 };
487881
488882 match event_rx.recv_timeout(timeout) {
@@ -496,11 +890,10 @@ fn manager_event_loop(
496890 );
497891 shutdown_deadline = None;
498892 shutdown_sent = false;
893+ hard_deadline = None;
499894 }
500895 Ok(ManagerEvent::Dec(pid)) => {
501− if ref_count > 0 {
502− ref_count -= 1;
503− }
896+ ref_count = ref_count.saturating_sub(1);
504897 tracing::info!(
505898 ref_count,
506899 pid = pid.unwrap_or(0),
@@ -514,21 +907,58 @@ fn manager_event_loop(
514907 }
515908 Ok(ManagerEvent::VmExited(err)) => {
516909 if let Some(err) = err {
517− tracing::error!(error = %err, "vm exited with error");
910+ tracing::error!(error = %err, "vm exited with an error");
518911 }
519912 break;
520913 }
521914 Err(mpsc::RecvTimeoutError::Timeout) => {
522− if let Some(deadline) = shutdown_deadline {
523− if Instant::now() >= deadline && !shutdown_sent {
524− if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
525− let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
915+ if let Some(deadline) = shutdown_deadline
916+ && Instant::now() >= deadline
917+ && !shutdown_sent
918+ {
919+ if hard_deadline.is_none() {
920+ hard_deadline = Some(Instant::now() + hard_timeout);
921+ }
922+ let mut sent = false;
923+ if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
924+ if tx
925+ .send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()))
926+ .is_ok()
927+ {
928+ sent = true;
929+ } else {
930+ tracing::warn!("shutdown command failed to send");
526931 }
932+ } else {
933+ tracing::warn!("shutdown command deferred; vm input not ready");
934+ }
935+ if sent {
527936 tracing::info!("shutdown command sent");
528937 shutdown_sent = true;
529938 shutdown_deadline = None;
939+ hard_deadline = Some(Instant::now() + hard_timeout);
940+ } else {
941+ shutdown_deadline =
942+ Some(Instant::now() + Duration::from_millis(SHUTDOWN_RETRY_MS));
530943 }
531944 }
945+ if ref_count == 0
946+ && let Some(deadline) = hard_deadline
947+ && Instant::now() >= deadline
948+ {
949+ if shutdown_sent {
950+ tracing::warn!(
951+ timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
952+ "force exiting: VM did not stop after shutdown timeout"
953+ );
954+ } else {
955+ tracing::warn!(
956+ timeout_ms = HARD_SHUTDOWN_TIMEOUT_MS,
957+ "force exiting: VM input not ready after shutdown timeout"
958+ );
959+ }
960+ force_exit("vm manager forced exit");
961+ }
532962 }
533963 Err(mpsc::RecvTimeoutError::Disconnected) => break,
534964 }
@@ -536,3 +966,112 @@ fn manager_event_loop(
536966
537967 Ok(())
538968}
969+
970+#[cfg(test)]
971+mod tests {
972+ use super::*;
973+ use std::{fs, sync::mpsc, thread, time::Duration};
974+
975+ #[test]
976+ fn manager_powers_off_after_grace_when_no_refs() {
977+ let _temp = tempfile::Builder::new()
978+ .prefix("vb")
979+ .tempdir_in("/tmp")
980+ .expect("tempdir");
981+
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)));
985+
986+ let manager_thread = thread::spawn(move || {
987+ manager_event_loop(event_rx, vm_input_tx, 50).expect("event loop");
988+ });
989+
990+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
991+ assert!(vm_rx.recv_timeout(Duration::from_millis(100)).is_err());
992+
993+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
994+ let msg = vm_rx
995+ .recv_timeout(Duration::from_secs(2))
996+ .expect("poweroff");
997+ match msg {
998+ VmInput::Bytes(data) => {
999+ assert_eq!(data, b"systemctl poweroff\n");
1000+ }
1001+ _ => panic!("unexpected vm input"),
1002+ }
1003+ let _ = event_tx.send(ManagerEvent::VmExited(None));
1004+ let _ = manager_thread.join();
1005+ }
1006+
1007+ #[test]
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));
1011+
1012+ let manager_thread = thread::spawn(move || {
1013+ let _ = manager_event_loop(event_rx, vm_input_tx, 10);
1014+ });
1015+
1016+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
1017+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
1018+
1019+ let join_result = manager_thread.join();
1020+ assert!(
1021+ join_result.is_err(),
1022+ "expected manager to force-exit when vm input never becomes ready"
1023+ );
1024+ }
1025+
1026+ #[test]
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();
1032+
1033+ let manager_thread = thread::spawn(move || {
1034+ manager_event_loop(event_rx, vm_input_for_thread, 10).expect("event loop");
1035+ });
1036+
1037+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
1038+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
1039+
1040+ thread::sleep(Duration::from_millis(100));
1041+ *vm_input_tx.lock().unwrap() = Some(vm_tx);
1042+
1043+ let msg = vm_rx
1044+ .recv_timeout(Duration::from_secs(2))
1045+ .expect("poweroff");
1046+ match msg {
1047+ VmInput::Bytes(data) => {
1048+ assert_eq!(data, b"systemctl poweroff\n");
1049+ }
1050+ _ => panic!("unexpected vm input"),
1051+ }
1052+ let _ = event_tx.send(ManagerEvent::VmExited(None));
1053+ let _ = manager_thread.join();
1054+ }
1055+
1056+ #[test]
1057+ fn lock_is_not_stale_when_owner_pid_is_alive() {
1058+ let temp = tempfile::Builder::new()
1059+ .prefix("vb")
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));
1065+ }
1066+
1067+ #[test]
1068+ fn lock_is_stale_when_owner_pid_is_missing() {
1069+ let temp = tempfile::Builder::new()
1070+ .prefix("vb")
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));
1076+ }
1077+}