@@ -1,22 +1,12 @@
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};
131use std::{
142 env, fs,
153 io::{Read, Write},
164 os::unix::{
5+ fs::FileTypeExt,
176 fs::PermissionsExt,
187 io::AsRawFd,
198 net::{UnixListener, UnixStream},
9+ process::CommandExt,
2010 },
2111 path::{Path, PathBuf},
2212 process::{Command, Stdio},
@@ -25,44 +15,36 @@ use std::{
2515 time::{Duration, Instant},
2616};
2717
28−#[cfg_attr(feature = "mock-vm", allow(dead_code))]
29−const VM_ROOT_LOG_NAME: &str = "vm_root.log";
18+use crate::{
19+ config::CONFIG_PATH_ENV,
20+ instance::VM_ROOT_LOG_NAME,
21+ instance::{
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,
24+ },
25+ session_manager::{
26+ GLOBAL_DIR_NAME, INSTANCE_FILENAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME,
27+ },
28+ vm::{self, DirectoryShare, LoginAction, VmInput},
29+};
30+
3031const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
3132const 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:";
38−
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−}
5133
5234pub fn ensure_manager(
5335 raw_args: &[std::ffi::OsString],
5436 auto_shutdown_ms: u64,
5537 config_path: Option<&Path>,
56−) -> Result<UnixStream> {
38+) -> Result<UnixStream, Box<dyn std::error::Error>> {
5739 let project_root = env::current_dir()?;
5840 tracing::debug!(root = %project_root.display(), "ensure vm manager");
5941 let instance_dir = ensure_instance_dir(&project_root)?;
60− cleanup_stale_manager(&project_root)?;
42+ cleanup_stale_manager(&instance_dir);
6143 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
6244
6345 if let Ok(stream) = UnixStream::connect(&socket_path) {
6446 send_client_pid(&stream);
65− tracing::info!(path = %socket_path.display(), "connected to an existing vm manager");
47+ tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
6648 return Ok(stream);
6749 }
6850
@@ -100,11 +82,12 @@ pub fn ensure_manager(
10082 drop(lock_file.take());
10183 let _ = fs::remove_file(&lock_path);
10284 }
103− bail!(format!(
104− "timed out waiting for vm manager socket: {} ({})",
85+ return Err(format!(
86+ "Timed out waiting for vm manager socket: {} ({})",
10587 socket_path.display(),
10688 err
107− ));
89+ )
90+ .into());
10891 }
10992 thread::sleep(Duration::from_millis(100));
11093 }
@@ -112,49 +95,24 @@ pub fn ensure_manager(
11295 }
11396}
11497
115−pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> {
98+pub fn run_manager(
99+ args: vm::VmArg,
100+ auto_shutdown_ms: u64,
101+) -> Result<(), Box<dyn std::error::Error>> {
116102 let project_root = env::current_dir()?;
117103 tracing::info!(root = %project_root.display(), "vm manager starting");
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− }
128104 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)
105+ run_manager_with(
106+ &project_root,
107+ args,
108+ auto_shutdown_ms,
109+ &RealVmExecutor,
110+ ManagerOptions {
111+ ensure_signed: true,
112+ detach: true,
113+ prepare_vm: true,
114+ },
115+ )
158116}
159117
160118fn spawn_manager_process(
@@ -162,28 +120,30 @@ fn spawn_manager_process(
162120 auto_shutdown_ms: u64,
163121 instance_dir: &Path,
164122 config_path: Option<&Path>,
165−) -> Result<()> {
123+) -> Result<(), Box<dyn std::error::Error>> {
166124 let exe = env::current_exe()?;
167125 let mut supervisor_exe = exe.clone();
168126 supervisor_exe.set_file_name("vibebox-supervisor");
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);
127+ let use_supervisor = supervisor_exe.exists();
128+ let mut cmd = if use_supervisor {
129+ Command::new(supervisor_exe)
130+ } else {
131+ let mut cmd = Command::new(exe);
132+ cmd.arg0("vibebox-supervisor");
133+ cmd
134+ };
177135 if raw_args.len() > 1 {
178136 cmd.args(&raw_args[1..]);
179137 }
180− cmd.env("VIBEBOX_INTERNAL", "1");
138+ if !use_supervisor {
139+ cmd.env("VIBEBOX_VM_MANAGER", "1");
140+ }
181141 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
182142 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
183143 if let Some(path) = config_path {
184144 cmd.env(CONFIG_PATH_ENV, path);
185145 }
186− tracing::debug!(auto_shutdown_ms, "vm manager process spawn requested");
146+ tracing::info!(auto_shutdown_ms, "vm manager process spawn requested");
187147 let log_path = instance_dir.join(VM_MANAGER_LOG_NAME);
188148 let log_file = fs::OpenOptions::new()
189149 .create(true)
@@ -205,65 +165,46 @@ fn spawn_manager_process(
205165 Ok(())
206166}
207167
208−fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard> {
168+fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard, Box<dyn std::error::Error>> {
209169 let instance_dir = ensure_instance_dir(project_root)?;
210170 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
211171 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− );
172+ if let Ok(content) = fs::read_to_string(&pid_path) {
173+ if let Ok(pid) = content.trim().parse::<u32>() {
174+ if pid_is_alive(pid) {
175+ if is_socket_path(&socket_path) {
176+ return Err(format!("vm manager already running (pid {pid})").into());
177+ }
178+ tracing::warn!(
179+ pid,
180+ path = %socket_path.display(),
181+ "stale pid file detected with missing socket"
182+ );
183+ }
222184 }
223− VmLiveness::NotRunningOrMissing => {}
185+ let _ = fs::remove_file(&pid_path);
224186 }
225− let _ = fs::remove_file(&pid_path);
226187 fs::write(&pid_path, format!("{}\n", std::process::id()))?;
227188 let _ = fs::set_permissions(&pid_path, fs::Permissions::from_mode(0o600));
228189 Ok(PidFileGuard { path: pid_path })
229190}
230191
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(());
192+fn cleanup_stale_manager(instance_dir: &Path) {
193+ let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
194+ if let Ok(content) = fs::read_to_string(&pid_path) {
195+ if let Ok(pid) = content.trim().parse::<u32>() {
196+ if pid_is_alive(pid) {
197+ return;
198+ }
199+ }
240200 }
241201 let _ = fs::remove_file(&pid_path);
242− Ok(())
243202}
244203
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"));
204+fn is_socket_path(path: &Path) -> bool {
205+ fs::metadata(path)
206+ .map(|meta| meta.file_type().is_socket())
207+ .unwrap_or(false)
267208}
268209
269210fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
@@ -312,7 +253,7 @@ fn rewrite_mount_spec(spec: &str, ssh_user: &str) -> (String, Option<HomeLink>)
312253 return (spec.to_string(), None);
313254 }
314255
315− let root_base = PROJECT_GUEST_BASE;
256+ let root_base = "/usr/local/vibebox-mounts";
316257 let root_path = if rel.is_empty() {
317258 root_base.to_string()
318259 } else {
@@ -409,30 +350,6 @@ fn wait_for_disconnect(mut stream: UnixStream) {
409350 }
410351}
411352
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−
436353fn send_client_pid(stream: &UnixStream) {
437354 let pid = std::process::id();
438355 let payload = format!("pid={pid}\n");
@@ -442,7 +359,7 @@ fn send_client_pid(stream: &UnixStream) {
442359 }
443360}
444361
445−fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>> {
362+fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>, Box<dyn std::error::Error>> {
446363 match fs::OpenOptions::new()
447364 .write(true)
448365 .create_new(true)
@@ -476,12 +393,6 @@ fn is_lock_stale(lock_path: &Path) -> bool {
476393 }
477394}
478395
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−
485396fn pid_is_alive(pid: u32) -> bool {
486397 let pid = pid as libc::pid_t;
487398 let result = unsafe { libc::kill(pid, 0) };
@@ -495,6 +406,12 @@ fn pid_is_alive(pid: u32) -> bool {
495406 }
496407}
497408
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()
413+}
414+
498415fn read_client_pid(stream: &UnixStream) -> Option<u32> {
499416 let mut stream = stream.try_clone().ok()?;
500417 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
@@ -505,7 +422,7 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
505422 Ok(0) => break,
506423 Ok(n) => {
507424 len += n;
508− if buf[..len].contains(&b'\n') || len == buf.len() {
425+ if buf[..len].iter().any(|b| *b == b'\n') || len == buf.len() {
509426 break;
510427 }
511428 }
@@ -527,17 +444,14 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
527444 }
528445}
529446
530−#[cfg_attr(feature = "mock-vm", allow(dead_code))]
531447fn spawn_manager_io(
532448 config: Arc<Mutex<InstanceConfig>>,
533− project_dir: PathBuf,
534− clients: ClientStreams,
535− latest_status: SharedStatus,
449+ instance_dir: PathBuf,
536450 output_monitor: Arc<vm::OutputMonitor>,
537451 vm_output_fd: std::os::unix::io::OwnedFd,
538452 vm_input_fd: std::os::unix::io::OwnedFd,
539453) -> vm::IoContext {
540− let log_path = project_dir.join(INSTANCE_DIR_NAME).join(VM_ROOT_LOG_NAME);
454+ let log_path = instance_dir.join(VM_ROOT_LOG_NAME);
541455 let log_file = fs::OpenOptions::new()
542456 .create(true)
543457 .write(true)
@@ -546,15 +460,16 @@ fn spawn_manager_io(
546460 .ok()
547461 .map(|file| Arc::new(Mutex::new(file)));
548462
463+ let instance_path = instance_dir.join(INSTANCE_FILENAME);
549464 let config_for_output = config.clone();
550465 let log_for_output = log_file.clone();
551466 let mut line_buf = String::new();
552467
553468 let on_output = move |bytes: &[u8]| {
554− if let Some(log) = &log_for_output
555− && let Ok(mut file) = log.lock()
556− {
557− let _ = file.write_all(bytes);
469+ if let Some(log) = &log_for_output {
470+ if let Ok(mut file) = log.lock() {
471+ let _ = file.write_all(bytes);
472+ }
558473 }
559474
560475 let text = String::from_utf8_lossy(bytes);
@@ -567,27 +482,17 @@ fn spawn_manager_io(
567482 }
568483 line_buf.drain(..=pos);
569484
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− }
485+ let cleaned = line.trim_start_matches(|c: char| c == '\r' || c == ' ');
582486 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
583487 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
584488 let ip = extract_ipv4(ip_raw).unwrap_or_default();
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);
489+ if !ip.is_empty() {
490+ if let Ok(mut cfg) = config_for_output.lock() {
491+ if cfg.vm_ipv4.as_deref() != Some(ip.as_str()) {
492+ cfg.vm_ipv4 = Some(ip.clone());
493+ let _ = write_instance_config(&instance_path, &cfg);
494+ }
495+ }
591496 }
592497 }
593498 }
@@ -609,13 +514,51 @@ enum ManagerEvent {
609514 VmExited(Option<String>),
610515}
611516
517+#[cfg(test)]
518+mod tests {
519+ use super::*;
520+ use std::{sync::mpsc, time::Duration};
521+
522+ #[test]
523+ fn manager_powers_off_after_grace_when_no_refs() {
524+ let _temp = tempfile::Builder::new()
525+ .prefix("vb")
526+ .tempdir_in("/tmp")
527+ .expect("tempdir");
528+
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)));
532+
533+ let manager_thread = thread::spawn(move || {
534+ manager_event_loop(event_rx, vm_input_tx, 50).expect("event loop");
535+ });
536+
537+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
538+ assert!(vm_rx.recv_timeout(Duration::from_millis(100)).is_err());
539+
540+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
541+ let msg = vm_rx
542+ .recv_timeout(Duration::from_secs(2))
543+ .expect("poweroff");
544+ match msg {
545+ VmInput::Bytes(data) => {
546+ assert_eq!(data, b"systemctl poweroff\n");
547+ }
548+ _ => panic!("unexpected vm input"),
549+ }
550+ let _ = event_tx.send(ManagerEvent::VmExited(None));
551+ let _ = manager_thread.join();
552+ }
553+}
554+
612555struct ManagerOptions {
556+ ensure_signed: bool,
613557 detach: bool,
614558 prepare_vm: bool,
615559}
616560
617561trait VmExecutor {
618− #[allow(clippy::too_many_arguments)]
619562 fn run_vm(
620563 &self,
621564 args: vm::VmArg,
@@ -623,13 +566,10 @@ trait VmExecutor {
623566 extra_shares: Vec<DirectoryShare>,
624567 config: Arc<Mutex<InstanceConfig>>,
625568 instance_dir: PathBuf,
626− clients: ClientStreams,
627− latest_status: SharedStatus,
628569 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
629− ) -> Result<()>;
570+ ) -> Result<(), Box<dyn std::error::Error>>;
630571}
631572
632−#[cfg_attr(feature = "mock-vm", allow(dead_code))]
633573struct RealVmExecutor;
634574
635575impl VmExecutor for RealVmExecutor {
@@ -639,22 +579,15 @@ impl VmExecutor for RealVmExecutor {
639579 extra_login_actions: Vec<LoginAction>,
640580 extra_shares: Vec<DirectoryShare>,
641581 config: Arc<Mutex<InstanceConfig>>,
642− project_dir: PathBuf,
643− clients: ClientStreams,
644− latest_status: SharedStatus,
582+ instance_dir: PathBuf,
645583 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
646− ) -> Result<()> {
647− let status_callback = |status: &str| {
648− broadcast_status(&clients, &latest_status, status);
649− };
650− vm::run_with_args_and_extras(
584+ ) -> Result<(), Box<dyn std::error::Error>> {
585+ let result = vm::run_with_args_and_extras(
651586 args,
652587 |output_monitor, vm_output_fd, vm_input_fd| {
653588 let io_ctx = spawn_manager_io(
654589 config.clone(),
655− project_dir.clone(),
656− clients.clone(),
657− latest_status.clone(),
590+ instance_dir.clone(),
658591 output_monitor,
659592 vm_output_fd,
660593 vm_input_fd,
@@ -664,60 +597,35 @@ impl VmExecutor for RealVmExecutor {
664597 },
665598 extra_login_actions,
666599 extra_shares,
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(())
600+ );
601+ result
704602 }
705603}
706604
707605fn run_manager_with(
708606 project_root: &Path,
709− mut args: vm::VmArg,
607+ args: vm::VmArg,
710608 auto_shutdown_ms: u64,
711609 executor: &dyn VmExecutor,
712610 options: ManagerOptions,
713−) -> Result<()> {
611+) -> Result<(), Box<dyn std::error::Error>> {
612+ if options.ensure_signed {
613+ let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok();
614+ unsafe {
615+ env::remove_var("VIBEBOX_SKIP_CODESIGN");
616+ }
617+ vm::ensure_signed();
618+ unsafe {
619+ env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
620+ }
621+ }
714622 if options.detach {
715623 detach_from_terminal();
716624 }
717625
718626 let project_name = project_root
719627 .file_name()
720− .ok_or_else(|| anyhow!("Project directory has no name"))?
628+ .ok_or("Project directory has no name")?
721629 .to_string_lossy()
722630 .into_owned();
723631 let instance_dir = ensure_instance_dir(project_root)?;
@@ -725,31 +633,27 @@ fn run_manager_with(
725633 let _ = ensure_ssh_keypair(&instance_dir)?;
726634 }
727635
728− let mut config = load_or_create_instance_config(project_root)?;
636+ let mut config = load_or_create_instance_config(&instance_dir)?;
729637 if config.vm_ipv4.is_some() {
730638 config.vm_ipv4 = None;
731− write_instance_config(project_root, &config)?;
639+ write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
732640 }
733641 let config = Arc::new(Mutex::new(config));
734642 let ssh_user = config
735643 .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− }
644+ .map(|cfg| cfg.ssh_user_display())
645+ .unwrap_or_else(|_| DEFAULT_SSH_USER.to_string());
741646 let (args, home_links_script) = prepare_mounts_and_links(args, &ssh_user);
742647
743− let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
744648 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
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− ];
649+ let extra_shares = vec![DirectoryShare::new(
650+ instance_dir.clone(),
651+ ssh_guest_dir.clone().into(),
652+ true,
653+ )?];
749654 let extra_login_actions = build_ssh_login_actions(
750655 &config,
751656 &project_name,
752− &project_guest_dir,
753657 ssh_guest_dir.as_str(),
754658 "ssh_key",
755659 &home_links_script,
@@ -768,43 +672,17 @@ fn run_manager_with(
768672 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
769673 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
770674
771− let clients: ClientStreams = Arc::new(Mutex::new(Vec::new()));
772− let latest_status: SharedStatus = Arc::new(Mutex::new(String::new()));
773675 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
774676 let event_tx_accept = event_tx.clone();
775− let clients_accept = clients.clone();
776− let latest_status_accept = latest_status.clone();
777677 thread::spawn(move || {
778678 for stream in listener.incoming() {
779679 match stream {
780680 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− }
799681 let event_tx_conn = event_tx_accept.clone();
800− let clients_conn = clients_accept.clone();
801682 thread::spawn(move || {
802683 let pid = read_client_pid(&stream);
803684 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
804685 wait_for_disconnect(stream);
805− if let Some(fd) = client_fd {
806− remove_client(&clients_conn, fd);
807− }
808686 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
809687 });
810688 }
@@ -824,59 +702,43 @@ fn run_manager_with(
824702 extra_login_actions,
825703 extra_shares,
826704 config.clone(),
827− project_root.to_path_buf(),
828− clients.clone(),
829− latest_status.clone(),
705+ instance_dir.clone(),
830706 vm_input_tx.clone(),
831707 );
832708 tracing::info!("vm manager vm run completed");
833709 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− }
841710 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
842− let event_loop_result = event_loop_handle
711+ let event_loop_result: Result<(), String> = event_loop_handle
843712 .join()
844− .unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked")))
713+ .unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
845714 .map_err(|err| err.to_string());
846715 let _ = fs::remove_file(&socket_path);
847716 if let Err(err) = &event_loop_result {
848717 tracing::error!(error = %err, "vm manager exiting due to event loop error");
849− bail!(err.to_string());
718+ return Err(err.to_string().into());
850719 }
851720 if let Some(err) = vm_err {
852721 tracing::error!(error = %err, "vm manager exiting due to vm error");
853− bail!(err);
722+ return Err(err.into());
854723 }
855724 tracing::info!("vm manager exiting");
856− event_loop_result.map_err(Error::msg)
725+ Ok(event_loop_result?)
857726}
858727
859728fn manager_event_loop(
860729 event_rx: mpsc::Receiver<ManagerEvent>,
861730 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
862731 auto_shutdown_ms: u64,
863−) -> Result<()> {
732+) -> Result<(), String> {
864733 let mut ref_count: usize = 0;
865734 let mut shutdown_deadline: Option<Instant> = None;
866735 let mut shutdown_sent = false;
867− let mut hard_deadline: Option<Instant> = None;
868736 let grace = Duration::from_millis(auto_shutdown_ms.max(1));
869− let hard_timeout = Duration::from_millis(HARD_SHUTDOWN_TIMEOUT_MS);
870737
871738 loop {
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),
739+ let timeout = match shutdown_deadline {
740+ Some(deadline) => deadline.saturating_duration_since(Instant::now()),
741+ None => Duration::from_secs(1),
880742 };
881743
882744 match event_rx.recv_timeout(timeout) {
@@ -890,10 +752,11 @@ fn manager_event_loop(
890752 );
891753 shutdown_deadline = None;
892754 shutdown_sent = false;
893− hard_deadline = None;
894755 }
895756 Ok(ManagerEvent::Dec(pid)) => {
896− ref_count = ref_count.saturating_sub(1);
757+ if ref_count > 0 {
758+ ref_count -= 1;
759+ }
897760 tracing::info!(
898761 ref_count,
899762 pid = pid.unwrap_or(0),
@@ -912,52 +775,15 @@ fn manager_event_loop(
912775 break;
913776 }
914777 Err(mpsc::RecvTimeoutError::Timeout) => {
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");
778+ if let Some(deadline) = shutdown_deadline {
779+ if Instant::now() >= deadline && !shutdown_sent {
780+ if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
781+ let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
931782 }
932− } else {
933− tracing::warn!("shutdown command deferred; vm input not ready");
934− }
935− if sent {
936783 tracing::info!("shutdown command sent");
937784 shutdown_sent = true;
938785 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));
943− }
944− }
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− );
959786 }
960− force_exit("vm manager forced exit");
961787 }
962788 }
963789 Err(mpsc::RecvTimeoutError::Disconnected) => break,
@@ -966,112 +792,3 @@ fn manager_event_loop(
966792
967793 Ok(())
968794}
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−}