Skip to main content

a3s_box_runtime/vm/
mod.rs

1//! VM Manager - Lifecycle management for MicroVM instances.
2
3mod layout;
4mod network;
5mod ready;
6pub mod reap;
7mod spec;
8
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12/// Callback type for image pull progress: `(current, total, digest, size_bytes)`.
13type PullProgressFn = Arc<dyn Fn(usize, usize, &str, i64) + Send + Sync>;
14
15use a3s_box_core::config::BoxConfig;
16#[cfg(unix)]
17use a3s_box_core::config::TeeConfig;
18use a3s_box_core::error::{BoxError, Result};
19use a3s_box_core::event::{BoxEvent, EventEmitter};
20use serde::{Deserialize, Serialize};
21use tokio::sync::RwLock;
22use tracing::Instrument;
23
24#[cfg(unix)]
25use libc;
26
27#[cfg(unix)]
28use crate::grpc::ExecClient;
29#[cfg(unix)]
30use crate::tee::TeeExtension;
31use crate::vmm::{VmController, VmHandler, VmmProvider, DEFAULT_SHUTDOWN_TIMEOUT_MS};
32
33/// Box state machine.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35pub enum BoxState {
36    /// Config captured, no VM started
37    Created,
38
39    /// VM booted, container initialized, gRPC healthy
40    Ready,
41
42    /// A session is actively processing a prompt
43    Busy,
44
45    /// A session is compressing its context
46    Compacting,
47
48    /// VM terminated, resources freed
49    Stopped,
50}
51
52/// Layout of directories for a box instance.
53pub(crate) struct BoxLayout {
54    /// Path to the root filesystem
55    pub(crate) rootfs_path: PathBuf,
56    /// Path to the exec Unix socket
57    pub(crate) exec_socket_path: PathBuf,
58    /// Path to the PTY Unix socket
59    pub(crate) pty_socket_path: PathBuf,
60    /// Path to the attestation Unix socket
61    pub(crate) attest_socket_path: PathBuf,
62    /// Path to the CRI port-forward Unix socket
63    pub(crate) port_forward_socket_path: PathBuf,
64    /// Path to the workspace directory
65    pub(crate) workspace_path: PathBuf,
66    /// Path to console output file (optional)
67    pub(crate) console_output: Option<PathBuf>,
68    /// OCI image config (entrypoint, env, working dir, volumes)
69    pub(crate) oci_config: Option<crate::oci::OciImageConfig>,
70    /// TEE instance configuration (if TEE is enabled)
71    pub(crate) tee_instance_config: Option<crate::vmm::TeeInstanceConfig>,
72}
73
74/// VM manager - orchestrates VM lifecycle.
75pub struct VmManager {
76    /// Box configuration
77    pub(crate) config: BoxConfig,
78
79    /// Unique box identifier
80    pub(crate) box_id: String,
81
82    /// Current state
83    pub(crate) state: Arc<RwLock<BoxState>>,
84
85    /// Event emitter
86    pub(crate) event_emitter: EventEmitter,
87
88    /// VMM provider (spawns VMs via pluggable backend)
89    pub(crate) provider: Option<Box<dyn VmmProvider>>,
90
91    /// VM handler (runtime operations on running VM)
92    pub(crate) handler: Arc<RwLock<Option<Box<dyn VmHandler>>>>,
93
94    /// Exec client for executing commands in the guest
95    #[cfg(unix)]
96    pub(crate) exec_client: Option<ExecClient>,
97
98    /// Network backend manager for bridge networking (None if TSI mode).
99    /// Platform-specific: passt on Linux, gvproxy on macOS.
100    pub(crate) net_manager: Option<Box<dyn crate::network::NetworkBackend>>,
101
102    /// A3S home directory (~/.a3s)
103    pub(crate) home_dir: PathBuf,
104
105    /// Anonymous volume names created during boot (from OCI VOLUME directives)
106    pub(crate) anonymous_volumes: Vec<String>,
107
108    /// Anonymous volumes newly created by the current boot attempt.
109    ///
110    /// Reused anonymous volumes must survive failed restarts because they may
111    /// contain data from an existing stopped box.
112    pub(crate) created_anonymous_volumes: Vec<String>,
113
114    /// OCI image config resolved during the last successful boot.
115    pub(crate) image_config: Option<crate::oci::OciImageConfig>,
116
117    /// TEE extension (attestation, sealing, secret injection)
118    #[cfg(unix)]
119    pub(crate) tee: Option<Box<dyn TeeExtension>>,
120
121    /// Rootfs provider (overlay or copy)
122    pub(crate) rootfs_provider: Box<dyn crate::rootfs::RootfsProvider>,
123
124    /// Path to the exec Unix socket (set after boot)
125    pub(crate) exec_socket_path: Option<PathBuf>,
126
127    /// Path to the PTY Unix socket (set after boot)
128    pub(crate) pty_socket_path: Option<PathBuf>,
129
130    /// Path to the CRI port-forward Unix socket (set after boot)
131    pub(crate) port_forward_socket_path: Option<PathBuf>,
132
133    /// Prometheus metrics (optional, for instrumented deployments).
134    pub(crate) prom: Option<crate::prom::RuntimeMetrics>,
135
136    /// Exit code captured from the shim process after it exits.
137    pub(crate) shim_exit_code: Option<i32>,
138
139    /// Optional progress callback for image pulls: `(current, total, digest, size_bytes)`.
140    pub(crate) pull_progress_fn: Option<PullProgressFn>,
141
142    /// Logging driver config, threaded into the InstanceSpec so the shim runs
143    /// the log processor for the box's lifetime (set by the CLI via
144    /// [`VmManager::set_log_config`]).
145    pub(crate) log_config: a3s_box_core::log::LogConfig,
146}
147
148impl VmManager {
149    /// Create a new VM manager.
150    pub fn new(config: BoxConfig, event_emitter: EventEmitter) -> Self {
151        let box_id = uuid::Uuid::new_v4().to_string();
152        let home_dir = a3s_box_core::dirs_home();
153
154        Self {
155            config,
156            box_id,
157            state: Arc::new(RwLock::new(BoxState::Created)),
158            event_emitter,
159            provider: None,
160            handler: Arc::new(RwLock::new(None)),
161            #[cfg(unix)]
162            exec_client: None,
163            net_manager: None,
164            home_dir,
165            anonymous_volumes: Vec::new(),
166            created_anonymous_volumes: Vec::new(),
167            image_config: None,
168            #[cfg(unix)]
169            tee: None,
170            rootfs_provider: crate::rootfs::default_provider(),
171            exec_socket_path: None,
172            pty_socket_path: None,
173            port_forward_socket_path: None,
174            prom: None,
175            shim_exit_code: None,
176            pull_progress_fn: None,
177            log_config: a3s_box_core::log::LogConfig::default(),
178        }
179    }
180
181    /// Create a new VM manager with a specific box ID.
182    pub fn with_box_id(config: BoxConfig, event_emitter: EventEmitter, box_id: String) -> Self {
183        let home_dir = a3s_box_core::dirs_home();
184
185        Self {
186            config,
187            box_id,
188            state: Arc::new(RwLock::new(BoxState::Created)),
189            event_emitter,
190            provider: None,
191            handler: Arc::new(RwLock::new(None)),
192            #[cfg(unix)]
193            exec_client: None,
194            net_manager: None,
195            home_dir,
196            anonymous_volumes: Vec::new(),
197            created_anonymous_volumes: Vec::new(),
198            image_config: None,
199            #[cfg(unix)]
200            tee: None,
201            rootfs_provider: crate::rootfs::default_provider(),
202            exec_socket_path: None,
203            pty_socket_path: None,
204            port_forward_socket_path: None,
205            prom: None,
206            shim_exit_code: None,
207            pull_progress_fn: None,
208            log_config: a3s_box_core::log::LogConfig::default(),
209        }
210    }
211
212    /// Remove host-side boot artifacts after a failed boot attempt.
213    async fn cleanup_boot_failure(&mut self) {
214        if let Some(mut handler) = self.handler.write().await.take() {
215            if let Err(error) = handler.stop(default_stop_signal(), DEFAULT_SHUTDOWN_TIMEOUT_MS) {
216                tracing::warn!(
217                    box_id = %self.box_id,
218                    error = %error,
219                    "Failed to stop VM handler after boot failure"
220                );
221            }
222            self.shim_exit_code = handler.exit_code();
223        }
224
225        if let Some(mut net_manager) = self.net_manager.take() {
226            net_manager.stop();
227        }
228
229        self.cleanup_created_anonymous_volumes();
230        self.cleanup_box_dir();
231    }
232
233    fn cleanup_created_anonymous_volumes(&mut self) {
234        if self.created_anonymous_volumes.is_empty() {
235            return;
236        }
237
238        let created = std::mem::take(&mut self.created_anonymous_volumes);
239        let created_set: std::collections::HashSet<_> = created.iter().cloned().collect();
240        let store = crate::volume::VolumeStore::new(
241            self.home_dir.join("volumes.json"),
242            self.home_dir.join("volumes"),
243        );
244
245        for volume_name in &created {
246            if let Err(error) = store.remove(volume_name, true) {
247                tracing::debug!(
248                    box_id = %self.box_id,
249                    volume = volume_name,
250                    error = %error,
251                    "Failed to remove anonymous volume after boot failure"
252                );
253            }
254        }
255
256        self.anonymous_volumes
257            .retain(|name| !created_set.contains(name));
258    }
259
260    /// Remove the box directory on the host.
261    fn cleanup_box_dir(&self) {
262        let box_dir = self.home_dir.join("boxes").join(&self.box_id);
263
264        // Reap the box's passt daemon (Linux bridge mode) BEFORE removing its
265        // socket dir. A boot that fails after passt spawned but before
266        // `self.net_manager` was assigned leaves `net_manager.stop()` a no-op, so
267        // passt would otherwise survive holding the published port — the
268        // "Address already in use" on the next start. terminate_passt reads
269        // `socket_dir/passt.pid` and is a no-op when there is no passt.
270        #[cfg(target_os = "linux")]
271        crate::network::terminate_passt(&self.socket_dir());
272
273        if let Err(error) = self.rootfs_provider.cleanup(&box_dir, false) {
274            tracing::warn!(
275                box_id = %self.box_id,
276                path = %box_dir.display(),
277                error = %error,
278                "Failed to cleanup rootfs provider after boot failure"
279            );
280        }
281
282        match std::fs::remove_dir_all(&box_dir) {
283            Ok(()) => {}
284            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
285            Err(error) => {
286                tracing::warn!(
287                    box_id = %self.box_id,
288                    path = %box_dir.display(),
289                    error = %error,
290                    "Failed to cleanup box directory after boot failure"
291                );
292            }
293        }
294
295        let socket_dir = self.socket_dir();
296        if socket_dir != box_dir.join("sockets") {
297            match std::fs::remove_dir_all(&socket_dir) {
298                Ok(()) => {}
299                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
300                Err(error) => {
301                    tracing::debug!(
302                        box_id = %self.box_id,
303                        path = %socket_dir.display(),
304                        error = %error,
305                        "Failed to cleanup socket directory after boot failure"
306                    );
307                }
308            }
309        }
310    }
311
312    /// Create a new VM manager with a custom VMM provider.
313    pub fn with_provider(
314        config: BoxConfig,
315        event_emitter: EventEmitter,
316        provider: Box<dyn VmmProvider>,
317    ) -> Self {
318        let box_id = uuid::Uuid::new_v4().to_string();
319        let home_dir = a3s_box_core::dirs_home();
320        Self {
321            config,
322            box_id,
323            state: Arc::new(RwLock::new(BoxState::Created)),
324            event_emitter,
325            provider: Some(provider),
326            handler: Arc::new(RwLock::new(None)),
327            #[cfg(unix)]
328            exec_client: None,
329            net_manager: None,
330            home_dir,
331            anonymous_volumes: Vec::new(),
332            created_anonymous_volumes: Vec::new(),
333            image_config: None,
334            #[cfg(unix)]
335            tee: None,
336            rootfs_provider: crate::rootfs::default_provider(),
337            exec_socket_path: None,
338            pty_socket_path: None,
339            port_forward_socket_path: None,
340            prom: None,
341            shim_exit_code: None,
342            pull_progress_fn: None,
343            log_config: a3s_box_core::log::LogConfig::default(),
344        }
345    }
346
347    /// Get the box ID.
348    pub fn box_id(&self) -> &str {
349        &self.box_id
350    }
351
352    /// Get current state.
353    pub async fn state(&self) -> BoxState {
354        *self.state.read().await
355    }
356
357    /// Get the exec client, if connected.
358    #[cfg(unix)]
359    pub fn exec_client(&self) -> Option<&ExecClient> {
360        self.exec_client.as_ref()
361    }
362
363    /// Attach this manager to an already-running shim process.
364    ///
365    /// This is useful for crash recovery or control-plane restart flows where
366    /// the workload VM is still alive and only the host-side manager state
367    /// needs to be reconstructed.
368    #[cfg(unix)]
369    pub async fn attach_running_process(
370        &mut self,
371        pid: u32,
372        exec_socket_path: PathBuf,
373        pty_socket_path: Option<PathBuf>,
374    ) -> Result<()> {
375        let port_forward_socket_path = exec_socket_path.with_file_name("portfwd.sock");
376        let handler = crate::vmm::ShimHandler::from_pid(pid, self.box_id.clone());
377        if !handler.is_running() {
378            return Err(BoxError::StateError(format!(
379                "Cannot attach to non-running VM process {pid}"
380            )));
381        }
382
383        self.exec_client = match ExecClient::connect(&exec_socket_path).await {
384            Ok(client) => Some(client),
385            Err(error) => {
386                tracing::debug!(
387                    box_id = %self.box_id,
388                    socket_path = %exec_socket_path.display(),
389                    error = %error,
390                    "Failed to reconnect exec client while attaching to running VM"
391                );
392                None
393            }
394        };
395        self.exec_socket_path = Some(exec_socket_path);
396        self.pty_socket_path = pty_socket_path;
397        self.port_forward_socket_path = Some(port_forward_socket_path);
398        *self.handler.write().await = Some(Box::new(handler));
399        *self.state.write().await = BoxState::Ready;
400        Ok(())
401    }
402
403    /// Get the exec socket path, if the VM has been booted.
404    pub fn exec_socket_path(&self) -> Option<&Path> {
405        self.exec_socket_path.as_deref()
406    }
407
408    /// Get the PTY socket path, if the VM has been booted.
409    pub fn pty_socket_path(&self) -> Option<&Path> {
410        self.pty_socket_path.as_deref()
411    }
412
413    /// Get the CRI port-forward socket path, if the VM has been booted.
414    pub fn port_forward_socket_path(&self) -> Option<&Path> {
415        self.port_forward_socket_path.as_deref()
416    }
417
418    /// Inject a custom VMM provider (e.g., a VmController with a known shim path).
419    ///
420    /// If set before `boot()`, the injected provider is used instead of the
421    /// default `VmController::find_shim()` fallback.
422    pub fn set_provider(&mut self, provider: Box<dyn VmmProvider>) {
423        self.provider = Some(provider);
424    }
425
426    /// Override the rootfs provider (overlay or copy).
427    ///
428    /// By default, `default_provider()` auto-detects the best available provider.
429    /// Call this before `boot()` to force a specific provider.
430    pub fn set_rootfs_provider(&mut self, provider: Box<dyn crate::rootfs::RootfsProvider>) {
431        self.rootfs_provider = provider;
432    }
433
434    /// Get the name of the active rootfs provider.
435    pub fn rootfs_provider_name(&self) -> &str {
436        self.rootfs_provider.name()
437    }
438
439    /// Set a progress callback for image pulls: `(current, total, digest, size_bytes)`.
440    /// Called once per layer when `run` pulls an image that is not yet cached.
441    pub fn set_pull_progress_fn(&mut self, f: PullProgressFn) {
442        self.pull_progress_fn = Some(f);
443    }
444
445    /// Attach Prometheus metrics to this VM manager.
446    pub fn set_metrics(&mut self, metrics: crate::prom::RuntimeMetrics) {
447        self.prom = Some(metrics);
448    }
449
450    /// Set the logging driver config. Threaded into the InstanceSpec so the shim
451    /// runs the log processor for the box's lifetime.
452    pub fn set_log_config(&mut self, log_config: a3s_box_core::log::LogConfig) {
453        self.log_config = log_config;
454    }
455
456    /// Get the attached Prometheus metrics (if any).
457    pub fn metrics_prom(&self) -> Option<&crate::prom::RuntimeMetrics> {
458        self.prom.as_ref()
459    }
460
461    /// Get the names of anonymous volumes created during boot.
462    ///
463    /// These are auto-created from OCI VOLUME directives and should be tracked
464    /// for cleanup when the box is removed.
465    pub fn anonymous_volumes(&self) -> &[String] {
466        &self.anonymous_volumes
467    }
468
469    /// Get the OCI image config resolved during boot.
470    pub fn image_config(&self) -> Option<&crate::oci::OciImageConfig> {
471        self.image_config.as_ref()
472    }
473
474    /// Get the exit code of the container, if it has exited.
475    ///
476    /// Returns `Some(code)` after `destroy()` has been called and the shim
477    /// process exited naturally (not killed). Returns `None` if the VM has not
478    /// yet stopped or the exit code could not be determined.
479    pub fn exit_code(&self) -> Option<i32> {
480        self.shim_exit_code
481    }
482
483    /// Poll the owned VM process for natural exit without sending a signal.
484    ///
485    /// This is used by foreground CLI flows where the container command may
486    /// finish on its own and the CLI should clean up instead of waiting for
487    /// a Ctrl-C.
488    pub async fn try_wait_exit(&mut self) -> Result<Option<i32>> {
489        let mut handler = self.handler.write().await;
490        let Some(handler) = handler.as_mut() else {
491            return Ok(self.shim_exit_code);
492        };
493
494        if let Some(code) = handler.try_wait_exit()? {
495            self.shim_exit_code = Some(code);
496            return Ok(Some(code));
497        }
498
499        Ok(None)
500    }
501
502    /// Run a command as the container MAIN in an IDLE-booted (deferred-main) VM.
503    ///
504    /// Sends the `spawn-main` control frame carrying `spec_json` (the command),
505    /// waits for the main to exit (which halts the VM), and returns its real exit
506    /// code + the box's json-file console logs split by stream. This is the full-
507    /// box-semantics counterpart to [`Self::exec_command`] (whose output is piped
508    /// over the exec stream, not the json-file logs).
509    #[cfg(unix)]
510    pub async fn run_deferred_main(
511        &mut self,
512        spec_json: &[u8],
513        timeout: std::time::Duration,
514    ) -> Result<a3s_box_core::exec::ExecOutput> {
515        let acked = {
516            let client = self
517                .exec_client
518                .as_ref()
519                .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?;
520            client.spawn_main(Some(spec_json)).await?
521        };
522        if !acked {
523            return Err(BoxError::ExecError(
524                "spawn-main was not acknowledged by the guest".to_string(),
525            ));
526        }
527
528        // Wait for the main to exit — guest-init persists the code and halts the VM.
529        let start = std::time::Instant::now();
530        let exit_code = loop {
531            if let Some(code) = self.try_wait_exit().await? {
532                break code;
533            }
534            if start.elapsed() >= timeout {
535                return Err(BoxError::ExecError(
536                    "deferred main did not exit within the timeout".to_string(),
537                ));
538            }
539            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
540        };
541
542        // Let the shim's log processor finish draining console.log into the json
543        // file (it flushes as the VM halts): poll until container.json stops
544        // growing for one interval (bounded at 1s) instead of a fixed sleep —
545        // fast when the drain is already done, safe when it lags.
546        let json_path = self
547            .home_dir
548            .join("boxes")
549            .join(&self.box_id)
550            .join("logs")
551            .join("container.json");
552        let drain_start = std::time::Instant::now();
553        let mut last_len = u64::MAX;
554        loop {
555            let len = std::fs::metadata(&json_path).map(|m| m.len()).unwrap_or(0);
556            if len == last_len || drain_start.elapsed() >= std::time::Duration::from_secs(1) {
557                break;
558            }
559            last_len = len;
560            tokio::time::sleep(std::time::Duration::from_millis(40)).await;
561        }
562        let (stdout, stderr) = self.read_container_logs();
563        Ok(a3s_box_core::exec::ExecOutput {
564            stdout,
565            stderr,
566            exit_code,
567        })
568    }
569
570    /// Read the box's json-file console logs, split into stdout/stderr by stream.
571    fn read_container_logs(&self) -> (Vec<u8>, Vec<u8>) {
572        let path = self
573            .home_dir
574            .join("boxes")
575            .join(&self.box_id)
576            .join("logs")
577            .join("container.json");
578        let (mut out, mut err) = (Vec::new(), Vec::new());
579        if let Ok(content) = std::fs::read_to_string(&path) {
580            for line in content.lines() {
581                if let Ok(entry) = serde_json::from_str::<a3s_box_core::log::LogEntry>(line) {
582                    if entry.stream == "stderr" {
583                        err.extend_from_slice(entry.log.as_bytes());
584                    } else {
585                        out.extend_from_slice(entry.log.as_bytes());
586                    }
587                }
588            }
589        }
590        (out, err)
591    }
592
593    /// Execute a command in the guest VM.
594    ///
595    /// Requires the VM to be in Ready, Busy, or Compacting state.
596    #[cfg(unix)]
597    #[tracing::instrument(skip(self, request), fields(box_id = %self.box_id))]
598    pub async fn exec_request(
599        &self,
600        request: &a3s_box_core::exec::ExecRequest,
601    ) -> Result<a3s_box_core::exec::ExecOutput> {
602        if request.cmd.is_empty() {
603            return Err(BoxError::ExecError(
604                "Exec request requires a non-empty command".to_string(),
605            ));
606        }
607
608        let state = self.state.read().await;
609        match *state {
610            BoxState::Ready | BoxState::Busy | BoxState::Compacting => {}
611            BoxState::Created => {
612                return Err(BoxError::ExecError("VM not yet booted".to_string()));
613            }
614            BoxState::Stopped => {
615                return Err(BoxError::ExecError("VM is stopped".to_string()));
616            }
617        }
618        drop(state);
619
620        let client = self
621            .exec_client
622            .as_ref()
623            .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?;
624
625        let exec_start = std::time::Instant::now();
626        let result = client.exec_command(request).await;
627
628        // Record Prometheus metrics
629        if let Some(ref prom) = self.prom {
630            prom.exec_total.inc();
631            prom.exec_duration
632                .observe(exec_start.elapsed().as_secs_f64());
633            if result.is_err() || result.as_ref().is_ok_and(|o| o.exit_code != 0) {
634                prom.exec_errors_total.inc();
635            }
636        }
637
638        result
639    }
640
641    /// Execute a command in the guest VM.
642    ///
643    /// Requires the VM to be in Ready, Busy, or Compacting state.
644    #[cfg(unix)]
645    #[tracing::instrument(skip(self, cmd), fields(box_id = %self.box_id))]
646    pub async fn exec_command(
647        &self,
648        cmd: Vec<String>,
649        timeout_ns: u64,
650    ) -> Result<a3s_box_core::exec::ExecOutput> {
651        let request = a3s_box_core::exec::ExecRequest {
652            cmd,
653            timeout_ns,
654            env: vec![],
655            working_dir: None,
656            rootfs: None,
657            stdin: None,
658            stdin_streaming: false,
659            user: None,
660            streaming: false,
661        };
662
663        self.exec_request(&request).await
664    }
665
666    /// Boot the VM.
667    pub async fn boot(&mut self) -> Result<()> {
668        let boot_span = tracing::info_span!("vm_boot", box_id = %self.box_id);
669        // Check and transition state: Created → booting
670        {
671            let state = self.state.read().await;
672            if *state != BoxState::Created {
673                return Err(BoxError::StateError("VM already booted".to_string()));
674            }
675        }
676
677        let boot_start = std::time::Instant::now();
678
679        tracing::info!(parent: &boot_span, box_id = %self.box_id, "Booting VM");
680
681        // 1. Prepare filesystem layout
682        let layout = match self
683            .prepare_layout()
684            .instrument(tracing::info_span!(parent: &boot_span, "prepare_layout"))
685            .await
686        {
687            Ok(layout) => layout,
688            Err(error) => {
689                self.cleanup_boot_failure().await;
690                return Err(error);
691            }
692        };
693        self.image_config = layout.oci_config.clone();
694
695        // 1.5. Override /etc/resolv.conf with configured DNS
696        let resolv_content = a3s_box_core::dns::generate_resolv_conf(&self.config.dns);
697        let resolv_path = layout.rootfs_path.join("etc/resolv.conf");
698        if let Err(e) = tokio::fs::write(&resolv_path, &resolv_content).await {
699            self.cleanup_boot_failure().await;
700            return Err(BoxError::IoError(e));
701        }
702        tracing::debug!(parent: &boot_span, dns = %resolv_content.trim(), "Configured guest DNS");
703
704        // 1.6. Apply hostname and static hosts entries before the VM starts.
705        if let Err(e) = self.write_hostname_file(&layout) {
706            self.cleanup_boot_failure().await;
707            return Err(e);
708        }
709        if let Err(e) = self.write_standalone_hosts_file(&layout) {
710            self.cleanup_boot_failure().await;
711            return Err(e);
712        }
713
714        // 2. Build InstanceSpec
715        let mut spec = match self.build_instance_spec(&layout) {
716            Ok(s) => s,
717            Err(e) => {
718                self.cleanup_boot_failure().await;
719                return Err(e);
720            }
721        };
722
723        // 2.5. Configure bridge networking if requested
724        let bridge_network = match &self.config.network {
725            a3s_box_core::NetworkMode::Bridge { network } => Some(network.clone()),
726            _ => None,
727        };
728        if let Some(network_name) = bridge_network {
729            let net_config = match self.setup_bridge_network(&network_name) {
730                Ok(n) => n,
731                Err(e) => {
732                    self.cleanup_boot_failure().await;
733                    return Err(e);
734                }
735            };
736
737            // Write /etc/hosts for DNS service discovery
738            match self.write_hosts_file(&layout, &network_name) {
739                Ok(()) => (),
740                Err(e) => {
741                    self.cleanup_boot_failure().await;
742                    return Err(e);
743                }
744            };
745
746            // Inject network env vars into entrypoint so they are passed via
747            // krun_set_exec's envp (not krun_set_env which overwrites all vars).
748            let ip_cidr = format!("{}/{}", net_config.ip_address, net_config.prefix_len);
749            spec.entrypoint
750                .env
751                .push(("A3S_NET_IP".to_string(), ip_cidr));
752            spec.entrypoint.env.push((
753                "A3S_NET_GATEWAY".to_string(),
754                net_config.gateway.to_string(),
755            ));
756            spec.entrypoint.env.push((
757                "A3S_NET_DNS".to_string(),
758                net_config
759                    .dns_servers
760                    .iter()
761                    .map(|s| s.to_string())
762                    .collect::<Vec<_>>()
763                    .join(","),
764            ));
765
766            spec.network = Some(net_config);
767        }
768
769        // 3. Initialize VMM provider (use injected provider or default to VmController)
770        if self.provider.is_none() {
771            let shim_path = match VmController::find_shim() {
772                Ok(p) => p,
773                Err(e) => {
774                    self.cleanup_boot_failure().await;
775                    return Err(e);
776                }
777            };
778            let controller = match VmController::new(shim_path) {
779                Ok(c) => c,
780                Err(e) => {
781                    self.cleanup_boot_failure().await;
782                    return Err(e);
783                }
784            };
785            self.provider = Some(Box::new(controller));
786        }
787
788        // 4. Start VM via provider
789        let handler = {
790            let provider = self
791                .provider
792                .as_ref()
793                .ok_or_else(|| BoxError::BoxBootError {
794                    message: "VMM provider not initialized".to_string(),
795                    hint: Some("Ensure VmManager has a provider set before boot".to_string()),
796                })?;
797            let vm_start_span = tracing::info_span!(parent: &boot_span, "vm_start");
798            match async { provider.start(&spec).await }
799                .instrument(vm_start_span)
800                .await
801            {
802                Ok(h) => h,
803                Err(e) => {
804                    self.cleanup_boot_failure().await;
805                    return Err(e);
806                }
807            }
808        };
809
810        // Store handler
811        *self.handler.write().await = Some(handler);
812
813        // 5. Wait for guest ready
814        {
815            let wait_span = tracing::info_span!(parent: &boot_span, "wait_for_ready");
816            if let Err(e) = async {
817                self.wait_for_vm_running().await?;
818
819                // 5b. Become ready. A snapshot-restore boot resumes an already-booted
820                // guest whose exec server won't re-signal readiness, so the cold-boot
821                // wait would stall registration on its safety cap — do one best-effort
822                // probe instead. A normal boot waits for the Heartbeat health check.
823                #[cfg(unix)]
824                if is_restore_mode(&self.config) {
825                    self.probe_exec_ready_once(&layout.exec_socket_path).await;
826                } else {
827                    self.wait_for_exec_ready(&layout.exec_socket_path).await?;
828                }
829                Ok::<(), BoxError>(())
830            }
831            .instrument(wait_span)
832            .await
833            {
834                self.cleanup_boot_failure().await;
835                return Err(e);
836            }
837        }
838
839        // Prototype: deferred-main-spawn. The guest booted IDLE (BOX_DEFERRED_MAIN);
840        // now that the exec server is ready, tell it to spawn the container command
841        // (already passed via BOX_EXEC_*) as the MAIN process — full box semantics
842        // (exit code + json-file console logs) without a cold boot.
843        // Auto-trigger spawn-main only for the env-driven `run` path, where the
844        // command is known at boot. The pool sets config.deferred_main to boot the
845        // VM IDLE but drives spawn-main EXPLICITLY per request (the per-request
846        // command isn't known at pre-warm), so a pool VM must NOT auto-trigger here.
847        // A restored guest's main is ALREADY running (captured in the snapshot), so
848        // it must never re-spawn — doing so would start a duplicate main.
849        #[cfg(unix)]
850        if !is_restore_mode(&self.config)
851            && std::env::var("BOX_DEFERRED_MAIN")
852                .map(|v| v == "1")
853                .unwrap_or(false)
854        {
855            if let Some(client) = self.exec_client.as_ref() {
856                match client.spawn_main(None).await {
857                    Ok(true) => tracing::info!("deferred container main spawned"),
858                    Ok(false) => tracing::warn!("deferred spawn-main not acknowledged"),
859                    Err(e) => tracing::warn!(error = %e, "deferred spawn-main failed"),
860                }
861            }
862        }
863
864        // 5b2. Store socket paths for CRI streaming access
865        self.exec_socket_path = Some(layout.exec_socket_path.clone());
866        self.pty_socket_path = Some(layout.pty_socket_path.clone());
867        self.port_forward_socket_path = Some(layout.port_forward_socket_path.clone());
868
869        // 5c. Initialize TEE extension for TEE environments
870        #[cfg(unix)]
871        if !matches!(self.config.tee, TeeConfig::None) {
872            self.tee = Some(Box::new(crate::tee::SnpTeeExtension::new(
873                self.box_id.clone(),
874                layout.attest_socket_path.clone(),
875            )));
876        }
877
878        // 6. Update state to Ready
879        *self.state.write().await = BoxState::Ready;
880
881        // Record Prometheus metrics
882        if let Some(ref prom) = self.prom {
883            let boot_duration = boot_start.elapsed().as_secs_f64();
884            prom.vm_boot_duration.observe(boot_duration);
885            prom.vm_created_total.inc();
886            prom.vm_count.with_label_values(&["ready"]).inc();
887        }
888
889        // Emit ready event
890        self.event_emitter.emit(BoxEvent::empty("box.ready"));
891
892        tracing::info!(parent: &boot_span, box_id = %self.box_id, "VM ready");
893
894        Ok(())
895    }
896
897    /// Destroy the VM with the default shutdown timeout and SIGTERM.
898    pub async fn destroy(&mut self) -> Result<()> {
899        self.destroy_with_options(default_stop_signal(), DEFAULT_SHUTDOWN_TIMEOUT_MS)
900            .await
901    }
902
903    /// Destroy the VM with a custom shutdown timeout and SIGTERM.
904    pub async fn destroy_with_timeout(&mut self, timeout_ms: u64) -> Result<()> {
905        self.destroy_with_options(libc::SIGTERM, timeout_ms).await
906    }
907
908    /// Destroy the VM with a specific stop signal and timeout.
909    ///
910    /// Sends `signal` to the shim process and waits up to `timeout_ms` for it
911    /// to exit gracefully before sending SIGKILL.
912    #[tracing::instrument(skip(self), fields(box_id = %self.box_id))]
913    pub async fn destroy_with_options(&mut self, signal: i32, timeout_ms: u64) -> Result<()> {
914        let mut state = self.state.write().await;
915
916        if *state == BoxState::Stopped {
917            return Ok(());
918        }
919
920        tracing::info!(box_id = %self.box_id, signal, timeout_ms, "Destroying VM");
921
922        // Mark as stopped first — ensures state is correct even if handler.stop() fails.
923        *state = BoxState::Stopped;
924
925        // Stop the VM handler and capture its exit code before it's dropped.
926        // A stop failure must NOT skip the host-resource teardown below (network
927        // backend, overlay unmount, socket + box dirs) — those are already
928        // best-effort and would otherwise leak on every wedged stop. Capture the
929        // error and surface it after teardown instead of returning early.
930        let mut stop_error = None;
931        if let Some(mut handler) = self.handler.write().await.take() {
932            if let Err(e) = handler.stop(signal, timeout_ms) {
933                tracing::error!(box_id = %self.box_id, error = %e, "Failed to stop VM handler; continuing teardown");
934                stop_error = Some(e);
935            }
936            self.shim_exit_code = handler.exit_code();
937        }
938
939        // Stop network backend if running
940        if let Some(ref mut net) = self.net_manager {
941            net.stop();
942        }
943        self.net_manager = None;
944
945        // Cleanup rootfs provider (unmount overlay if applicable)
946        let box_dir = self.home_dir.join("boxes").join(&self.box_id);
947        if let Err(e) = self
948            .rootfs_provider
949            .cleanup(&box_dir, self.config.persistent)
950        {
951            tracing::warn!(
952                box_id = %self.box_id,
953                error = %e,
954                "Failed to cleanup rootfs provider"
955            );
956        }
957
958        let socket_dir = self.socket_dir();
959        if let Err(e) = std::fs::remove_dir_all(&socket_dir) {
960            tracing::debug!(
961                box_id = %self.box_id,
962                path = %socket_dir.display(),
963                error = %e,
964                "Failed to cleanup VM socket directory"
965            );
966        }
967
968        // Remove the box working directory itself (overlay upper/work, logs,
969        // leftover metadata) for non-persistent boxes. Without this, ephemeral
970        // CRI pods leak their `boxes/<id>` directory on every destroy; the
971        // accumulation slows later RunPodSandbox calls until they time out
972        // (observed: pod #21 after churning 20). Persistent boxes keep their
973        // dir intentionally.
974        if !self.config.persistent {
975            match std::fs::remove_dir_all(&box_dir) {
976                Ok(()) => {}
977                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
978                Err(e) => {
979                    tracing::warn!(
980                        box_id = %self.box_id,
981                        path = %box_dir.display(),
982                        error = %e,
983                        "Failed to remove box directory on destroy"
984                    );
985                }
986            }
987        }
988
989        // Record Prometheus metrics
990        if let Some(ref prom) = self.prom {
991            prom.vm_destroyed_total.inc();
992            prom.vm_count.with_label_values(&["ready"]).dec();
993        }
994
995        // Emit stopped event
996        self.event_emitter.emit(BoxEvent::empty("box.stopped"));
997
998        // Host teardown above is complete; surface a handler-stop failure now so
999        // the caller still learns the stop was imperfect.
1000        match stop_error {
1001            Some(e) => Err(e),
1002            None => Ok(()),
1003        }
1004    }
1005
1006    /// Transition to busy state.
1007    pub async fn set_busy(&self) -> Result<()> {
1008        let mut state = self.state.write().await;
1009
1010        if *state != BoxState::Ready {
1011            return Err(BoxError::StateError("VM not ready".to_string()));
1012        }
1013
1014        *state = BoxState::Busy;
1015        Ok(())
1016    }
1017
1018    /// Transition back to ready state.
1019    pub async fn set_ready(&self) -> Result<()> {
1020        let mut state = self.state.write().await;
1021
1022        if *state != BoxState::Busy && *state != BoxState::Compacting {
1023            return Err(BoxError::StateError("Invalid state transition".to_string()));
1024        }
1025
1026        *state = BoxState::Ready;
1027        Ok(())
1028    }
1029
1030    /// Transition to compacting state.
1031    pub async fn set_compacting(&self) -> Result<()> {
1032        let mut state = self.state.write().await;
1033
1034        if *state != BoxState::Busy {
1035            return Err(BoxError::StateError("VM not busy".to_string()));
1036        }
1037
1038        *state = BoxState::Compacting;
1039        Ok(())
1040    }
1041
1042    /// Pause the VM by sending SIGSTOP to the shim process.
1043    ///
1044    /// The VM must be in Ready, Busy, or Compacting state.
1045    #[cfg(unix)]
1046    pub async fn pause(&self) -> Result<()> {
1047        let state = self.state.read().await;
1048        match *state {
1049            BoxState::Ready | BoxState::Busy | BoxState::Compacting => {}
1050            BoxState::Created => {
1051                return Err(BoxError::StateError("VM not yet booted".to_string()));
1052            }
1053            BoxState::Stopped => {
1054                return Err(BoxError::StateError("VM is stopped".to_string()));
1055            }
1056        }
1057        drop(state);
1058
1059        if let Some(pid) = self.pid().await {
1060            // Safety: sending SIGSTOP to pause the process
1061            let ret = unsafe { libc::kill(pid as i32, libc::SIGSTOP) };
1062            if ret != 0 {
1063                let err = std::io::Error::last_os_error();
1064                return Err(BoxError::ExecError(format!(
1065                    "Failed to send SIGSTOP to pid {}: {}",
1066                    pid, err
1067                )));
1068            }
1069            tracing::info!(box_id = %self.box_id, pid, "VM paused");
1070            Ok(())
1071        } else {
1072            Err(BoxError::StateError(
1073                "VM has no running process".to_string(),
1074            ))
1075        }
1076    }
1077
1078    /// Resume the VM by sending SIGCONT to the shim process.
1079    ///
1080    /// Can be called on a paused VM to resume execution.
1081    #[cfg(unix)]
1082    pub async fn resume(&self) -> Result<()> {
1083        if let Some(pid) = self.pid().await {
1084            // Safety: sending SIGCONT to resume the process
1085            let ret = unsafe { libc::kill(pid as i32, libc::SIGCONT) };
1086            if ret != 0 {
1087                let err = std::io::Error::last_os_error();
1088                return Err(BoxError::ExecError(format!(
1089                    "Failed to send SIGCONT to pid {}: {}",
1090                    pid, err
1091                )));
1092            }
1093            tracing::info!(box_id = %self.box_id, pid, "VM resumed");
1094            Ok(())
1095        } else {
1096            Err(BoxError::StateError(
1097                "VM has no running process".to_string(),
1098            ))
1099        }
1100    }
1101
1102    /// Pause the VM (Windows stub - not yet implemented).
1103    #[cfg(windows)]
1104    pub async fn pause(&self) -> Result<()> {
1105        Err(BoxError::StateError(
1106            "VM pause is not yet supported on Windows".to_string(),
1107        ))
1108    }
1109
1110    /// Resume the VM (Windows stub - not yet implemented).
1111    #[cfg(windows)]
1112    pub async fn resume(&self) -> Result<()> {
1113        Err(BoxError::StateError(
1114            "VM resume is not yet supported on Windows".to_string(),
1115        ))
1116    }
1117
1118    /// Check if VM is healthy.
1119    pub async fn health_check(&self) -> Result<bool> {
1120        let state = self.state.read().await;
1121
1122        match *state {
1123            BoxState::Ready | BoxState::Busy | BoxState::Compacting => {
1124                // Check if handler reports VM is running
1125                if let Some(ref handler) = *self.handler.read().await {
1126                    Ok(handler.is_running())
1127                } else {
1128                    Ok(false)
1129                }
1130            }
1131            _ => Ok(false),
1132        }
1133    }
1134
1135    /// Get VM metrics.
1136    pub async fn metrics(&self) -> Option<crate::vmm::VmMetrics> {
1137        let vm_metrics = self
1138            .handler
1139            .read()
1140            .await
1141            .as_ref()
1142            .map(|handler| handler.metrics())?;
1143
1144        // Update per-VM Prometheus gauges if metrics are attached
1145        if let Some(ref prom) = self.prom {
1146            prom.vm_cpu_percent
1147                .with_label_values(&[&self.box_id])
1148                .set(vm_metrics.cpu_percent.unwrap_or(0.0) as f64);
1149            prom.vm_memory_bytes
1150                .with_label_values(&[&self.box_id])
1151                .set(vm_metrics.memory_bytes.unwrap_or(0) as f64);
1152        }
1153
1154        Some(vm_metrics)
1155    }
1156
1157    /// Get the PID of the VM shim process.
1158    pub async fn pid(&self) -> Option<u32> {
1159        self.handler
1160            .read()
1161            .await
1162            .as_ref()
1163            .map(|handler| handler.pid())
1164    }
1165
1166    /// Get the TEE extension, if TEE is configured and VM is booted.
1167    #[cfg(unix)]
1168    pub fn tee(&self) -> Option<&dyn TeeExtension> {
1169        self.tee.as_deref()
1170    }
1171
1172    /// Get the TEE extension or return an error.
1173    #[cfg(unix)]
1174    pub fn require_tee(&self) -> Result<&dyn TeeExtension> {
1175        self.tee.as_deref().ok_or_else(|| {
1176            BoxError::AttestationError("TEE is not configured for this box".to_string())
1177        })
1178    }
1179
1180    /// Apply a live resource update to the running VM.
1181    ///
1182    /// Tier 1 changes (vCPU count, memory size) are rejected with a clear error
1183    /// because libkrun does not expose a hot-resize API.
1184    ///
1185    /// Tier 2 changes (cgroup-based limits) are applied by executing shell
1186    /// commands inside the guest that write to cgroup v2 control files.
1187    #[cfg(unix)]
1188    pub async fn update_resources(
1189        &self,
1190        update: &crate::resize::ResourceUpdate,
1191    ) -> Result<crate::resize::ResizeResult> {
1192        // Reject Tier 1 changes upfront
1193        crate::resize::validate_update(update)?;
1194
1195        let mut result = crate::resize::ResizeResult {
1196            applied: Vec::new(),
1197            rejected: Vec::new(),
1198        };
1199
1200        if !update.has_tier2_changes() {
1201            return Ok(result);
1202        }
1203
1204        // Build cgroup commands and execute them inside the guest
1205        let commands = update.build_cgroup_commands();
1206        for cmd_str in &commands {
1207            let shell_cmd = vec!["sh".to_string(), "-c".to_string(), cmd_str.clone()];
1208
1209            match self.exec_command(shell_cmd, 5_000_000_000).await {
1210                Ok(output) if output.exit_code == 0 => {
1211                    result.applied.push(cmd_str.clone());
1212                }
1213                Ok(output) => {
1214                    let stderr = String::from_utf8_lossy(&output.stderr);
1215                    let reason = if stderr.trim().is_empty() {
1216                        format!("exit code {}", output.exit_code)
1217                    } else {
1218                        stderr.trim().to_string()
1219                    };
1220                    tracing::warn!(
1221                        box_id = %self.box_id,
1222                        cmd = %cmd_str,
1223                        exit_code = output.exit_code,
1224                        stderr = %stderr,
1225                        "Cgroup update failed inside guest"
1226                    );
1227                    result.rejected.push((cmd_str.clone(), reason));
1228                }
1229                Err(e) => {
1230                    tracing::warn!(
1231                        box_id = %self.box_id,
1232                        cmd = %cmd_str,
1233                        error = %e,
1234                        "Failed to exec cgroup update in guest"
1235                    );
1236                    result.rejected.push((cmd_str.clone(), e.to_string()));
1237                }
1238            }
1239        }
1240
1241        Ok(result)
1242    }
1243}
1244
1245/// Whether this boot is a snapshot-fork restore (the guest is resumed already-booted
1246/// rather than cold-booted). PER-VM: a pool / fork daemon sets `config.restore_from`
1247/// so one process can restore different VMs; the single-VM `run` path uses the
1248/// `KRUN_RESTORE_FROM` env. Either source means restore mode.
1249#[cfg(unix)]
1250fn is_restore_mode(config: &BoxConfig) -> bool {
1251    config
1252        .restore_from
1253        .as_deref()
1254        .is_some_and(|s| !s.is_empty())
1255        || std::env::var("KRUN_RESTORE_FROM")
1256            .map(|v| !v.is_empty())
1257            .unwrap_or(false)
1258}
1259
1260/// Simple FNV-1a hash for generating short deterministic hashes from strings.
1261pub(crate) fn fnv1a_hash(input: &str) -> u64 {
1262    let mut hash: u64 = 0xcbf29ce484222325;
1263    for byte in input.bytes() {
1264        hash ^= byte as u64;
1265        hash = hash.wrapping_mul(0x100000001b3);
1266    }
1267    hash
1268}
1269
1270#[cfg(unix)]
1271fn default_stop_signal() -> i32 {
1272    libc::SIGTERM
1273}
1274
1275#[cfg(windows)]
1276fn default_stop_signal() -> i32 {
1277    15
1278}
1279
1280#[cfg(test)]
1281mod tests {
1282    use super::*;
1283    use a3s_box_core::event::EventEmitter;
1284    use std::sync::{
1285        atomic::{AtomicBool, Ordering},
1286        Arc,
1287    };
1288
1289    struct RecordingHandler {
1290        stopped: Arc<AtomicBool>,
1291    }
1292
1293    impl VmHandler for RecordingHandler {
1294        fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
1295            self.stopped.store(true, Ordering::SeqCst);
1296            Ok(())
1297        }
1298
1299        fn metrics(&self) -> crate::vmm::VmMetrics {
1300            crate::vmm::VmMetrics::default()
1301        }
1302
1303        fn is_running(&self) -> bool {
1304            true
1305        }
1306
1307        fn pid(&self) -> u32 {
1308            42
1309        }
1310    }
1311
1312    struct ExitStateHandler {
1313        exited: bool,
1314    }
1315
1316    impl VmHandler for ExitStateHandler {
1317        fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
1318            Ok(())
1319        }
1320
1321        fn metrics(&self) -> crate::vmm::VmMetrics {
1322            crate::vmm::VmMetrics::default()
1323        }
1324
1325        fn is_running(&self) -> bool {
1326            !self.exited
1327        }
1328
1329        fn has_exited(&self) -> bool {
1330            self.exited
1331        }
1332
1333        fn pid(&self) -> u32 {
1334            42
1335        }
1336    }
1337
1338    /// A handler whose `stop` always fails — models a wedged VM that won't halt.
1339    struct FailingHandler;
1340
1341    impl VmHandler for FailingHandler {
1342        fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
1343            Err(BoxError::StateError("simulated stop failure".to_string()))
1344        }
1345
1346        fn metrics(&self) -> crate::vmm::VmMetrics {
1347            crate::vmm::VmMetrics::default()
1348        }
1349
1350        fn is_running(&self) -> bool {
1351            true
1352        }
1353
1354        fn pid(&self) -> u32 {
1355            42
1356        }
1357    }
1358
1359    // Regression: a handler-stop failure must still run the host teardown
1360    // (overlay unmount, socket + box dirs). Pre-fix, destroy_with_options
1361    // returned early on the stop error and leaked the box directory on every
1362    // wedged stop.
1363    #[tokio::test]
1364    async fn test_destroy_runs_host_teardown_even_when_handler_stop_fails() {
1365        let tmp = tempfile::tempdir().unwrap();
1366        let box_id = "box-stopfail".to_string();
1367        // persistent=false (the default) → the box dir is removed on destroy.
1368        let mut vm =
1369            VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
1370        vm.home_dir = tmp.path().to_path_buf();
1371        *vm.handler.write().await = Some(Box::new(FailingHandler));
1372
1373        let box_dir = tmp.path().join("boxes").join(&box_id);
1374        std::fs::create_dir_all(box_dir.join("logs")).unwrap();
1375
1376        let result = vm.destroy_with_options(default_stop_signal(), 100).await;
1377
1378        // The stop failure is still surfaced to the caller...
1379        assert!(
1380            result.is_err(),
1381            "a handler-stop failure must still be reported"
1382        );
1383        // ...but the host teardown ran anyway: handler taken + box dir removed.
1384        assert!(vm.handler.read().await.is_none());
1385        assert!(
1386            !box_dir.exists(),
1387            "non-persistent box dir must be removed even when the stop failed"
1388        );
1389    }
1390
1391    #[tokio::test]
1392    async fn test_cleanup_boot_failure_stops_handler_and_removes_created_volumes() {
1393        let tmp = tempfile::tempdir().unwrap();
1394        let box_id = "box-test".to_string();
1395        let mut vm =
1396            VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
1397        vm.home_dir = tmp.path().to_path_buf();
1398        vm.anonymous_volumes = vec!["created-volume".to_string(), "reused-volume".to_string()];
1399        vm.created_anonymous_volumes = vec!["created-volume".to_string()];
1400
1401        let stopped = Arc::new(AtomicBool::new(false));
1402        *vm.handler.write().await = Some(Box::new(RecordingHandler {
1403            stopped: stopped.clone(),
1404        }));
1405
1406        let box_dir = tmp.path().join("boxes").join(&box_id);
1407        std::fs::create_dir_all(box_dir.join("logs")).unwrap();
1408
1409        let store = crate::volume::VolumeStore::new(
1410            tmp.path().join("volumes.json"),
1411            tmp.path().join("volumes"),
1412        );
1413        store
1414            .create(a3s_box_core::volume::VolumeConfig::new(
1415                "created-volume",
1416                "",
1417            ))
1418            .unwrap();
1419        store
1420            .create(a3s_box_core::volume::VolumeConfig::new("reused-volume", ""))
1421            .unwrap();
1422
1423        vm.cleanup_boot_failure().await;
1424
1425        assert!(stopped.load(Ordering::SeqCst));
1426        assert!(vm.handler.read().await.is_none());
1427        assert!(vm.created_anonymous_volumes.is_empty());
1428        assert_eq!(vm.anonymous_volumes, vec!["reused-volume".to_string()]);
1429        assert!(store.get("created-volume").unwrap().is_none());
1430        assert!(store.get("reused-volume").unwrap().is_some());
1431        assert!(!box_dir.exists());
1432    }
1433
1434    #[tokio::test]
1435    async fn test_wait_for_vm_running_returns_error_when_handler_exited() {
1436        let vm = VmManager::with_box_id(
1437            BoxConfig::default(),
1438            EventEmitter::new(16),
1439            "box-exited".to_string(),
1440        );
1441        *vm.handler.write().await = Some(Box::new(ExitStateHandler { exited: true }));
1442
1443        let err = vm.wait_for_vm_running().await.unwrap_err();
1444
1445        assert!(err
1446            .to_string()
1447            .contains("VM process exited immediately after start"));
1448    }
1449
1450    #[tokio::test]
1451    async fn test_wait_for_vm_running_succeeds_when_handler_stays_running() {
1452        let config = BoxConfig {
1453            restore_from: Some("snapshot-path".to_string()),
1454            ..BoxConfig::default()
1455        };
1456        let vm = VmManager::with_box_id(config, EventEmitter::new(16), "box-running".to_string());
1457        *vm.handler.write().await = Some(Box::new(ExitStateHandler { exited: false }));
1458
1459        vm.wait_for_vm_running().await.unwrap();
1460    }
1461
1462    #[cfg(unix)]
1463    #[tokio::test]
1464    async fn test_wait_for_exec_ready_returns_when_handler_already_exited() {
1465        let mut vm = VmManager::with_box_id(
1466            BoxConfig::default(),
1467            EventEmitter::new(16),
1468            "box-exec-exited".to_string(),
1469        );
1470        *vm.handler.write().await = Some(Box::new(ExitStateHandler { exited: true }));
1471        let tmp = tempfile::tempdir().unwrap();
1472
1473        vm.wait_for_exec_ready(&tmp.path().join("missing-exec.sock"))
1474            .await
1475            .unwrap();
1476
1477        assert!(vm.exec_client.is_none());
1478    }
1479
1480    #[cfg(unix)]
1481    #[tokio::test]
1482    async fn test_probe_exec_ready_once_ignores_missing_socket() {
1483        let mut vm = VmManager::with_box_id(
1484            BoxConfig::default(),
1485            EventEmitter::new(16),
1486            "box-probe".to_string(),
1487        );
1488        let tmp = tempfile::tempdir().unwrap();
1489
1490        vm.probe_exec_ready_once(&tmp.path().join("missing-exec.sock"))
1491            .await;
1492
1493        assert!(vm.exec_client.is_none());
1494    }
1495
1496    #[cfg(unix)]
1497    #[tokio::test]
1498    async fn test_attach_running_process_infers_port_forward_socket_path() {
1499        let mut vm = VmManager::with_box_id(
1500            BoxConfig::default(),
1501            EventEmitter::new(16),
1502            "box-test".to_string(),
1503        );
1504        let tmp = tempfile::tempdir().unwrap();
1505        let exec_socket_path = tmp.path().join("exec.sock");
1506        let pty_socket_path = Some(tmp.path().join("pty.sock"));
1507
1508        vm.attach_running_process(
1509            std::process::id(),
1510            exec_socket_path.clone(),
1511            pty_socket_path.clone(),
1512        )
1513        .await
1514        .unwrap();
1515
1516        assert_eq!(vm.exec_socket_path(), Some(exec_socket_path.as_path()));
1517        assert_eq!(vm.pty_socket_path(), pty_socket_path.as_deref());
1518        assert_eq!(
1519            vm.port_forward_socket_path(),
1520            Some(exec_socket_path.with_file_name("portfwd.sock").as_path())
1521        );
1522        assert_eq!(vm.state().await, BoxState::Ready);
1523    }
1524}