@@ -1,5 +1,5 @@
1−use crate::instance::STATUS_FILE_NAME;
2use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
1use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
2+use anyhow::{Context, Error, Result, bail};
5 io::{self, Write},
5 io::{self, Write},
@@ -23,6 +23,7 @@ use std::{
25use block2::RcBlock;
25use block2::RcBlock;
26+use bytesize::ByteSize;
26use dispatch2::DispatchQueue;
27use dispatch2::DispatchQueue;
27use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
28use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
28use objc2_foundation::*;
29use objc2_foundation::*;
@@ -34,68 +35,44 @@ const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
34const SHARED_DIRECTORIES_TAG: &str = "shared";
35const SHARED_DIRECTORIES_TAG: &str = "shared";
35pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
36pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
37−const BYTES_PER_MB: u64 = 1024 * 1024;
38−const DEFAULT_CPU_COUNT: usize = 2;
39−const DEFAULT_RAM_MB: u64 = 2048;
40−const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB;
41const START_TIMEOUT: Duration = Duration::from_secs(60);
38const START_TIMEOUT: Duration = Duration::from_secs(60);
42const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
39const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
40+const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900);
44−struct StatusFile {
42+const ERROR_REPORT_SCRIPT: &str = include_str!("error_report.sh");
50− fn new(path: PathBuf) -> Self {
53− cleared: AtomicBool::new(false),
57− fn update(&self, message: &str) {
58− let _ = fs::write(&self.path, message);
62−impl Drop for StatusFile {
64− if !self.cleared.load(Ordering::SeqCst) {
65− let _ = fs::remove_file(&self.path);
66− self.cleared.store(true, Ordering::SeqCst);
70const PROVISION_SCRIPT: &str = include_str!("provision.sh");
43const PROVISION_SCRIPT: &str = include_str!("provision.sh");
71−const PROVISION_SCRIPT_NAME: &str = "provision.sh";
72const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
44const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
73const DEFAULT_RAW_NAME: &str = "default.raw";
45const DEFAULT_RAW_NAME: &str = "default.raw";
74const INSTANCE_RAW_NAME: &str = "instance.raw";
46const INSTANCE_RAW_NAME: &str = "instance.raw";
75const BASE_DISK_RAW_NAME: &str = "disk.raw";
47const BASE_DISK_RAW_NAME: &str = "disk.raw";
77#[derive(Clone)]
49#[derive(Clone)]
78−pub(crate) enum LoginAction {
50+pub enum LoginAction {
79− Expect { text: String, timeout: Duration },
53+ timeout: Duration,
80 Send(String),
60 Send(String),
62+use crate::config::BoxConfig;
82use LoginAction::*;
63use LoginAction::*;
84#[derive(Clone)]
65#[derive(Clone)]
85−pub(crate) struct DirectoryShare {
66+pub struct DirectoryShare {
86 host: PathBuf,
67 host: PathBuf,
87 guest: PathBuf,
68 guest: PathBuf,
88 read_only: bool,
69 read_only: bool,
91impl DirectoryShare {
72impl DirectoryShare {
94− mut guest: PathBuf,
73+ pub fn new(host: PathBuf, mut guest: PathBuf, read_only: bool) -> Result<Self> {
96− ) -> Result<Self, Box<dyn std::error::Error>> {
97 if !host.exists() {
74 if !host.exists() {
98− return Err(format!("Host path does not exist: {}", host.display()).into());
75+ bail!(format!("host path does not exist: {}", host.display()));
100 if !guest.is_absolute() {
77 if !guest.is_absolute() {
101 guest = PathBuf::from("/root").join(guest);
78 guest = PathBuf::from("/root").join(guest);
@@ -107,10 +84,10 @@ impl DirectoryShare {
110− 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> {
111 let parts: Vec<&str> = spec.split(':').collect();
88 let parts: Vec<&str> = spec.split(':').collect();
112 if parts.len() < 2 || parts.len() > 3 {
89 if parts.len() < 2 || parts.len() > 3 {
113− return Err(format!("Invalid mount spec: {spec}").into());
90+ bail!(format!("invalid mount spec: {spec}"));
115 let host = expand_tilde_path(parts[0]);
92 let host = expand_tilde_path(parts[0]);
116 let guest = PathBuf::from(parts[1]);
93 let guest = PathBuf::from(parts[1]);
@@ -119,11 +96,10 @@ impl DirectoryShare {
119 "read-only" => true,
96 "read-only" => true,
120 "read-write" => false,
97 "read-write" => false,
122− return Err(format!(
99+ bail!(format!(
123 "Invalid mount mode '{}'; expected read-only or read-write",
100 "Invalid mount mode '{}'; expected read-only or read-write",
@@ -167,19 +143,28 @@ pub struct VmArg {
167 pub mounts: Vec<String>,
143 pub mounts: Vec<String>,
146+type StatusEmitter<'a> = dyn Fn(&str) + std::marker::Send + Sync + 'a;
148+fn emit_status(status: Option<&StatusEmitter<'_>>, message: &str) {
149+ if let Some(status) = status {
170−pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
154+pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<()>
172 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
156 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
174− 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)
177−pub(crate) fn run_with_args_and_extras<F>(
161+pub fn run_with_args_and_extras<F>(
178 args: VmArg,
162 args: VmArg,
179 io_handler: F,
163 io_handler: F,
180 extra_login_actions: Vec<LoginAction>,
164 extra_login_actions: Vec<LoginAction>,
181 extra_directory_shares: Vec<DirectoryShare>,
165 extra_directory_shares: Vec<DirectoryShare>,
182−) -> Result<(), Box<dyn std::error::Error>>
166+ status: Option<&StatusEmitter<'_>>,
184 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
169 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
@@ -188,7 +173,7 @@ where
188 let project_root = env::current_dir()?;
173 let project_root = env::current_dir()?;
189 let project_name = project_root
174 let project_name = project_root
190 .file_name()
175 .file_name()
191− .ok_or("Project directory has no name")?
176+ .with_context(|| "Project directory has no name")?
192 .to_string_lossy()
177 .to_string_lossy()
193 .into_owned();
178 .into_owned();
@@ -200,8 +185,10 @@ where
200 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
185 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
202 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
187 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
203− let status_file = StatusFile::new(instance_dir.join(STATUS_FILE_NAME));
188+ fs::create_dir_all(&instance_dir)?;
204− status_file.update("preparing VM image...");
189+ emit_status(status, "preparing VM image...");
190+ tracing::info!("preparing VM image...");
191+ let provision_log = instance_dir.join("provision.log");
206 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
193 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
207 let base_compressed = cache_dir.join(basename_compressed);
194 let base_compressed = cache_dir.join(basename_compressed);
@@ -225,13 +212,14 @@ where
225 &base_compressed,
212 &base_compressed,
226 &default_raw,
213 &default_raw,
227 std::slice::from_ref(&mise_directory_share),
214 std::slice::from_ref(&mise_directory_share),
228− Some(&status_file),
215+ Some(&provision_log),
230 let _ = ensure_instance_disk(
218 let _ = ensure_instance_disk(
231 &instance_raw,
219 &instance_raw,
232 &default_raw,
220 &default_raw,
233− args.disk_bytes,
221+ ByteSize(args.disk_bytes),
234− Some(&status_file),
222+ status,
236 let base_size = fs::metadata(&default_raw)?.len();
224 let base_size = fs::metadata(&default_raw)?.len();
237 let instance_size = fs::metadata(&instance_raw)?.len();
225 let instance_size = fs::metadata(&instance_raw)?.len();
@@ -261,7 +249,7 @@ where
263 if needs_resize {
251 if needs_resize {
264− 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)?;
265 login_actions.push(Send(resize_cmd));
253 login_actions.push(Send(resize_cmd));
@@ -277,25 +265,29 @@ where
277 &directory_shares[..],
265 &directory_shares[..],
278 args.cpu_count,
266 args.cpu_count,
279 args.ram_bytes,
267 args.ram_bytes,
280− Some(&status_file),
268+ status,
281 io_handler,
269 io_handler,
285−pub(crate) fn script_command_from_content(
273+pub fn script_command_from_content(label: &str, script: &str) -> Result<String> {
288−) -> Result<String, Box<dyn std::error::Error>> {
289 let marker = "VIBE_SCRIPT_EOF";
274 let marker = "VIBE_SCRIPT_EOF";
290 let guest_dir = "/tmp/vibe-scripts";
275 let guest_dir = "/tmp/vibe-scripts";
291 let guest_path = format!("{guest_dir}/{label}.sh");
276 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,
281+ let wrapped_script = ERROR_REPORT_SCRIPT
282+ .replace("__LABEL__", label)
283+ .replace("__SCRIPT_BODY__", script_body);
292 let command = format!(
284 let command = format!(
293− "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}"
295 if script.contains(marker) {
287 if script.contains(marker) {
296− return Err(
288+ bail!(format!(
297− format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
289+ "Script '{label}' contains marker '{marker}', cannot safely upload"
300 Ok(command)
292 Ok(command)
@@ -359,6 +351,12 @@ enum WaitResult {
354+#[derive(PartialEq, Eq)]
362pub enum VmInput {
360pub enum VmInput {
363 Bytes(Vec<u8>),
361 Bytes(Vec<u8>),
364 Shutdown,
362 Shutdown,
@@ -366,6 +364,7 @@ pub enum VmInput {
367enum VmOutput {
365enum VmOutput {
368 LoginActionTimeout { action: String, timeout: Duration },
366 LoginActionTimeout { action: String, timeout: Duration },
367+ LoginActionFailed { action: String, reason: String },
371#[derive(Default)]
370#[derive(Default)]
@@ -402,6 +401,41 @@ impl OutputMonitor {
402 WaitResult::Found
401 WaitResult::Found
405+ fn wait_for_any(&self, needles: &[&str], timeout: Duration) -> WaitAnyResult {
406+ let mut found: Option<usize> = None;
407+ let (_unused, timeout_result) = self
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();
420+ if timeout_result.timed_out() {
421+ WaitAnyResult::Timeout
423+ WaitAnyResult::Found(found.unwrap_or(0))
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);
407#[derive(Debug)]
441#[derive(Debug)]
@@ -450,18 +484,16 @@ impl IoControl {
450fn ensure_base_image(
484fn ensure_base_image(
451 base_raw: &Path,
485 base_raw: &Path,
452 base_compressed: &Path,
486 base_compressed: &Path,
453− status: Option<&StatusFile>,
487+ status: Option<&StatusEmitter<'_>>,
454−) -> Result<(), Box<dyn std::error::Error>> {
488+) -> Result<()> {
455 if base_raw.exists() {
489 if base_raw.exists() {
456 return Ok(());
490 return Ok(());
459 if !base_compressed.exists()
493 if !base_compressed.exists()
460− || 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
462− if let Some(status) = status {
463− status.update("downloading base image...");
496+ emit_status(status, "downloading base image...");
465 tracing::info!("downloading base image");
497 tracing::info!("downloading base image");
466 let status = Command::new("curl")
498 let status = Command::new("curl")
@@ -476,15 +508,14 @@ fn ensure_base_image(
477 .status()?;
509 .status()?;
478 if !status.success() {
510 if !status.success() {
479− return Err("Failed to download base image".into());
511+ bail!("failed to download base image");
483 // Check SHA
515 // Check SHA
485− if let Some(status) = status {
517+ emit_status(status, "verifying base image...");
486− status.update("verifying base image...");
518+ tracing::info!("verifying base image...");
488 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
519 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
490 let mut child = Command::new("/usr/bin/shasum")
521 let mut child = Command::new("/usr/bin/shasum")
@@ -502,25 +533,25 @@ fn ensure_base_image(
503 let status = child.wait().expect("failed to wait on child");
534 let status = child.wait().expect("failed to wait on child");
504 if !status.success() {
535 if !status.success() {
505− return Err(format!("SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}").into());
537+ "SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}"
509− if let Some(status) = status {
510− status.update("decompressing base image...");
542+ emit_status(status, "decompressing base image...");
512− tracing::info!("decompressing base image");
543+ tracing::info!("decompressing base image...");
513 let status = Command::new("tar")
544 let status = Command::new("tar")
516 &base_compressed.to_string_lossy(),
547 &base_compressed.to_string_lossy(),
517 BASE_DISK_RAW_NAME,
548 BASE_DISK_RAW_NAME,
519− .stdout(std::fs::File::create(base_raw)?)
550+ .stdout(fs::File::create(base_raw)?)
520 .status()?;
551 .status()?;
522 if !status.success() {
553 if !status.success() {
523− return Err("Failed to decompress base image".into());
554+ bail!("Failed to decompress base image");
@@ -531,29 +562,56 @@ fn ensure_default_image(
531 base_compressed: &Path,
562 base_compressed: &Path,
532 default_raw: &Path,
563 default_raw: &Path,
533 directory_shares: &[DirectoryShare],
564 directory_shares: &[DirectoryShare],
565+ provision_log: Option<&Path>,
534− status: Option<&StatusFile>,
566+ status: Option<&StatusEmitter<'_>>,
535−) -> Result<(), Box<dyn std::error::Error>> {
567+) -> Result<()> {
536 if default_raw.exists() {
568 if default_raw.exists() {
537 return Ok(());
569 return Ok(());
540 ensure_base_image(base_raw, base_compressed, status)?;
572 ensure_base_image(base_raw, base_compressed, status)?;
542− if let Some(status) = status {
543− status.update("configuring base image...");
574+ emit_status(status, "configuring base image...");
545− tracing::info!("configuring base image");
575+ tracing::info!("configuring base image...");
546 fs::copy(base_raw, default_raw)?;
576 fs::copy(base_raw, default_raw)?;
548− let provision_command = script_command_from_content(PROVISION_SCRIPT_NAME, PROVISION_SCRIPT)?;
578+ let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
549− run_vm(
579+ let provision_actions = [
551− &[Send(provision_command)],
580+ Send(provision_command),
582+ success: "VIBEBOX_PROVISION_OK".to_string(),
583+ failure: "VIBEBOX_PROVISION_FAILED".to_string(),
584+ timeout: PROVISION_EXPECT_TIMEOUT,
587+ let provision_result = if let Some(log_path) = provision_log {
588+ let log_path = log_path.to_path_buf();
593+ BoxConfig::default().cpu_count,
594+ BoxConfig::default().ram_size.as_u64(),
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)
552− directory_shares,
604+ directory_shares,
553− DEFAULT_CPU_COUNT,
605+ BoxConfig::default().cpu_count,
554− DEFAULT_RAM_BYTES,
606+ BoxConfig::default().ram_size.as_u64(),
611+ if let Err(err) = provision_result {
612+ let _ = fs::remove_file(default_raw);
@@ -561,18 +619,16 @@ fn ensure_default_image(
561fn ensure_instance_disk(
619fn ensure_instance_disk(
562 instance_raw: &Path,
620 instance_raw: &Path,
563 template_raw: &Path,
621 template_raw: &Path,
564− target_bytes: u64,
622+ target_bytes: ByteSize,
565− status: Option<&StatusFile>,
623+ status: Option<&StatusEmitter<'_>>,
566−) -> Result<bool, Box<dyn std::error::Error>> {
624+) -> Result<bool> {
567 if instance_raw.exists() {
625 if instance_raw.exists() {
568− let current_size = fs::metadata(instance_raw)?.len();
626+ let current_size = ByteSize(fs::metadata(instance_raw)?.len());
569 if current_size != target_bytes {
627 if current_size != target_bytes {
570− let current_gb = current_size as f64 / (1024.0 * 1024.0 * 1024.0);
628+ let current_gb = current_size;
571− let target_gb = target_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
629+ let target_gb = target_bytes;
572 tracing::warn!(
630 tracing::warn!(
573− current_bytes = current_size,
575− "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.",
576 current_gb,
632 current_gb,
577 target_gb
633 target_gb
@@ -580,30 +636,28 @@ fn ensure_instance_disk(
580 return Ok(false);
636 return Ok(false);
583− let template_size = fs::metadata(template_raw)?.len();
639+ let template_size = ByteSize(fs::metadata(template_raw)?.len());
584 if target_bytes < template_size {
640 if target_bytes < template_size {
585− return Err(format!(
641+ bail!(format!(
586− "Requested disk size {} bytes is smaller than base image size {} bytes",
642+ "Requested disk size {} is smaller than base image size {}",
587 target_bytes, template_size
643 target_bytes, template_size
591 let target_size = target_bytes;
646 let target_size = target_bytes;
592 let needs_resize = target_size > template_size;
647 let needs_resize = target_size > template_size;
594− if let Some(status) = status {
649+ emit_status(status, "creating instance disk...");
595− status.update("creating instance disk...");
650+ tracing::info!("creating instance disk...");
597 tracing::info!(path = %template_raw.display(), "creating instance disk");
651 tracing::info!(path = %template_raw.display(), "creating instance disk");
598− std::fs::create_dir_all(instance_raw.parent().unwrap())?;
652+ fs::create_dir_all(instance_raw.parent().unwrap())?;
599 if target_size == template_size {
653 if target_size == template_size {
600 fs::copy(template_raw, instance_raw)?;
654 fs::copy(template_raw, instance_raw)?;
601 return Ok(needs_resize);
655 return Ok(needs_resize);
604− let mut dst = std::fs::File::create(instance_raw)?;
658+ let mut dst = fs::File::create(instance_raw)?;
605− dst.set_len(target_size)?;
659+ dst.set_len(target_size.as_u64())?;
606− let mut src = std::fs::File::open(template_raw)?;
660+ let mut src = fs::File::open(template_raw)?;
607 std::io::copy(&mut src, &mut dst)?;
661 std::io::copy(&mut src, &mut dst)?;
608 Ok(needs_resize)
662 Ok(needs_resize)
@@ -621,18 +675,17 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
621 (read_stream.into(), write_stream.into())
675 (read_stream.into(), write_stream.into())
624−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,
625 output_monitor: Arc<OutputMonitor>,
682 output_monitor: Arc<OutputMonitor>,
626 vm_output_fd: OwnedFd,
683 vm_output_fd: OwnedFd,
627 vm_input_fd: OwnedFd,
684 vm_input_fd: OwnedFd,
628 io_control: Arc<IoControl>,
685 io_control: Arc<IoControl>,
629 mut on_line: F,
686 mut on_line: F,
630 mut on_output: G,
687 mut on_output: G,
631−) -> IoContext
688+) -> IoContext {
633− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
634− G: FnMut(&[u8]) + ::std::marker::Send + 'static,
636 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
689 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
638 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
691 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
@@ -780,14 +833,14 @@ where
780 PollResult::Spurious => continue,
833 PollResult::Spurious => continue,
781 PollResult::Ready(bytes) => {
834 PollResult::Ready(bytes) => {
782 if io_control.forward_output() {
835 if io_control.forward_output() {
783− // enable raw mode, if we haven't already
836+ // enable raw mode if we haven't already
784 if raw_guard.lock().unwrap().is_none()
837 if raw_guard.lock().unwrap().is_none()
785 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
838 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
787 *raw_guard.lock().unwrap() = Some(guard);
840 *raw_guard.lock().unwrap() = Some(guard);
790− let mut stdout = std::io::stdout().lock();
843+ let mut stdout = io::stdout().lock();
791 if stdout.write_all(bytes).is_err() {
844 if stdout.write_all(bytes).is_err() {
@@ -803,7 +856,7 @@ where
804 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
857 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
805 let mux_thread = thread::spawn(move || {
858 let mux_thread = thread::spawn(move || {
806− let mut vm_writer = std::fs::File::from(vm_input_fd);
859+ let mut vm_writer = fs::File::from(vm_input_fd);
808 match input_rx.recv() {
861 match input_rx.recv() {
809 Ok(VmInput::Bytes(data)) => {
862 Ok(VmInput::Bytes(data)) => {
@@ -833,7 +886,7 @@ pub fn spawn_vm_io_with_line_handler<F>(
833 on_line: F,
886 on_line: F,
834) -> IoContext
887) -> IoContext
836− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
889+ F: FnMut(&str) -> bool + std::marker::Send + 'static,
838 spawn_vm_io_with_hooks(
891 spawn_vm_io_with_hooks(
839 output_monitor,
892 output_monitor,
@@ -853,6 +906,36 @@ pub fn spawn_vm_io(
853 spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
906 spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
909+fn spawn_vm_io_with_log(
910+ output_monitor: Arc<OutputMonitor>,
911+ vm_output_fd: OwnedFd,
912+ vm_input_fd: OwnedFd,
915+ let log_file = fs::OpenOptions::new()
921+ .map(|file| Arc::new(Mutex::new(file)));
923+ spawn_vm_io_with_hooks(
930+ if let Some(log) = &log_file
931+ && let Ok(mut file) = log.lock()
933+ let _ = file.write_all(bytes);
856impl IoContext {
939impl IoContext {
857 pub fn shutdown(self) {
940 pub fn shutdown(self) {
858 let _ = self.input_tx.send(VmInput::Shutdown);
941 let _ = self.input_tx.send(VmInput::Shutdown);
@@ -870,7 +953,7 @@ fn create_vm_configuration(
870 vm_writes_to_fd: OwnedFd,
953 vm_writes_to_fd: OwnedFd,
871 cpu_count: usize,
954 cpu_count: usize,
872 ram_bytes: u64,
955 ram_bytes: u64,
873−) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
956+) -> Result<Retained<VZVirtualMachineConfiguration>> {
875 let platform =
958 let platform =
876 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
959 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
@@ -904,7 +987,7 @@ fn create_vm_configuration(
905 VZDiskImageCachingMode::Automatic,
988 VZDiskImageCachingMode::Automatic,
906 VZDiskImageSynchronizationMode::Full,
989 VZDiskImageSynchronizationMode::Full,
909 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
992 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
910 VZVirtioBlockDeviceConfiguration::alloc(),
993 VZVirtioBlockDeviceConfiguration::alloc(),
@@ -991,7 +1074,7 @@ fn create_vm_configuration(
991 // Validate
1074 // Validate
992 config.validateWithError().map_err(|e| {
1075 config.validateWithError().map_err(|e| {
993 io::Error::other(format!(
1076 io::Error::other(format!(
994− "Invalid VM configuration: {:?}",
1077+ "invalid VM configuration: {:?}",
995 e.localizedDescription()
1078 e.localizedDescription()
@@ -1000,9 +1083,9 @@ fn create_vm_configuration(
1003−fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
1086+fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>> {
1004 unsafe {
1087 unsafe {
1005− let temp_dir = std::env::temp_dir();
1088+ let temp_dir = env::temp_dir();
1006 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
1089 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
1007 let url = nsurl_from_path(&temp_path)?;
1090 let url = nsurl_from_path(&temp_path)?;
1008 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
1091 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
@@ -1018,8 +1101,8 @@ fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std
1018fn spawn_login_actions_thread(
1101fn spawn_login_actions_thread(
1019 login_actions: Vec<LoginAction>,
1102 login_actions: Vec<LoginAction>,
1020 output_monitor: Arc<OutputMonitor>,
1103 output_monitor: Arc<OutputMonitor>,
1021− input_tx: mpsc::Sender<VmInput>,
1104+ input_tx: Sender<VmInput>,
1022− vm_output_tx: mpsc::Sender<VmOutput>,
1105+ vm_output_tx: Sender<VmOutput>,
1023) -> thread::JoinHandle<()> {
1106) -> thread::JoinHandle<()> {
1024 thread::spawn(move || {
1107 thread::spawn(move || {
1025 for a in login_actions {
1108 for a in login_actions {
@@ -1033,6 +1116,27 @@ fn spawn_login_actions_thread(
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),
1132+ WaitAnyResult::Timeout => {
1133+ let _ = vm_output_tx.send(VmOutput::LoginActionTimeout {
1134+ action: format!("expect '{}'", success),
1036 Send(mut text) => {
1140 Send(mut text) => {
1037 text.push('\n'); // Type the newline so the command is actually submitted.
1141 text.push('\n'); // Type the newline so the command is actually submitted.
1038 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
1142 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
@@ -1048,9 +1152,9 @@ fn run_vm_with_io<F>(
1048 directory_shares: &[DirectoryShare],
1152 directory_shares: &[DirectoryShare],
1049 cpu_count: usize,
1153 cpu_count: usize,
1050 ram_bytes: u64,
1154 ram_bytes: u64,
1051− status: Option<&StatusFile>,
1155+ status: Option<&StatusEmitter<'_>>,
1052 io_handler: F,
1156 io_handler: F,
1053−) -> Result<(), Box<dyn std::error::Error>>
1157+) -> Result<()>
1055 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
1159 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
@@ -1097,23 +1201,22 @@ where
1098 match rx.try_recv() {
1202 match rx.try_recv() {
1099 Ok(result) => {
1203 Ok(result) => {
1100− result.map_err(|e| format!("Failed to start VM: {}", e))?;
1204+ result.map_err(|e| Error::msg(format!("Failed to start VM: {}", e)))?;
1103 Err(mpsc::TryRecvError::Empty) => continue,
1207 Err(mpsc::TryRecvError::Empty) => continue,
1104 Err(mpsc::TryRecvError::Disconnected) => {
1208 Err(mpsc::TryRecvError::Disconnected) => {
1105− return Err("VM start channel disconnected".into());
1209+ bail!("VM start channel disconnected");
1110 if Instant::now() >= start_deadline {
1214 if Instant::now() >= start_deadline {
1111− return Err("Timed out waiting for VM to start".into());
1215+ bail!("Timed out waiting for VM to start");
1114− if let Some(status) = status {
1218+ emit_status(status, "vm booting... go vibecoder!");
1115− status.update("vm booting... go vibecoder!");
1219+ tracing::info!("vm booting... go vibecoder!");
1117 tracing::info!("vm booting");
1220 tracing::info!("vm booting");
1119 let output_monitor = Arc::new(OutputMonitor::default());
1222 let output_monitor = Arc::new(OutputMonitor::default());
@@ -1159,7 +1262,7 @@ where
1161 let mut last_state = None;
1264 let mut last_state = None;
1162− let mut exit_result = Ok(());
1265+ let mut exit_result: Result<(), String> = Ok(());
1164 unsafe {
1267 unsafe {
1165 NSRunLoop::mainRunLoop().runMode_beforeDate(
1268 NSRunLoop::mainRunLoop().runMode_beforeDate(
@@ -1178,8 +1281,24 @@ where
1178 exit_result = Err(format!(
1281 exit_result = Err(format!(
1179 "Login action ({}) timed out after {:?}; shutting down.",
1282 "Login action ({}) timed out after {:?}; shutting down.",
1180 action, timeout
1283 action, timeout
1286+ if vm.canRequestStop() {
1287+ if let Err(err) = vm.requestStopWithError() {
1288+ tracing::error!(error = ?err, "failed to request VM stop");
1290+ } else if vm.canStop() {
1291+ let handler = RcBlock::new(|_error: *mut NSError| {});
1292+ vm.stopWithCompletionHandler(&handler);
1297+ Ok(VmOutput::LoginActionFailed { action, reason }) => {
1298+ exit_result = Err(format!(
1299+ "Login action ({}) failed: {}; shutting down.",
1183 unsafe {
1302 unsafe {
1184 if vm.canRequestStop() {
1303 if vm.canRequestStop() {
1185 if let Err(err) = vm.requestStopWithError() {
1304 if let Err(err) = vm.requestStopWithError() {
@@ -1195,7 +1314,7 @@ where
1195 Err(mpsc::TryRecvError::Empty) => {}
1314 Err(mpsc::TryRecvError::Empty) => {}
1196 Err(mpsc::TryRecvError::Disconnected) => {}
1315 Err(mpsc::TryRecvError::Disconnected) => {}
1198− if state != objc2_virtualization::VZVirtualMachineState::Running {
1317+ if state != VZVirtualMachineState::Running {
1199 //eprintln!("VM stopped with state: {:?}", state);
1318 //eprintln!("VM stopped with state: {:?}", state);
@@ -1205,7 +1324,7 @@ where
1206 io_ctx.shutdown();
1325 io_ctx.shutdown();
1208− exit_result
1327+ exit_result.map_err(Error::msg)
1211fn run_vm(
1330fn run_vm(
@@ -1214,8 +1333,8 @@ fn run_vm(
1214 directory_shares: &[DirectoryShare],
1333 directory_shares: &[DirectoryShare],
1215 cpu_count: usize,
1334 cpu_count: usize,
1216 ram_bytes: u64,
1335 ram_bytes: u64,
1217− status: Option<&StatusFile>,
1336+ status: Option<&StatusEmitter<'_>>,
1218−) -> Result<(), Box<dyn std::error::Error>> {
1337+) -> Result<()> {
1219 run_vm_with_io(
1338 run_vm_with_io(
1220 disk_path,
1339 disk_path,
1221 login_actions,
1340 login_actions,
@@ -1227,7 +1346,7 @@ fn run_vm(
1230−fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
1349+fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>> {
1231 let abs_path = if path.is_absolute() {
1350 let abs_path = if path.is_absolute() {
1232 path.to_path_buf()
1351 path.to_path_buf()
1233 } else {
1352 } else {
@@ -1236,7 +1355,7 @@ fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::E
1236 let ns_path = NSString::from_str(
1355 let ns_path = NSString::from_str(
1237 abs_path
1356 abs_path
1238 .to_str()
1357 .to_str()
1239− .ok_or("Non-UTF8 path encountered while building NSURL")?,
1358+ .with_context(|| "non-UTF8 path encountered while building NSURL")?,
1241 Ok(NSURL::fileURLWithPath(&ns_path))
1360 Ok(NSURL::fileURLWithPath(&ns_path))
@@ -1251,7 +1370,7 @@ fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
1251 let original = attributes;
1370 let original = attributes;
1253 // Disable translation of carriage return to newline on input
1372 // Disable translation of carriage return to newline on input
1254− attributes.c_iflag &= !(libc::ICRNL);
1373+ attributes.c_iflag &= !libc::ICRNL;
1255 // Disable canonical mode (line buffering), echo, and signal generation
1374 // Disable canonical mode (line buffering), echo, and signal generation
1256 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
1375 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
1257 attributes.c_cc[libc::VMIN] = 0;
1376 attributes.c_cc[libc::VMIN] = 0;
@@ -1279,10 +1398,10 @@ impl Drop for RawModeGuard {
1280// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
1399// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
1281pub fn ensure_signed() {
1400pub fn ensure_signed() {
1282− if std::env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
1401+ if env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
1285− 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");
1286 let exe_str = exe.to_str().expect("exe path not valid utf-8");
1405 let exe_str = exe.to_str().expect("exe path not valid utf-8");
1288 let has_required_entitlements = {
1407 let has_required_entitlements = {
@@ -1304,8 +1423,8 @@ pub fn ensure_signed() {
1306 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1425 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1307− let entitlements_path = std::env::temp_dir().join("entitlements.plist");
1426+ let entitlements_path = env::temp_dir().join("entitlements.plist");
1308− std::fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1427+ fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1310 let output = Command::new("codesign")
1429 let output = Command::new("codesign")
@@ -1318,7 +1437,7 @@ pub fn ensure_signed() {
1319 .output();
1438 .output();
1321− let _ = std::fs::remove_file(&entitlements_path);
1440+ let _ = fs::remove_file(&entitlements_path);
1323 match output {
1442 match output {
1324 Ok(o) if o.status.success() => {
1443 Ok(o) if o.status.success() => {
@@ -1326,7 +1445,7 @@ pub fn ensure_signed() {
1326 if !stderr.trim().is_empty() {
1445 if !stderr.trim().is_empty() {
1327 tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
1446 tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
1329− 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();
1330 tracing::error!(error = %err, "failed to re-exec after signing");
1449 tracing::error!(error = %err, "failed to re-exec after signing");
1331 std::process::exit(1);
1450 std::process::exit(1);