on main help sign in
feat: allow multi vibebox to connect to the same vm.
ca33633robcholz
Cargo.lock+76
@@ -17,6 +17,15 @@ version = "2.0.1"
17source = "registry+https://github.com/rust-lang/crates.io-index"
17source = "registry+https://github.com/rust-lang/crates.io-index"
18checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
18checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
19
19
20+[[package]]
21+name = "aho-corasick"
22+version = "1.1.4"
23+source = "registry+https://github.com/rust-lang/crates.io-index"
24+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
25+dependencies = [
26+ "memchr",
27+]
28+
20[[package]]
29[[package]]
21name = "allocator-api2"
30name = "allocator-api2"
22version = "0.2.21"
31version = "0.2.21"
@@ -506,6 +515,15 @@ dependencies = [
506 "hashbrown 0.15.5",
515 "hashbrown 0.15.5",
507]
516]
508
517
518+[[package]]
519+name = "matchers"
520+version = "0.2.0"
521+source = "registry+https://github.com/rust-lang/crates.io-index"
522+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
523+dependencies = [
524+ "regex-automata",
525+]
526+
509[[package]]
527[[package]]
510name = "memchr"
528name = "memchr"
511version = "2.7.6"
529version = "2.7.6"
@@ -533,6 +551,15 @@ dependencies = [
533 "windows-sys 0.61.2",
551 "windows-sys 0.61.2",
534]
552]
535
553
554+[[package]]
555+name = "nu-ansi-term"
556+version = "0.50.3"
557+source = "registry+https://github.com/rust-lang/crates.io-index"
558+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
559+dependencies = [
560+ "windows-sys 0.59.0",
561+]
562+
536[[package]]
563[[package]]
537name = "num-conv"
564name = "num-conv"
538version = "0.2.0"
565version = "0.2.0"
@@ -726,6 +753,23 @@ dependencies = [
726 "bitflags",
753 "bitflags",
727]
754]
728
755
756+[[package]]
757+name = "regex-automata"
758+version = "0.4.14"
759+source = "registry+https://github.com/rust-lang/crates.io-index"
760+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
761+dependencies = [
762+ "aho-corasick",
763+ "memchr",
764+ "regex-syntax",
765+]
766+
767+[[package]]
768+name = "regex-syntax"
769+version = "0.8.9"
770+source = "registry+https://github.com/rust-lang/crates.io-index"
771+checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
772+
729[[package]]
773[[package]]
730name = "rustc-demangle"
774name = "rustc-demangle"
731version = "0.1.27"
775version = "0.1.27"
@@ -1069,9 +1113,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1069checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
1113checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
1070dependencies = [
1114dependencies = [
1071 "pin-project-lite",
1115 "pin-project-lite",
1116+ "tracing-attributes",
1072 "tracing-core",
1117 "tracing-core",
1073]
1118]
1074
1119
1120+[[package]]
1121+name = "tracing-attributes"
1122+version = "0.1.31"
1123+source = "registry+https://github.com/rust-lang/crates.io-index"
1124+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
1125+dependencies = [
1126+ "proc-macro2",
1127+ "quote",
1128+ "syn",
1129+]
1130+
1075[[package]]
1131[[package]]
1076name = "tracing-core"
1132name = "tracing-core"
1077version = "0.1.36"
1133version = "0.1.36"
@@ -1092,15 +1148,33 @@ dependencies = [
1092 "tracing-subscriber",
1148 "tracing-subscriber",
1093]
1149]
1094
1150
1151+[[package]]
1152+name = "tracing-log"
1153+version = "0.2.0"
1154+source = "registry+https://github.com/rust-lang/crates.io-index"
1155+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
1156+dependencies = [
1157+ "log",
1158+ "once_cell",
1159+ "tracing-core",
1160+]
1161+
1095[[package]]
1162[[package]]
1096name = "tracing-subscriber"
1163name = "tracing-subscriber"
1097version = "0.3.22"
1164version = "0.3.22"
1098source = "registry+https://github.com/rust-lang/crates.io-index"
1165source = "registry+https://github.com/rust-lang/crates.io-index"
1099checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
1166checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
1100dependencies = [
1167dependencies = [
1168+ "matchers",
1169+ "nu-ansi-term",
1170+ "once_cell",
1171+ "regex-automata",
1101 "sharded-slab",
1172 "sharded-slab",
1173+ "smallvec",
1102 "thread_local",
1174 "thread_local",
1175+ "tracing",
1103 "tracing-core",
1176 "tracing-core",
1177+ "tracing-log",
1104]
1178]
1105
1179
1106[[package]]
1180[[package]]
@@ -1187,6 +1261,8 @@ dependencies = [
1187 "time",
1261 "time",
1188 "tokio",
1262 "tokio",
1189 "toml",
1263 "toml",
1264+ "tracing",
1265+ "tracing-subscriber",
1190 "tui-textarea",
1266 "tui-textarea",
1191 "unicode-width 0.2.0",
1267 "unicode-width 0.2.0",
1192 "uuid",
1268 "uuid",
Cargo.toml+2
@@ -34,3 +34,5 @@ ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
34tokio = { version = "1.40.0", features = ["full"] }
34tokio = { version = "1.40.0", features = ["full"] }
35tui-textarea = { version = "0.7.0", default-features = false, features = ["ratatui"] }
35tui-textarea = { version = "0.7.0", default-features = false, features = ["ratatui"] }
36unicode-width = "0.2"
36unicode-width = "0.2"
37+tracing = "0.1"
38+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
docs/arch.mermaidnew file+14
@@ -0,0 +1,14 @@
1+flowchart TD
2+ VB[Vibebox] --> SM[Session Manager]
3+
4+ SM --> VMM1[VM Manager #1]
5+ SM --> VMM2[VM Manager #2]
6+ SM --> VMM3[VM Manager #N]
7+
8+ VMM1 --> VMI1[VM Instance #1]
9+ VMM2 --> VMI2[VM Instance #2]
10+ VMM3 --> VMIN[VM Instance #N]
11+
12+ VMI1 --> VM1[VM #1]
13+ VMI2 --> VM2[VM #2]
14+ VMIN --> VMN[VM #N]
docs/tasks.md+16−3
@@ -33,9 +33,22 @@
332. [x] Fix the input field that does not expand its height (currently, it just roll the text horizontally). The
332. [x] Fix the input field that does not expand its height (currently, it just roll the text horizontally). The
34 inputfield it should not be scrollable.
34 inputfield it should not be scrollable.
35
35
36−## Integration
36+## Stage 1
37
37
381. [x] Wire up the vm and tui.
381. [x] Wire up the vm and tui.
392. [x] Use ssh to connect to vm.
392. [x] Use ssh to connect to vm.
40+3. [x] allow multi vibebox to connect to the same vm.
41+4. [ ] use vm.lock to ensure process concurrency safety.
40−3. [ ] wire up SessionManager.
42+5. [ ] wire up SessionManager.
41−4. [ ] VM should be separated by a per-session VM daemon process (only accepts if to shut down vm and itself).
43+6. [ ] VM should be separated by a per-session VM daemon process (only accepts if to shut down vm and itself).
44+7. [ ] setup vibebox commands
45+8. [ ] setup cli commands.
46+9. [ ] fix ui overlap.
47+
48+## Publish
49+
50+1. [ ] write the docs
51+2. [ ] setup quick link.
52+3. [ ] setup website.
53+
54+[ ]
src/bin/vibebox-cli.rs+63−4
@@ -1,18 +1,49 @@
1use std::{
1use std::{
2 env,
2 env,
3+ ffi::OsString,
4+ fs,
3− io::{self, Write},
5+ io::{self, IsTerminal, Write},
6+ path::Path,
4 sync::{Arc, Mutex},
7 sync::{Arc, Mutex},
5};
8};
6
9
7use color_eyre::Result;
10use color_eyre::Result;
11+use serde::Deserialize;
12+use tracing_subscriber::EnvFilter;
8
13
9use vibebox::tui::{AppState, VmInfo};
14use vibebox::tui::{AppState, VmInfo};
10−use vibebox::{instance, tui, vm};
15+use vibebox::{instance, tui, vm, vm_manager};
16+
17+const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 3000;
18+
19+#[derive(Debug, Default, Deserialize)]
20+struct ProjectConfig {
21+ auto_shutdown_ms: Option<u64>,
22+}
11
23
12fn main() -> Result<()> {
24fn main() -> Result<()> {
25+ init_tracing();
13 color_eyre::install()?;
26 color_eyre::install()?;
14
27
28+ let raw_args: Vec<OsString> = env::args_os().collect();
29+
30+ if env::var("VIBEBOX_VM_MANAGER").as_deref() == Ok("1") {
31+ tracing::info!("starting vm manager mode");
32+ let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
33+ let auto_shutdown_ms = env::var("VIBEBOX_AUTO_SHUTDOWN_MS")
34+ .ok()
35+ .and_then(|value| value.parse::<u64>().ok())
36+ .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
37+ tracing::info!(auto_shutdown_ms, "vm manager config");
38+ if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) {
39+ tracing::error!(error = %err, "vm manager exited");
40+ return Err(color_eyre::eyre::eyre!(err.to_string()));
41+ }
42+ return Ok(());
43+ }
44+
15 let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
45 let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
46+ tracing::debug!("parsed cli args");
16 if args.version() {
47 if args.version() {
17 vm::print_version();
48 vm::print_version();
18 return Ok(());
49 return Ok(());
@@ -22,13 +53,16 @@ fn main() -> Result<()> {
22 return Ok(());
53 return Ok(());
23 }
54 }
24
55
56+ vm::ensure_signed();
57+
25 let vm_info = VmInfo {
58 let vm_info = VmInfo {
26 version: env!("CARGO_PKG_VERSION").to_string(),
59 version: env!("CARGO_PKG_VERSION").to_string(),
27 max_memory_mb: args.ram_mb(),
60 max_memory_mb: args.ram_mb(),
28 cpu_cores: args.cpu_count(),
61 cpu_cores: args.cpu_count(),
29 };
62 };
30 let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
63 let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
64+ tracing::info!(cwd = %cwd.display(), "starting vibebox cli");
31− let app = Arc::new(Mutex::new(AppState::new(cwd, vm_info)));
65+ let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info)));
32
66
33 {
67 {
34 let mut locked = app.lock().expect("app state poisoned");
68 let mut locked = app.lock().expect("app state poisoned");
@@ -40,8 +74,33 @@ fn main() -> Result<()> {
40 stdout.flush()?;
74 stdout.flush()?;
41 }
75 }
42
76
43− instance::run_with_ssh(args, app.clone())
77+ let auto_shutdown_ms = load_auto_shutdown_ms(&cwd)?;
78+ tracing::info!(auto_shutdown_ms, "auto shutdown config");
79+ let manager_conn = vm_manager::ensure_manager(&raw_args, auto_shutdown_ms)
44 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
80 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
45
81
82+ instance::run_with_ssh(manager_conn).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
83+
46 Ok(())
84 Ok(())
47}
85}
86+
87+fn load_auto_shutdown_ms(project_root: &Path) -> Result<u64> {
88+ let path = project_root.join("vibebox.toml");
89+ let config = match fs::read_to_string(&path) {
90+ Ok(raw) => toml::from_str::<ProjectConfig>(&raw)?,
91+ Err(err) if err.kind() == io::ErrorKind::NotFound => ProjectConfig::default(),
92+ Err(err) => return Err(err.into()),
93+ };
94+ Ok(config.auto_shutdown_ms.unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS))
95+}
96+
97+fn init_tracing() {
98+ let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
99+ let ansi = std::io::stderr().is_terminal() && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
100+ let _ = tracing_subscriber::fmt()
101+ .with_env_filter(filter)
102+ .with_target(false)
103+ .with_ansi(ansi)
104+ .with_writer(std::io::stderr)
105+ .try_init();
106+}
src/bin/vibebox-supervisor.rsnew file+43
@@ -0,0 +1,43 @@
1+use std::{
2+ env,
3+ io::{self, IsTerminal},
4+};
5+
6+use color_eyre::Result;
7+use tracing_subscriber::EnvFilter;
8+
9+use vibebox::{vm, vm_manager};
10+
11+const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 3000;
12+
13+fn main() -> Result<()> {
14+ init_tracing();
15+ color_eyre::install()?;
16+
17+ tracing::info!("starting vm supervisor");
18+ let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
19+ let auto_shutdown_ms = env::var("VIBEBOX_AUTO_SHUTDOWN_MS")
20+ .ok()
21+ .and_then(|value| value.parse::<u64>().ok())
22+ .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
23+ tracing::info!(auto_shutdown_ms, "vm supervisor config");
24+
25+ if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) {
26+ tracing::error!(error = %err, "vm supervisor exited");
27+ return Err(color_eyre::eyre::eyre!(err.to_string()));
28+ }
29+ tracing::info!("vm supervisor exited");
30+
31+ Ok(())
32+}
33+
34+fn init_tracing() {
35+ let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
36+ let ansi = std::io::stderr().is_terminal() && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
37+ let _ = tracing_subscriber::fmt()
38+ .with_env_filter(filter)
39+ .with_target(false)
40+ .with_ansi(ansi)
41+ .with_writer(io::stderr)
42+ .try_init();
43+}
src/instance.rs+131−55
@@ -2,7 +2,7 @@ use std::{
2 env, fs,
2 env, fs,
3 io::{self, Write},
3 io::{self, Write},
4 net::{SocketAddr, TcpStream},
4 net::{SocketAddr, TcpStream},
5− os::unix::{fs::PermissionsExt, io::OwnedFd},
5+ os::unix::{fs::PermissionsExt, io::OwnedFd, net::UnixStream},
6 path::{Path, PathBuf},
6 path::{Path, PathBuf},
7 process::{Command, Stdio},
7 process::{Command, Stdio},
8 sync::{
8 sync::{
@@ -10,6 +10,7 @@ use std::{
10 atomic::{AtomicBool, Ordering},
10 atomic::{AtomicBool, Ordering},
11 },
11 },
12 thread,
12 thread,
13+ time::{Duration, Instant},
13};
14};
14
15
15use serde::{Deserialize, Serialize};
16use serde::{Deserialize, Serialize};
@@ -17,13 +18,14 @@ use std::sync::mpsc::Sender;
17use uuid::Uuid;
18use uuid::Uuid;
18
19
19use crate::{
20use crate::{
20− session_manager::{GLOBAL_DIR_NAME, INSTANCE_DIR_NAME},
21+ session_manager::INSTANCE_DIR_NAME,
21 tui::{self, AppState},
22 tui::{self, AppState},
22− vm::{self, DirectoryShare, LoginAction, VmInput},
23+ vm::{self, LoginAction, VmInput},
23};
24};
24
25
25const INSTANCE_TOML: &str = "instance.toml";
26const INSTANCE_TOML: &str = "instance.toml";
26const SSH_KEY_NAME: &str = "ssh_key";
27const SSH_KEY_NAME: &str = "ssh_key";
28+#[allow(dead_code)]
27const SERIAL_LOG_NAME: &str = "serial.log";
29const SERIAL_LOG_NAME: &str = "serial.log";
28const DEFAULT_SSH_USER: &str = "vibecoder";
30const DEFAULT_SSH_USER: &str = "vibecoder";
29const SSH_CONNECT_RETRIES: usize = 30;
31const SSH_CONNECT_RETRIES: usize = 30;
@@ -31,76 +33,49 @@ const SSH_CONNECT_DELAY_MS: u64 = 500;
31const SSH_SETUP_SCRIPT: &str = include_str!("ssh.sh");
33const SSH_SETUP_SCRIPT: &str = include_str!("ssh.sh");
32
34
33#[derive(Debug, Clone, Serialize, Deserialize)]
35#[derive(Debug, Clone, Serialize, Deserialize)]
34−struct InstanceConfig {
36+pub(crate) struct InstanceConfig {
35 #[serde(default = "default_ssh_user")]
37 #[serde(default = "default_ssh_user")]
36 ssh_user: String,
38 ssh_user: String,
37 #[serde(default)]
39 #[serde(default)]
38 sudo_password: String,
40 sudo_password: String,
39 #[serde(default)]
41 #[serde(default)]
40− vm_ipv4: Option<String>,
42+ pub(crate) vm_ipv4: Option<String>,
41}
43}
42
44
43fn default_ssh_user() -> String {
45fn default_ssh_user() -> String {
44 DEFAULT_SSH_USER.to_string()
46 DEFAULT_SSH_USER.to_string()
45}
47}
46
48
47−pub fn run_with_ssh(
48− args: vm::CliArgs,
49− app: Arc<Mutex<AppState>>,
50−) -> Result<(), Box<dyn std::error::Error>> {
49+pub fn run_with_ssh(manager_conn: UnixStream) -> Result<(), Box<dyn std::error::Error>> {
51 let project_root = env::current_dir()?;
50 let project_root = env::current_dir()?;
52− let project_name = project_root
51+ tracing::info!(root = %project_root.display(), "starting ssh session");
53− .file_name()
54− .ok_or("Project directory has no name")?
55− .to_string_lossy()
56− .into_owned();
57 let instance_dir = ensure_instance_dir(&project_root)?;
52 let instance_dir = ensure_instance_dir(&project_root)?;
53+ tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
58 let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
54 let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
59
55
60− let mut config = load_or_create_instance_config(&instance_dir)?;
56+ let config = load_or_create_instance_config(&instance_dir)?;
61− // Clear cached IP to avoid reusing a stale address on startup.
57+ let ssh_user = config.ssh_user.clone();
62− if config.vm_ipv4.is_some() {
58+ tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
63− config.vm_ipv4 = None;
59+
64− write_instance_config(&instance_dir.join(INSTANCE_TOML), &config)?;
60+ let _manager_conn = manager_conn;
65− }
61+ tracing::debug!("waiting for vm ipv4");
66− let config = Arc::new(Mutex::new(config));
62+ wait_for_vm_ipv4(&instance_dir, Duration::from_secs(120))?;
67−
63+
68− let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
64+ let ip = load_or_create_instance_config(&instance_dir)?
69−
65+ .vm_ipv4
70− let extra_shares = vec![DirectoryShare::new(
66+ .ok_or("VM IPv4 not available")?;
71− instance_dir.clone(),
67+ tracing::info!(ip = %ip, "vm ipv4 ready");
72− ssh_guest_dir.clone().into(),
68+
73− true,
69+ run_ssh_session(ssh_key, ssh_user, ip)
74− )?];
75−
76− let extra_login_actions =
77− build_ssh_login_actions(&config, &project_name, ssh_guest_dir.as_str(), SSH_KEY_NAME);
78−
79− vm::run_with_args_and_extras(
80− args,
81− |output_monitor, vm_output_fd, vm_input_fd| {
82− spawn_ssh_io(
83− app.clone(),
84− config.clone(),
85− instance_dir.clone(),
86− ssh_key.clone(),
87− output_monitor,
88− vm_output_fd,
89− vm_input_fd,
90− )
91− },
92− extra_login_actions,
93− extra_shares,
94− )
95}
70}
96
71
97−fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
72+pub(crate) fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
98 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
73 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
99 fs::create_dir_all(&instance_dir)?;
74 fs::create_dir_all(&instance_dir)?;
100 Ok(instance_dir)
75 Ok(instance_dir)
101}
76}
102
77
103−fn ensure_ssh_keypair(
78+pub(crate) fn ensure_ssh_keypair(
104 instance_dir: &Path,
79 instance_dir: &Path,
105) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
80) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
106 let private_key = instance_dir.join(SSH_KEY_NAME);
81 let private_key = instance_dir.join(SSH_KEY_NAME);
@@ -143,7 +118,7 @@ fn ensure_ssh_keypair(
143 Ok((private_key, public_key))
118 Ok((private_key, public_key))
144}
119}
145
120
146−fn load_or_create_instance_config(
121+pub(crate) fn load_or_create_instance_config(
147 instance_dir: &Path,
122 instance_dir: &Path,
148) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
123) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
149 let config_path = instance_dir.join(INSTANCE_TOML);
124 let config_path = instance_dir.join(INSTANCE_TOML);
@@ -176,7 +151,7 @@ fn load_or_create_instance_config(
176 Ok(config)
151 Ok(config)
177}
152}
178
153
179−fn write_instance_config(
154+pub(crate) fn write_instance_config(
180 path: &Path,
155 path: &Path,
181 config: &InstanceConfig,
156 config: &InstanceConfig,
182) -> Result<(), Box<dyn std::error::Error>> {
157) -> Result<(), Box<dyn std::error::Error>> {
@@ -190,7 +165,7 @@ fn generate_password() -> String {
190 Uuid::now_v7().simple().to_string()
165 Uuid::now_v7().simple().to_string()
191}
166}
192
167
193−fn extract_ipv4(line: &str) -> Option<String> {
168+pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
194 let mut current = String::new();
169 let mut current = String::new();
195 let mut best: Option<String> = None;
170 let mut best: Option<String> = None;
196
171
@@ -209,6 +184,106 @@ fn extract_ipv4(line: &str) -> Option<String> {
209 best
184 best
210}
185}
211
186
187+fn wait_for_vm_ipv4(
188+ instance_dir: &Path,
189+ timeout: Duration,
190+) -> Result<(), Box<dyn std::error::Error>> {
191+ let start = Instant::now();
192+ loop {
193+ let config = load_or_create_instance_config(instance_dir)?;
194+ if config.vm_ipv4.is_some() {
195+ return Ok(());
196+ }
197+ if start.elapsed() > timeout {
198+ return Err("Timed out waiting for VM IPv4".into());
199+ }
200+ if start.elapsed().as_secs() % 5 == 0 {
201+ tracing::debug!("still waiting for vm ipv4");
202+ }
203+ thread::sleep(Duration::from_millis(200));
204+ }
205+}
206+
207+fn run_ssh_session(
208+ ssh_key: PathBuf,
209+ ssh_user: String,
210+ ip: String,
211+) -> Result<(), Box<dyn std::error::Error>> {
212+ let mut attempts = 0usize;
213+ loop {
214+ attempts += 1;
215+ if !ssh_port_open(&ip) {
216+ tracing::debug!(attempts, "ssh port not open yet");
217+ eprintln!(
218+ "[vibebox] waiting for ssh port on {ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
219+ );
220+ if attempts >= SSH_CONNECT_RETRIES {
221+ return Err(
222+ format!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts").into(),
223+ );
224+ }
225+ thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
226+ continue;
227+ }
228+
229+ eprintln!(
230+ "[vibebox] starting ssh to {ssh_user}@{ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
231+ );
232+ tracing::info!(attempts, user = %ssh_user, ip = %ip, "starting ssh");
233+ let status = Command::new("ssh")
234+ .args([
235+ "-i",
236+ ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
237+ "-o",
238+ "IdentitiesOnly=yes",
239+ "-o",
240+ "StrictHostKeyChecking=no",
241+ "-o",
242+ "UserKnownHostsFile=/dev/null",
243+ "-o",
244+ "GlobalKnownHostsFile=/dev/null",
245+ "-o",
246+ "PasswordAuthentication=no",
247+ "-o",
248+ "BatchMode=yes",
249+ "-o",
250+ "LogLevel=ERROR",
251+ "-o",
252+ "ConnectTimeout=5",
253+ ])
254+ .env_remove("LC_CTYPE")
255+ .env_remove("LC_ALL")
256+ .env_remove("LANG")
257+ .arg(format!("{ssh_user}@{ip}"))
258+ .stdin(Stdio::inherit())
259+ .stdout(Stdio::inherit())
260+ .stderr(Stdio::inherit())
261+ .status();
262+
263+ match status {
264+ Ok(status) if status.success() => {
265+ eprintln!("[vibebox] ssh exited with status: {status}");
266+ break;
267+ }
268+ Ok(status) if status.code() == Some(255) => {
269+ eprintln!("[vibebox] ssh connection failed: {status}");
270+ if attempts >= SSH_CONNECT_RETRIES {
271+ return Err(format!("ssh failed after {SSH_CONNECT_RETRIES} attempts").into());
272+ }
273+ thread::sleep(Duration::from_millis(500));
274+ }
275+ Ok(status) => {
276+ return Err(format!("ssh exited with status: {status}").into());
277+ }
278+ Err(err) => {
279+ return Err(format!("failed to start ssh: {err}").into());
280+ }
281+ }
282+ }
283+
284+ Ok(())
285+}
286+
212fn is_ipv4_candidate(candidate: &str) -> bool {
287fn is_ipv4_candidate(candidate: &str) -> bool {
213 let parts: Vec<&str> = candidate.split('.').collect();
288 let parts: Vec<&str> = candidate.split('.').collect();
214 if parts.len() != 4 {
289 if parts.len() != 4 {
@@ -233,7 +308,7 @@ fn ssh_port_open(ip: &str) -> bool {
233 TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok()
308 TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok()
234}
309}
235
310
236−fn build_ssh_login_actions(
311+pub(crate) fn build_ssh_login_actions(
237 config: &Arc<Mutex<InstanceConfig>>,
312 config: &Arc<Mutex<InstanceConfig>>,
238 project_name: &str,
313 project_name: &str,
239 guest_dir: &str,
314 guest_dir: &str,
@@ -257,6 +332,7 @@ fn build_ssh_login_actions(
257 vec![LoginAction::Send(setup)]
332 vec![LoginAction::Send(setup)]
258}
333}
259
334
335+#[allow(dead_code)]
260fn spawn_ssh_io(
336fn spawn_ssh_io(
261 app: Arc<Mutex<AppState>>,
337 app: Arc<Mutex<AppState>>,
262 config: Arc<Mutex<InstanceConfig>>,
338 config: Arc<Mutex<InstanceConfig>>,
src/lib.rs+1
@@ -2,5 +2,6 @@ pub mod instance;
2pub mod session_manager;
2pub mod session_manager;
3pub mod tui;
3pub mod tui;
4pub mod vm;
4pub mod vm;
5+pub mod vm_manager;
5
6
6pub use session_manager::{SessionError, SessionManager, SessionRecord};
7pub use session_manager::{SessionError, SessionManager, SessionRecord};
src/vm.rs+3
@@ -1358,6 +1358,9 @@ impl Drop for RawModeGuard {
1358
1358
1359// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
1359// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
1360pub fn ensure_signed() {
1360pub fn ensure_signed() {
1361+ if std::env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
1362+ return;
1363+ }
1361 let exe = std::env::current_exe().expect("failed to get current exe path");
1364 let exe = std::env::current_exe().expect("failed to get current exe path");
1362 let exe_str = exe.to_str().expect("exe path not valid utf-8");
1365 let exe_str = exe.to_str().expect("exe path not valid utf-8");
1363
1366
src/vm_manager.rsnew file+538
@@ -0,0 +1,538 @@
1+use std::{
2+ env, fs,
3+ io::{Read, Write},
4+ os::unix::{
5+ fs::PermissionsExt,
6+ io::AsRawFd,
7+ net::{UnixListener, UnixStream},
8+ process::CommandExt,
9+ },
10+ path::{Path, PathBuf},
11+ process::{Command, Stdio},
12+ sync::{Arc, Mutex, mpsc},
13+ thread,
14+ time::{Duration, Instant},
15+};
16+
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+};
25+
26+const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
27+
28+pub fn ensure_manager(
29+ raw_args: &[std::ffi::OsString],
30+ auto_shutdown_ms: u64,
31+) -> Result<UnixStream, Box<dyn std::error::Error>> {
32+ let project_root = env::current_dir()?;
33+ tracing::debug!(root = %project_root.display(), "ensure vm manager");
34+ let instance_dir = ensure_instance_dir(&project_root)?;
35+ let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
36+
37+ if let Ok(stream) = UnixStream::connect(&socket_path) {
38+ send_client_pid(&stream);
39+ tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
40+ return Ok(stream);
41+ }
42+
43+ tracing::info!(path = %socket_path.display(), "spawning vm manager");
44+ spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir)?;
45+
46+ let start = Instant::now();
47+ let timeout = Duration::from_secs(10);
48+ loop {
49+ match UnixStream::connect(&socket_path) {
50+ Ok(stream) => {
51+ send_client_pid(&stream);
52+ tracing::info!(path = %socket_path.display(), "connected to vm manager");
53+ return Ok(stream);
54+ }
55+ Err(err) => {
56+ tracing::debug!(error = %err, "waiting for vm manager socket");
57+ if start.elapsed() > timeout {
58+ return Err(format!(
59+ "Timed out waiting for vm manager socket: {} ({})",
60+ socket_path.display(),
61+ err
62+ )
63+ .into());
64+ }
65+ thread::sleep(Duration::from_millis(100));
66+ }
67+ }
68+ }
69+}
70+
71+pub fn run_manager(
72+ args: vm::CliArgs,
73+ auto_shutdown_ms: u64,
74+) -> Result<(), Box<dyn std::error::Error>> {
75+ let project_root = env::current_dir()?;
76+ 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+ )
88+}
89+
90+fn spawn_manager_process(
91+ raw_args: &[std::ffi::OsString],
92+ auto_shutdown_ms: u64,
93+ instance_dir: &Path,
94+) -> Result<(), Box<dyn std::error::Error>> {
95+ let exe = env::current_exe()?;
96+ let mut supervisor_exe = exe.clone();
97+ 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+ };
106+ if raw_args.len() > 1 {
107+ cmd.args(&raw_args[1..]);
108+ }
109+ if !use_supervisor {
110+ cmd.env("VIBEBOX_VM_MANAGER", "1");
111+ }
112+ cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
113+ 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");
116+ let log_file = fs::OpenOptions::new()
117+ .create(true)
118+ .append(true)
119+ .open(&log_path)
120+ .ok();
121+ if let Some(file) = log_file {
122+ let stderr = Stdio::from(file);
123+ cmd.stdin(Stdio::null())
124+ .stdout(Stdio::null())
125+ .stderr(stderr);
126+ } else {
127+ cmd.stdin(Stdio::null())
128+ .stdout(Stdio::null())
129+ .stderr(Stdio::null());
130+ }
131+ let _child = cmd.spawn()?;
132+ Ok(())
133+}
134+
135+fn detach_from_terminal() {
136+ unsafe {
137+ libc::setsid();
138+ }
139+
140+ if let Ok(devnull) = fs::OpenOptions::new()
141+ .read(true)
142+ .write(true)
143+ .open("/dev/null")
144+ {
145+ let _ = unsafe { libc::dup2(devnull.as_raw_fd(), libc::STDIN_FILENO) };
146+ let _ = unsafe { libc::dup2(devnull.as_raw_fd(), libc::STDOUT_FILENO) };
147+ }
148+}
149+
150+fn wait_for_disconnect(mut stream: UnixStream) {
151+ let mut buf = [0u8; 64];
152+ loop {
153+ match stream.read(&mut buf) {
154+ Ok(0) => break,
155+ Ok(_) => continue,
156+ Err(_) => break,
157+ }
158+ }
159+}
160+
161+fn send_client_pid(stream: &UnixStream) {
162+ let pid = std::process::id();
163+ let payload = format!("pid={pid}\n");
164+ if let Ok(mut stream) = stream.try_clone() {
165+ let _ = stream.write_all(payload.as_bytes());
166+ let _ = stream.flush();
167+ }
168+}
169+
170+fn read_client_pid(stream: &UnixStream) -> Option<u32> {
171+ let mut stream = stream.try_clone().ok()?;
172+ let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
173+ let mut buf = [0u8; 64];
174+ let mut len = 0usize;
175+ loop {
176+ match stream.read(&mut buf[len..]) {
177+ Ok(0) => break,
178+ Ok(n) => {
179+ len += n;
180+ if buf[..len].iter().any(|b| *b == b'\n') || len == buf.len() {
181+ break;
182+ }
183+ }
184+ Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break,
185+ Err(err) if err.kind() == std::io::ErrorKind::TimedOut => break,
186+ Err(_) => break,
187+ }
188+ }
189+ let _ = stream.set_read_timeout(None);
190+ if len == 0 {
191+ return None;
192+ }
193+ let line = String::from_utf8_lossy(&buf[..len]);
194+ let trimmed = line.trim();
195+ if let Some(value) = trimmed.strip_prefix("pid=") {
196+ value.parse::<u32>().ok()
197+ } else {
198+ None
199+ }
200+}
201+
202+fn spawn_manager_io(
203+ config: Arc<Mutex<InstanceConfig>>,
204+ instance_dir: PathBuf,
205+ output_monitor: Arc<vm::OutputMonitor>,
206+ vm_output_fd: std::os::unix::io::OwnedFd,
207+ vm_input_fd: std::os::unix::io::OwnedFd,
208+) -> vm::IoContext {
209+ let log_path = instance_dir.join("serial.log");
210+ let log_file = fs::OpenOptions::new()
211+ .create(true)
212+ .append(true)
213+ .open(&log_path)
214+ .ok()
215+ .map(|file| Arc::new(Mutex::new(file)));
216+
217+ let instance_path = instance_dir.join("instance.toml");
218+ let config_for_output = config.clone();
219+ let log_for_output = log_file.clone();
220+ let mut line_buf = String::new();
221+
222+ 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+ }
227+ }
228+
229+ let text = String::from_utf8_lossy(bytes);
230+ line_buf.push_str(&text);
231+
232+ while let Some(pos) = line_buf.find('\n') {
233+ let mut line = line_buf[..pos].to_string();
234+ if line.ends_with('\r') {
235+ line.pop();
236+ }
237+ line_buf.drain(..=pos);
238+
239+ let cleaned = line.trim_start_matches(|c: char| c == '\r' || c == ' ');
240+ if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
241+ let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
242+ 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+ }
250+ }
251+ }
252+ }
253+ };
254+
255+ vm::spawn_vm_io_with_hooks(
256+ output_monitor,
257+ vm_output_fd,
258+ vm_input_fd,
259+ vm::IoControl::new(),
260+ |_| false,
261+ on_output,
262+ )
263+}
264+
265+enum ManagerEvent {
266+ Inc(Option<u32>),
267+ Dec(Option<u32>),
268+ VmExited(Option<String>),
269+}
270+
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+
309+struct ManagerOptions {
310+ ensure_signed: bool,
311+ detach: bool,
312+ prepare_vm: bool,
313+}
314+
315+trait VmExecutor {
316+ fn run_vm(
317+ &self,
318+ args: vm::CliArgs,
319+ extra_login_actions: Vec<LoginAction>,
320+ extra_shares: Vec<DirectoryShare>,
321+ config: Arc<Mutex<InstanceConfig>>,
322+ instance_dir: PathBuf,
323+ vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
324+ ) -> Result<(), Box<dyn std::error::Error>>;
325+}
326+
327+struct RealVmExecutor;
328+
329+impl VmExecutor for RealVmExecutor {
330+ fn run_vm(
331+ &self,
332+ args: vm::CliArgs,
333+ extra_login_actions: Vec<LoginAction>,
334+ extra_shares: Vec<DirectoryShare>,
335+ config: Arc<Mutex<InstanceConfig>>,
336+ instance_dir: PathBuf,
337+ 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(
340+ args,
341+ |output_monitor, vm_output_fd, vm_input_fd| {
342+ let io_ctx = spawn_manager_io(
343+ config.clone(),
344+ instance_dir.clone(),
345+ output_monitor,
346+ vm_output_fd,
347+ vm_input_fd,
348+ );
349+ *vm_input_tx.lock().unwrap() = Some(io_ctx.input_tx.clone());
350+ io_ctx
351+ },
352+ extra_login_actions,
353+ extra_shares,
354+ );
355+ result
356+ }
357+}
358+
359+fn run_manager_with(
360+ project_root: &Path,
361+ args: vm::CliArgs,
362+ auto_shutdown_ms: u64,
363+ executor: &dyn VmExecutor,
364+ 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+ }
376+ if options.detach {
377+ detach_from_terminal();
378+ }
379+
380+ let project_name = project_root
381+ .file_name()
382+ .ok_or("Project directory has no name")?
383+ .to_string_lossy()
384+ .into_owned();
385+ let instance_dir = ensure_instance_dir(project_root)?;
386+ if options.prepare_vm {
387+ let _ = ensure_ssh_keypair(&instance_dir)?;
388+ }
389+
390+ let mut config = load_or_create_instance_config(&instance_dir)?;
391+ if config.vm_ipv4.is_some() {
392+ config.vm_ipv4 = None;
393+ write_instance_config(&instance_dir.join("instance.toml"), &config)?;
394+ }
395+ let config = Arc::new(Mutex::new(config));
396+
397+ 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");
405+
406+ let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
407+ if let Ok(stream) = UnixStream::connect(&socket_path) {
408+ drop(stream);
409+ return Ok(());
410+ }
411+ if socket_path.exists() {
412+ let _ = fs::remove_file(&socket_path);
413+ }
414+
415+ let listener = UnixListener::bind(&socket_path)?;
416+ let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
417+ tracing::info!(path = %socket_path.display(), "vm manager socket bound");
418+
419+ let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
420+ let event_tx_accept = event_tx.clone();
421+ thread::spawn(move || {
422+ for stream in listener.incoming() {
423+ match stream {
424+ Ok(stream) => {
425+ let event_tx_conn = event_tx_accept.clone();
426+ thread::spawn(move || {
427+ let pid = read_client_pid(&stream);
428+ let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
429+ wait_for_disconnect(stream);
430+ let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
431+ });
432+ }
433+ Err(_) => break,
434+ }
435+ }
436+ });
437+
438+ let vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>> = Arc::new(Mutex::new(None));
439+ let vm_input_for_loop = vm_input_tx.clone();
440+ let event_loop_handle =
441+ thread::spawn(move || manager_event_loop(event_rx, vm_input_for_loop, auto_shutdown_ms));
442+
443+ tracing::info!("vm manager launching vm");
444+ let vm_result = executor.run_vm(
445+ args,
446+ extra_login_actions,
447+ extra_shares,
448+ config.clone(),
449+ instance_dir.clone(),
450+ vm_input_tx.clone(),
451+ );
452+ tracing::info!("vm manager vm run completed");
453+ let vm_err = vm_result.err().map(|e| e.to_string());
454+ let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
455+ let event_loop_result: Result<(), String> = event_loop_handle
456+ .join()
457+ .unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
458+ .map_err(|err| err.to_string());
459+ let _ = fs::remove_file(&socket_path);
460+ if let Err(err) = &event_loop_result {
461+ tracing::error!(error = %err, "vm manager exiting due to event loop error");
462+ return Err(err.to_string().into());
463+ }
464+ if let Some(err) = vm_err {
465+ tracing::error!(error = %err, "vm manager exiting due to vm error");
466+ return Err(err.into());
467+ }
468+ tracing::info!("vm manager exiting");
469+ Ok(event_loop_result?)
470+}
471+
472+fn manager_event_loop(
473+ event_rx: mpsc::Receiver<ManagerEvent>,
474+ vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
475+ auto_shutdown_ms: u64,
476+) -> Result<(), String> {
477+ let mut ref_count: usize = 0;
478+ let mut shutdown_deadline: Option<Instant> = None;
479+ let mut shutdown_sent = false;
480+ let grace = Duration::from_millis(auto_shutdown_ms.max(1));
481+
482+ loop {
483+ let timeout = match shutdown_deadline {
484+ Some(deadline) => deadline.saturating_duration_since(Instant::now()),
485+ None => Duration::from_secs(1),
486+ };
487+
488+ match event_rx.recv_timeout(timeout) {
489+ Ok(ManagerEvent::Inc(pid)) => {
490+ ref_count = ref_count.saturating_add(1);
491+ tracing::info!(
492+ ref_count,
493+ pid = pid.unwrap_or(0),
494+ pid_known = pid.is_some(),
495+ "vm manager refcount increment"
496+ );
497+ shutdown_deadline = None;
498+ shutdown_sent = false;
499+ }
500+ Ok(ManagerEvent::Dec(pid)) => {
501+ if ref_count > 0 {
502+ ref_count -= 1;
503+ }
504+ tracing::info!(
505+ ref_count,
506+ pid = pid.unwrap_or(0),
507+ pid_known = pid.is_some(),
508+ "vm manager refcount decrement"
509+ );
510+ if ref_count == 0 {
511+ shutdown_deadline = Some(Instant::now() + grace);
512+ tracing::info!(grace_ms = auto_shutdown_ms, "shutdown scheduled");
513+ }
514+ }
515+ Ok(ManagerEvent::VmExited(err)) => {
516+ if let Some(err) = err {
517+ tracing::error!(error = %err, "vm exited with error");
518+ }
519+ break;
520+ }
521+ 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()));
526+ }
527+ tracing::info!("shutdown command sent");
528+ shutdown_sent = true;
529+ shutdown_deadline = None;
530+ }
531+ }
532+ }
533+ Err(mpsc::RecvTimeoutError::Disconnected) => break,
534+ }
535+ }
536+
537+ Ok(())
538+}