@@ -1,5 +1,5 @@
1−use crate::instance::STATUS_FILE_NAME;
21use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
2+use anyhow::{Context, Error, Result, bail};
33use std::{
44 env, fs,
55 io::{self, Write},
@@ -23,6 +23,7 @@ use std::{
2323};
2424
2525use block2::RcBlock;
26+use bytesize::ByteSize;
2627use dispatch2::DispatchQueue;
2728use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
2829use objc2_foundation::*;
@@ -32,75 +33,46 @@ const DEBIAN_COMPRESSED_DISK_URL: &str = "https://cloud.debian.org/images/cloud/
3233const DEBIAN_COMPRESSED_SHA: &str = "6ab9be9e6834adc975268367f2f0235251671184345c34ee13031749fdfbf66fe4c3aafd949a2d98550426090e9ac645e79009c51eb0eefc984c15786570bb38";
3334const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
3435const SHARED_DIRECTORIES_TAG: &str = "shared";
36+pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
3537
36−const BYTES_PER_MB: u64 = 1024 * 1024;
37−const DEFAULT_CPU_COUNT: usize = 2;
38−const DEFAULT_RAM_MB: u64 = 2048;
39−const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB;
4038const START_TIMEOUT: Duration = Duration::from_secs(60);
4139const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
40+const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900);
4241
43−struct StatusFile {
44− path: PathBuf,
45− cleared: AtomicBool,
46−}
47−
48−impl StatusFile {
49− fn new(path: PathBuf) -> Self {
50− Self {
51− path,
52− cleared: AtomicBool::new(false),
53− }
54− }
55−
56− fn update(&self, message: &str) {
57− let _ = fs::write(&self.path, message);
58− }
59−
60− fn clear(&self) {
61− if !self.cleared.swap(true, Ordering::SeqCst) {
62− let _ = fs::remove_file(&self.path);
63− }
64− }
65−}
66−
67−impl Drop for StatusFile {
68− fn drop(&mut self) {
69− if !self.cleared.load(Ordering::SeqCst) {
70− let _ = fs::remove_file(&self.path);
71− self.cleared.store(true, Ordering::SeqCst);
72− }
73− }
74−}
42+const ERROR_REPORT_SCRIPT: &str = include_str!("error_report.sh");
7543const PROVISION_SCRIPT: &str = include_str!("provision.sh");
76−const PROVISION_SCRIPT_NAME: &str = "provision.sh";
7744const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
7845const DEFAULT_RAW_NAME: &str = "default.raw";
7946const INSTANCE_RAW_NAME: &str = "instance.raw";
8047const BASE_DISK_RAW_NAME: &str = "disk.raw";
8148
8249#[derive(Clone)]
83−pub(crate) enum LoginAction {
84− Expect { text: String, timeout: Duration },
50+pub enum LoginAction {
51+ Expect {
52+ text: String,
53+ timeout: Duration,
54+ },
55+ ExpectEither {
56+ success: String,
57+ failure: String,
58+ timeout: Duration,
59+ },
8560 Send(String),
8661}
62+use crate::config::BoxConfig;
8763use LoginAction::*;
8864
8965#[derive(Clone)]
90−pub(crate) struct DirectoryShare {
66+pub struct DirectoryShare {
9167 host: PathBuf,
9268 guest: PathBuf,
9369 read_only: bool,
9470}
9571
9672impl DirectoryShare {
97− pub(crate) fn new(
98− host: PathBuf,
99− mut guest: PathBuf,
100− read_only: bool,
101− ) -> Result<Self, Box<dyn std::error::Error>> {
73+ pub fn new(host: PathBuf, mut guest: PathBuf, read_only: bool) -> Result<Self> {
10274 if !host.exists() {
103− return Err(format!("Host path does not exist: {}", host.display()).into());
75+ bail!(format!("host path does not exist: {}", host.display()));
10476 }
10577 if !guest.is_absolute() {
10678 guest = PathBuf::from("/root").join(guest);
@@ -112,10 +84,10 @@ impl DirectoryShare {
11284 })
11385 }
11486
115− pub(crate) fn from_mount_spec(spec: &str) -> Result<Self, Box<dyn std::error::Error>> {
87+ pub fn from_mount_spec(spec: &str) -> Result<Self> {
11688 let parts: Vec<&str> = spec.split(':').collect();
11789 if parts.len() < 2 || parts.len() > 3 {
118− return Err(format!("Invalid mount spec: {spec}").into());
90+ bail!(format!("invalid mount spec: {spec}"));
11991 }
12092 let host = expand_tilde_path(parts[0]);
12193 let guest = PathBuf::from(parts[1]);
@@ -124,11 +96,10 @@ impl DirectoryShare {
12496 "read-only" => true,
12597 "read-write" => false,
12698 _ => {
127− return Err(format!(
99+ bail!(format!(
128100 "Invalid mount mode '{}'; expected read-only or read-write",
129101 parts[2]
130− )
131− .into());
102+ ));
132103 }
133104 }
134105 } else {
@@ -172,19 +143,28 @@ pub struct VmArg {
172143 pub mounts: Vec<String>,
173144}
174145
175−pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
146+type StatusEmitter<'a> = dyn Fn(&str) + std::marker::Send + Sync + 'a;
147+
148+fn emit_status(status: Option<&StatusEmitter<'_>>, message: &str) {
149+ if let Some(status) = status {
150+ status(message);
151+ }
152+}
153+
154+pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<()>
176155where
177156 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
178157{
179− run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new())
158+ run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new(), None)
180159}
181160
182−pub(crate) fn run_with_args_and_extras<F>(
161+pub fn run_with_args_and_extras<F>(
183162 args: VmArg,
184163 io_handler: F,
185164 extra_login_actions: Vec<LoginAction>,
186165 extra_directory_shares: Vec<DirectoryShare>,
187−) -> Result<(), Box<dyn std::error::Error>>
166+ status: Option<&StatusEmitter<'_>>,
167+) -> Result<()>
188168where
189169 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
190170{
@@ -193,7 +173,7 @@ where
193173 let project_root = env::current_dir()?;
194174 let project_name = project_root
195175 .file_name()
196− .ok_or("Project directory has no name")?
176+ .with_context(|| "Project directory has no name")?
197177 .to_string_lossy()
198178 .into_owned();
199179
@@ -205,8 +185,10 @@ where
205185 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
206186
207187 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
208− let status_file = StatusFile::new(instance_dir.join(STATUS_FILE_NAME));
209− status_file.update("preparing VM image...");
188+ fs::create_dir_all(&instance_dir)?;
189+ emit_status(status, "preparing VM image...");
190+ tracing::info!("preparing VM image...");
191+ let provision_log = instance_dir.join("provision.log");
210192
211193 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
212194 let base_compressed = cache_dir.join(basename_compressed);
@@ -230,13 +212,14 @@ where
230212 &base_compressed,
231213 &default_raw,
232214 std::slice::from_ref(&mise_directory_share),
233− Some(&status_file),
215+ Some(&provision_log),
216+ status,
234217 )?;
235218 let _ = ensure_instance_disk(
236219 &instance_raw,
237220 &default_raw,
238− args.disk_bytes,
239− Some(&status_file),
221+ ByteSize(args.disk_bytes),
222+ status,
240223 )?;
241224 let base_size = fs::metadata(&default_raw)?.len();
242225 let instance_size = fs::metadata(&instance_raw)?.len();
@@ -247,7 +230,8 @@ where
247230 let mut directory_shares = Vec::new();
248231
249232 if !args.no_default_mounts {
250− login_actions.push(Send(format!("cd {project_name}")));
233+ let project_guest_dir = PathBuf::from(PROJECT_GUEST_BASE).join(project_name);
234+ login_actions.push(Send(format!("cd {}", project_guest_dir.display())));
251235
252236 // discourage read/write of .git folder from within the VM. note that this isn't secure, since the VM runs as root and could unmount this.
253237 // I couldn't find an alternative way to do this --- the MacOS sandbox doesn't apply to the Apple Virtualization system
@@ -255,15 +239,6 @@ where
255239 login_actions.push(Send(r"mount -t tmpfs tmpfs .git/".into()));
256240 }
257241
258− directory_shares.push(
259− DirectoryShare::new(
260− project_root,
261− PathBuf::from("/root/").join(project_name),
262− false,
263− )
264− .expect("Project directory must exist"),
265− );
266−
267242 directory_shares.push(mise_directory_share);
268243 }
269244
@@ -274,7 +249,7 @@ where
274249 }
275250
276251 if needs_resize {
277− let resize_cmd = script_command_from_content("resize_disk", RESIZE_DISK_SCRIPT)?;
252+ let resize_cmd = script_command_from_content("resize_disk.sh", RESIZE_DISK_SCRIPT)?;
278253 login_actions.push(Send(resize_cmd));
279254 }
280255
@@ -290,25 +265,29 @@ where
290265 &directory_shares[..],
291266 args.cpu_count,
292267 args.ram_bytes,
293− Some(&status_file),
268+ status,
294269 io_handler,
295270 )
296271}
297272
298−pub(crate) fn script_command_from_content(
299− label: &str,
300− script: &str,
301−) -> Result<String, Box<dyn std::error::Error>> {
273+pub fn script_command_from_content(label: &str, script: &str) -> Result<String> {
302274 let marker = "VIBE_SCRIPT_EOF";
303275 let guest_dir = "/tmp/vibe-scripts";
304276 let guest_path = format!("{guest_dir}/{label}.sh");
277+ let script_body = match script.split_once('\n') {
278+ Some((first, rest)) if first.starts_with("#!") => rest,
279+ _ => script,
280+ };
281+ let wrapped_script = ERROR_REPORT_SCRIPT
282+ .replace("__LABEL__", label)
283+ .replace("__SCRIPT_BODY__", script_body);
305284 let command = format!(
306− "mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{script}\n{marker}\nchmod +x {guest_path}\n{guest_path}"
285+ "mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{wrapped_script}\n{marker}\nchmod +x {guest_path}\n{guest_path}"
307286 );
308287 if script.contains(marker) {
309− return Err(
310− format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
311− );
288+ bail!(format!(
289+ "Script '{label}' contains marker '{marker}', cannot safely upload"
290+ ));
312291 }
313292 Ok(command)
314293}
@@ -372,6 +351,12 @@ enum WaitResult {
372351 Found,
373352}
374353
354+#[derive(PartialEq, Eq)]
355+enum WaitAnyResult {
356+ Timeout,
357+ Found(usize),
358+}
359+
375360pub enum VmInput {
376361 Bytes(Vec<u8>),
377362 Shutdown,
@@ -379,6 +364,7 @@ pub enum VmInput {
379364
380365enum VmOutput {
381366 LoginActionTimeout { action: String, timeout: Duration },
367+ LoginActionFailed { action: String, reason: String },
382368}
383369
384370#[derive(Default)]
@@ -415,6 +401,41 @@ impl OutputMonitor {
415401 WaitResult::Found
416402 }
417403 }
404+
405+ fn wait_for_any(&self, needles: &[&str], timeout: Duration) -> WaitAnyResult {
406+ let mut found: Option<usize> = None;
407+ let (_unused, timeout_result) = self
408+ .condvar
409+ .wait_timeout_while(self.buffer.lock().unwrap(), timeout, |buf| {
410+ if let Some((pos, idx, len)) = find_any(buf, needles) {
411+ *buf = buf[(pos + len)..].to_string();
412+ found = Some(idx);
413+ false
414+ } else {
415+ true
416+ }
417+ })
418+ .unwrap();
419+
420+ if timeout_result.timed_out() {
421+ WaitAnyResult::Timeout
422+ } else {
423+ WaitAnyResult::Found(found.unwrap_or(0))
424+ }
425+ }
426+}
427+
428+fn find_any(buf: &str, needles: &[&str]) -> Option<(usize, usize, usize)> {
429+ let mut best: Option<(usize, usize, usize)> = None; // (pos, idx, len)
430+ for (idx, needle) in needles.iter().enumerate() {
431+ if let Some(pos) = buf.find(needle) {
432+ let candidate = (pos, idx, needle.len());
433+ if best.is_none_or(|b| candidate.0 < b.0) {
434+ best = Some(candidate);
435+ }
436+ }
437+ }
438+ best
418439}
419440
420441#[derive(Debug)]
@@ -463,18 +484,16 @@ impl IoControl {
463484fn ensure_base_image(
464485 base_raw: &Path,
465486 base_compressed: &Path,
466− status: Option<&StatusFile>,
467−) -> Result<(), Box<dyn std::error::Error>> {
487+ status: Option<&StatusEmitter<'_>>,
488+) -> Result<()> {
468489 if base_raw.exists() {
469490 return Ok(());
470491 }
471492
472493 if !base_compressed.exists()
473− || std::fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES
494+ || fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES
474495 {
475− if let Some(status) = status {
476− status.update("downloading base image...");
477− }
496+ emit_status(status, "downloading base image...");
478497 tracing::info!("downloading base image");
479498 let status = Command::new("curl")
480499 .args([
@@ -489,15 +508,14 @@ fn ensure_base_image(
489508 ])
490509 .status()?;
491510 if !status.success() {
492− return Err("Failed to download base image".into());
511+ bail!("failed to download base image");
493512 }
494513 }
495514
496515 // Check SHA
497516 {
498− if let Some(status) = status {
499− status.update("verifying base image...");
500− }
517+ emit_status(status, "verifying base image...");
518+ tracing::info!("verifying base image...");
501519 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
502520
503521 let mut child = Command::new("/usr/bin/shasum")
@@ -515,25 +533,25 @@ fn ensure_base_image(
515533
516534 let status = child.wait().expect("failed to wait on child");
517535 if !status.success() {
518− return Err(format!("SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}").into());
536+ bail!(format!(
537+ "SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}"
538+ ));
519539 }
520540 }
521541
522− if let Some(status) = status {
523− status.update("decompressing base image...");
524− }
525− tracing::info!("decompressing base image");
542+ emit_status(status, "decompressing base image...");
543+ tracing::info!("decompressing base image...");
526544 let status = Command::new("tar")
527545 .args([
528546 "-xOf",
529547 &base_compressed.to_string_lossy(),
530548 BASE_DISK_RAW_NAME,
531549 ])
532− .stdout(std::fs::File::create(base_raw)?)
550+ .stdout(fs::File::create(base_raw)?)
533551 .status()?;
534552
535553 if !status.success() {
536− return Err("Failed to decompress base image".into());
554+ bail!("Failed to decompress base image");
537555 }
538556
539557 Ok(())
@@ -544,29 +562,56 @@ fn ensure_default_image(
544562 base_compressed: &Path,
545563 default_raw: &Path,
546564 directory_shares: &[DirectoryShare],
547− status: Option<&StatusFile>,
548−) -> Result<(), Box<dyn std::error::Error>> {
565+ provision_log: Option<&Path>,
566+ status: Option<&StatusEmitter<'_>>,
567+) -> Result<()> {
549568 if default_raw.exists() {
550569 return Ok(());
551570 }
552571
553572 ensure_base_image(base_raw, base_compressed, status)?;
554573
555− if let Some(status) = status {
556− status.update("configuring base image...");
557− }
558− tracing::info!("configuring base image");
574+ emit_status(status, "configuring base image...");
575+ tracing::info!("configuring base image...");
559576 fs::copy(base_raw, default_raw)?;
560577
561− let provision_command = script_command_from_content(PROVISION_SCRIPT_NAME, PROVISION_SCRIPT)?;
562− run_vm(
563− default_raw,
564− &[Send(provision_command)],
565− directory_shares,
566− DEFAULT_CPU_COUNT,
567− DEFAULT_RAM_BYTES,
568− None,
569− )?;
578+ let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
579+ let provision_actions = [
580+ Send(provision_command),
581+ ExpectEither {
582+ success: "VIBEBOX_PROVISION_OK".to_string(),
583+ failure: "VIBEBOX_PROVISION_FAILED".to_string(),
584+ timeout: PROVISION_EXPECT_TIMEOUT,
585+ },
586+ ];
587+ let provision_result = if let Some(log_path) = provision_log {
588+ let log_path = log_path.to_path_buf();
589+ run_vm_with_io(
590+ default_raw,
591+ &provision_actions,
592+ directory_shares,
593+ BoxConfig::default().cpu_count,
594+ BoxConfig::default().ram_size.as_u64(),
595+ status,
596+ move |output_monitor, vm_output_fd, vm_input_fd| {
597+ spawn_vm_io_with_log(output_monitor, vm_output_fd, vm_input_fd, log_path)
598+ },
599+ )
600+ } else {
601+ run_vm(
602+ default_raw,
603+ &provision_actions,
604+ directory_shares,
605+ BoxConfig::default().cpu_count,
606+ BoxConfig::default().ram_size.as_u64(),
607+ status,
608+ )
609+ };
610+
611+ if let Err(err) = provision_result {
612+ let _ = fs::remove_file(default_raw);
613+ return Err(err);
614+ }
570615
571616 Ok(())
572617}
@@ -574,18 +619,16 @@ fn ensure_default_image(
574619fn ensure_instance_disk(
575620 instance_raw: &Path,
576621 template_raw: &Path,
577− target_bytes: u64,
578− status: Option<&StatusFile>,
579−) -> Result<bool, Box<dyn std::error::Error>> {
622+ target_bytes: ByteSize,
623+ status: Option<&StatusEmitter<'_>>,
624+) -> Result<bool> {
580625 if instance_raw.exists() {
581− let current_size = fs::metadata(instance_raw)?.len();
626+ let current_size = ByteSize(fs::metadata(instance_raw)?.len());
582627 if current_size != target_bytes {
583− let current_gb = current_size as f64 / (1024.0 * 1024.0 * 1024.0);
584− let target_gb = target_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
628+ let current_gb = current_size;
629+ let target_gb = target_bytes;
585630 tracing::warn!(
586− current_bytes = current_size,
587− target_bytes,
588− "instance disk size does not match config (current {:.2} GB, config {:.2} GB); disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using existing disk.",
631+ "instance disk size does not match config (current {}, config {}); disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using existing disk.",
589632 current_gb,
590633 target_gb
591634 );
@@ -593,30 +636,28 @@ fn ensure_instance_disk(
593636 return Ok(false);
594637 }
595638
596− let template_size = fs::metadata(template_raw)?.len();
639+ let template_size = ByteSize(fs::metadata(template_raw)?.len());
597640 if target_bytes < template_size {
598− return Err(format!(
599− "Requested disk size {} bytes is smaller than base image size {} bytes",
641+ bail!(format!(
642+ "Requested disk size {} is smaller than base image size {}",
600643 target_bytes, template_size
601− )
602− .into());
644+ ));
603645 }
604646 let target_size = target_bytes;
605647 let needs_resize = target_size > template_size;
606648
607− if let Some(status) = status {
608− status.update("creating instance disk...");
609− }
649+ emit_status(status, "creating instance disk...");
650+ tracing::info!("creating instance disk...");
610651 tracing::info!(path = %template_raw.display(), "creating instance disk");
611− std::fs::create_dir_all(instance_raw.parent().unwrap())?;
652+ fs::create_dir_all(instance_raw.parent().unwrap())?;
612653 if target_size == template_size {
613654 fs::copy(template_raw, instance_raw)?;
614655 return Ok(needs_resize);
615656 }
616657
617− let mut dst = std::fs::File::create(instance_raw)?;
618− dst.set_len(target_size)?;
619− let mut src = std::fs::File::open(template_raw)?;
658+ let mut dst = fs::File::create(instance_raw)?;
659+ dst.set_len(target_size.as_u64())?;
660+ let mut src = fs::File::open(template_raw)?;
620661 std::io::copy(&mut src, &mut dst)?;
621662 Ok(needs_resize)
622663}
@@ -634,18 +675,17 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
634675 (read_stream.into(), write_stream.into())
635676}
636677
637−pub fn spawn_vm_io_with_hooks<F, G>(
678+pub fn spawn_vm_io_with_hooks<
679+ F: FnMut(&str) -> bool + std::marker::Send + 'static,
680+ G: FnMut(&[u8]) + std::marker::Send + 'static,
681+>(
638682 output_monitor: Arc<OutputMonitor>,
639683 vm_output_fd: OwnedFd,
640684 vm_input_fd: OwnedFd,
641685 io_control: Arc<IoControl>,
642686 mut on_line: F,
643687 mut on_output: G,
644−) -> IoContext
645−where
646− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
647− G: FnMut(&[u8]) + ::std::marker::Send + 'static,
648−{
688+) -> IoContext {
649689 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
650690
651691 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
@@ -793,14 +833,14 @@ where
793833 PollResult::Spurious => continue,
794834 PollResult::Ready(bytes) => {
795835 if io_control.forward_output() {
796− // enable raw mode, if we haven't already
836+ // enable raw mode if we haven't already
797837 if raw_guard.lock().unwrap().is_none()
798838 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
799839 {
800840 *raw_guard.lock().unwrap() = Some(guard);
801841 }
802842
803− let mut stdout = std::io::stdout().lock();
843+ let mut stdout = io::stdout().lock();
804844 if stdout.write_all(bytes).is_err() {
805845 break;
806846 }
@@ -816,7 +856,7 @@ where
816856
817857 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
818858 let mux_thread = thread::spawn(move || {
819− let mut vm_writer = std::fs::File::from(vm_input_fd);
859+ let mut vm_writer = fs::File::from(vm_input_fd);
820860 loop {
821861 match input_rx.recv() {
822862 Ok(VmInput::Bytes(data)) => {
@@ -846,7 +886,7 @@ pub fn spawn_vm_io_with_line_handler<F>(
846886 on_line: F,
847887) -> IoContext
848888where
849− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
889+ F: FnMut(&str) -> bool + std::marker::Send + 'static,
850890{
851891 spawn_vm_io_with_hooks(
852892 output_monitor,
@@ -866,6 +906,36 @@ pub fn spawn_vm_io(
866906 spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
867907}
868908
909+fn spawn_vm_io_with_log(
910+ output_monitor: Arc<OutputMonitor>,
911+ vm_output_fd: OwnedFd,
912+ vm_input_fd: OwnedFd,
913+ log_path: PathBuf,
914+) -> IoContext {
915+ let log_file = fs::OpenOptions::new()
916+ .create(true)
917+ .write(true)
918+ .truncate(true)
919+ .open(&log_path)
920+ .ok()
921+ .map(|file| Arc::new(Mutex::new(file)));
922+
923+ spawn_vm_io_with_hooks(
924+ output_monitor,
925+ vm_output_fd,
926+ vm_input_fd,
927+ IoControl::new(),
928+ |_| false,
929+ move |bytes| {
930+ if let Some(log) = &log_file
931+ && let Ok(mut file) = log.lock()
932+ {
933+ let _ = file.write_all(bytes);
934+ }
935+ },
936+ )
937+}
938+
869939impl IoContext {
870940 pub fn shutdown(self) {
871941 let _ = self.input_tx.send(VmInput::Shutdown);
@@ -883,7 +953,7 @@ fn create_vm_configuration(
883953 vm_writes_to_fd: OwnedFd,
884954 cpu_count: usize,
885955 ram_bytes: u64,
886−) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
956+) -> Result<Retained<VZVirtualMachineConfiguration>> {
887957 unsafe {
888958 let platform =
889959 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
@@ -917,7 +987,7 @@ fn create_vm_configuration(
917987 false,
918988 VZDiskImageCachingMode::Automatic,
919989 VZDiskImageSynchronizationMode::Full,
920− ).unwrap();
990+ )?;
921991
922992 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
923993 VZVirtioBlockDeviceConfiguration::alloc(),
@@ -1004,7 +1074,7 @@ fn create_vm_configuration(
10041074 // Validate
10051075 config.validateWithError().map_err(|e| {
10061076 io::Error::other(format!(
1007− "Invalid VM configuration: {:?}",
1077+ "invalid VM configuration: {:?}",
10081078 e.localizedDescription()
10091079 ))
10101080 })?;
@@ -1013,9 +1083,9 @@ fn create_vm_configuration(
10131083 }
10141084}
10151085
1016−fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
1086+fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>> {
10171087 unsafe {
1018− let temp_dir = std::env::temp_dir();
1088+ let temp_dir = env::temp_dir();
10191089 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
10201090 let url = nsurl_from_path(&temp_path)?;
10211091 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
@@ -1031,8 +1101,8 @@ fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std
10311101fn spawn_login_actions_thread(
10321102 login_actions: Vec<LoginAction>,
10331103 output_monitor: Arc<OutputMonitor>,
1034− input_tx: mpsc::Sender<VmInput>,
1035− vm_output_tx: mpsc::Sender<VmOutput>,
1104+ input_tx: Sender<VmInput>,
1105+ vm_output_tx: Sender<VmOutput>,
10361106) -> thread::JoinHandle<()> {
10371107 thread::spawn(move || {
10381108 for a in login_actions {
@@ -1046,6 +1116,27 @@ fn spawn_login_actions_thread(
10461116 return;
10471117 }
10481118 }
1119+ ExpectEither {
1120+ success,
1121+ failure,
1122+ timeout,
1123+ } => match output_monitor.wait_for_any(&[&success, &failure], timeout) {
1124+ WaitAnyResult::Found(0) => {}
1125+ WaitAnyResult::Found(_) => {
1126+ let _ = vm_output_tx.send(VmOutput::LoginActionFailed {
1127+ action: format!("expect '{}'", success),
1128+ reason: format!("saw failure marker '{}'", failure),
1129+ });
1130+ return;
1131+ }
1132+ WaitAnyResult::Timeout => {
1133+ let _ = vm_output_tx.send(VmOutput::LoginActionTimeout {
1134+ action: format!("expect '{}'", success),
1135+ timeout,
1136+ });
1137+ return;
1138+ }
1139+ },
10491140 Send(mut text) => {
10501141 text.push('\n'); // Type the newline so the command is actually submitted.
10511142 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
@@ -1061,9 +1152,9 @@ fn run_vm_with_io<F>(
10611152 directory_shares: &[DirectoryShare],
10621153 cpu_count: usize,
10631154 ram_bytes: u64,
1064− status: Option<&StatusFile>,
1155+ status: Option<&StatusEmitter<'_>>,
10651156 io_handler: F,
1066−) -> Result<(), Box<dyn std::error::Error>>
1157+) -> Result<()>
10671158where
10681159 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
10691160{
@@ -1110,24 +1201,22 @@ where
11101201
11111202 match rx.try_recv() {
11121203 Ok(result) => {
1113− result.map_err(|e| format!("Failed to start VM: {}", e))?;
1204+ result.map_err(|e| Error::msg(format!("Failed to start VM: {}", e)))?;
11141205 break;
11151206 }
11161207 Err(mpsc::TryRecvError::Empty) => continue,
11171208 Err(mpsc::TryRecvError::Disconnected) => {
1118− return Err("VM start channel disconnected".into());
1209+ bail!("VM start channel disconnected");
11191210 }
11201211 }
11211212 }
11221213
11231214 if Instant::now() >= start_deadline {
1124− return Err("Timed out waiting for VM to start".into());
1215+ bail!("Timed out waiting for VM to start");
11251216 }
11261217
1127− if let Some(status) = status {
1128− status.update("vm booting... go vibecoder!");
1129− status.clear();
1130− }
1218+ emit_status(status, "vm booting... go vibecoder!");
1219+ tracing::info!("vm booting... go vibecoder!");
11311220 tracing::info!("vm booting");
11321221
11331222 let output_monitor = Arc::new(OutputMonitor::default());
@@ -1173,7 +1262,7 @@ where
11731262 );
11741263
11751264 let mut last_state = None;
1176− let mut exit_result = Ok(());
1265+ let mut exit_result: Result<(), String> = Ok(());
11771266 loop {
11781267 unsafe {
11791268 NSRunLoop::mainRunLoop().runMode_beforeDate(
@@ -1192,8 +1281,24 @@ where
11921281 exit_result = Err(format!(
11931282 "Login action ({}) timed out after {:?}; shutting down.",
11941283 action, timeout
1195− )
1196− .into());
1284+ ));
1285+ unsafe {
1286+ if vm.canRequestStop() {
1287+ if let Err(err) = vm.requestStopWithError() {
1288+ tracing::error!(error = ?err, "failed to request VM stop");
1289+ }
1290+ } else if vm.canStop() {
1291+ let handler = RcBlock::new(|_error: *mut NSError| {});
1292+ vm.stopWithCompletionHandler(&handler);
1293+ }
1294+ }
1295+ break;
1296+ }
1297+ Ok(VmOutput::LoginActionFailed { action, reason }) => {
1298+ exit_result = Err(format!(
1299+ "Login action ({}) failed: {}; shutting down.",
1300+ action, reason
1301+ ));
11971302 unsafe {
11981303 if vm.canRequestStop() {
11991304 if let Err(err) = vm.requestStopWithError() {
@@ -1209,7 +1314,7 @@ where
12091314 Err(mpsc::TryRecvError::Empty) => {}
12101315 Err(mpsc::TryRecvError::Disconnected) => {}
12111316 }
1212− if state != objc2_virtualization::VZVirtualMachineState::Running {
1317+ if state != VZVirtualMachineState::Running {
12131318 //eprintln!("VM stopped with state: {:?}", state);
12141319 break;
12151320 }
@@ -1219,7 +1324,7 @@ where
12191324
12201325 io_ctx.shutdown();
12211326
1222− exit_result
1327+ exit_result.map_err(Error::msg)
12231328}
12241329
12251330fn run_vm(
@@ -1228,8 +1333,8 @@ fn run_vm(
12281333 directory_shares: &[DirectoryShare],
12291334 cpu_count: usize,
12301335 ram_bytes: u64,
1231− status: Option<&StatusFile>,
1232−) -> Result<(), Box<dyn std::error::Error>> {
1336+ status: Option<&StatusEmitter<'_>>,
1337+) -> Result<()> {
12331338 run_vm_with_io(
12341339 disk_path,
12351340 login_actions,
@@ -1241,7 +1346,7 @@ fn run_vm(
12411346 )
12421347}
12431348
1244−fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
1349+fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>> {
12451350 let abs_path = if path.is_absolute() {
12461351 path.to_path_buf()
12471352 } else {
@@ -1250,7 +1355,7 @@ fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::E
12501355 let ns_path = NSString::from_str(
12511356 abs_path
12521357 .to_str()
1253− .ok_or("Non-UTF8 path encountered while building NSURL")?,
1358+ .with_context(|| "non-UTF8 path encountered while building NSURL")?,
12541359 );
12551360 Ok(NSURL::fileURLWithPath(&ns_path))
12561361}
@@ -1265,7 +1370,7 @@ fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
12651370 let original = attributes;
12661371
12671372 // Disable translation of carriage return to newline on input
1268− attributes.c_iflag &= !(libc::ICRNL);
1373+ attributes.c_iflag &= !libc::ICRNL;
12691374 // Disable canonical mode (line buffering), echo, and signal generation
12701375 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
12711376 attributes.c_cc[libc::VMIN] = 0;
@@ -1293,10 +1398,10 @@ impl Drop for RawModeGuard {
12931398
12941399// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
12951400pub fn ensure_signed() {
1296− if std::env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
1401+ if env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
12971402 return;
12981403 }
1299− let exe = std::env::current_exe().expect("failed to get current exe path");
1404+ let exe = env::current_exe().expect("failed to get current exe path");
13001405 let exe_str = exe.to_str().expect("exe path not valid utf-8");
13011406
13021407 let has_required_entitlements = {
@@ -1318,8 +1423,8 @@ pub fn ensure_signed() {
13181423 }
13191424
13201425 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1321− let entitlements_path = std::env::temp_dir().join("entitlements.plist");
1322− std::fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1426+ let entitlements_path = env::temp_dir().join("entitlements.plist");
1427+ fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
13231428
13241429 let output = Command::new("codesign")
13251430 .args([
@@ -1332,7 +1437,7 @@ pub fn ensure_signed() {
13321437 ])
13331438 .output();
13341439
1335− let _ = std::fs::remove_file(&entitlements_path);
1440+ let _ = fs::remove_file(&entitlements_path);
13361441
13371442 match output {
13381443 Ok(o) if o.status.success() => {
@@ -1340,7 +1445,7 @@ pub fn ensure_signed() {
13401445 if !stderr.trim().is_empty() {
13411446 tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
13421447 }
1343− let err = Command::new(&exe).args(std::env::args_os().skip(1)).exec();
1448+ let err = Command::new(&exe).args(env::args_os().skip(1)).exec();
13441449 tracing::error!(error = %err, "failed to re-exec after signing");
13451450 std::process::exit(1);
13461451 }