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    fn persisted_exit_code(&self) -> Option<i32> {
484        std::fs::read_to_string(
485            self.home_dir
486                .join("boxes")
487                .join(&self.box_id)
488                .join("upper")
489                .join(".a3s_exit_code"),
490        )
491        .ok()
492        .and_then(|contents| contents.trim().parse::<i32>().ok())
493    }
494
495    /// Poll the owned VM process for natural exit without sending a signal.
496    ///
497    /// This is used by foreground CLI flows where the container command may
498    /// finish on its own and the CLI should clean up instead of waiting for
499    /// a Ctrl-C.
500    pub async fn try_wait_exit(&mut self) -> Result<Option<i32>> {
501        if let Some(code) = self.persisted_exit_code() {
502            self.shim_exit_code = Some(code);
503            return Ok(Some(code));
504        }
505
506        let mut handler = self.handler.write().await;
507        let Some(handler) = handler.as_mut() else {
508            return Ok(self.shim_exit_code);
509        };
510
511        if let Some(code) = handler.try_wait_exit()? {
512            self.shim_exit_code = Some(code);
513            return Ok(Some(code));
514        }
515
516        Ok(None)
517    }
518
519    /// Return true once the foreground container is known to have finished, even
520    /// if the shim exit status has not been reaped yet.
521    pub async fn has_exited(&self) -> bool {
522        if self.shim_exit_code.is_some() || self.persisted_exit_code().is_some() {
523            return true;
524        }
525
526        self.handler
527            .read()
528            .await
529            .as_ref()
530            .map(|handler| handler.has_exited())
531            .unwrap_or(false)
532    }
533
534    /// Run a command as the container MAIN in an IDLE-booted (deferred-main) VM.
535    ///
536    /// Sends the `spawn-main` control frame carrying `spec_json` (the command),
537    /// waits for the main to exit (which halts the VM), and returns its real exit
538    /// code + the box's json-file console logs split by stream. This is the full-
539    /// box-semantics counterpart to [`Self::exec_command`] (whose output is piped
540    /// over the exec stream, not the json-file logs).
541    #[cfg(unix)]
542    pub async fn run_deferred_main(
543        &mut self,
544        spec_json: &[u8],
545        timeout: std::time::Duration,
546    ) -> Result<a3s_box_core::exec::ExecOutput> {
547        let acked = {
548            let client = self
549                .exec_client
550                .as_ref()
551                .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?;
552            client.spawn_main(Some(spec_json)).await?
553        };
554        if !acked {
555            return Err(BoxError::ExecError(
556                "spawn-main was not acknowledged by the guest".to_string(),
557            ));
558        }
559
560        // Wait for the main to exit — guest-init persists the code and halts the VM.
561        let start = std::time::Instant::now();
562        let exit_code = loop {
563            if let Some(code) = self.try_wait_exit().await? {
564                break code;
565            }
566            if start.elapsed() >= timeout {
567                return Err(BoxError::ExecError(
568                    "deferred main did not exit within the timeout".to_string(),
569                ));
570            }
571            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
572        };
573
574        // Let the shim's log processor finish draining console.log into the json
575        // file (it flushes as the VM halts): poll until container.json stops
576        // growing for one interval (bounded at 1s) instead of a fixed sleep —
577        // fast when the drain is already done, safe when it lags.
578        let json_path = self
579            .home_dir
580            .join("boxes")
581            .join(&self.box_id)
582            .join("logs")
583            .join("container.json");
584        let drain_start = std::time::Instant::now();
585        let mut last_len = u64::MAX;
586        loop {
587            let len = std::fs::metadata(&json_path).map(|m| m.len()).unwrap_or(0);
588            if len == last_len || drain_start.elapsed() >= std::time::Duration::from_secs(1) {
589                break;
590            }
591            last_len = len;
592            tokio::time::sleep(std::time::Duration::from_millis(40)).await;
593        }
594        let (stdout, stderr) = self.read_container_logs();
595        Ok(a3s_box_core::exec::ExecOutput {
596            stdout,
597            stderr,
598            exit_code,
599        })
600    }
601
602    /// Read the box's json-file console logs, split into stdout/stderr by stream.
603    fn read_container_logs(&self) -> (Vec<u8>, Vec<u8>) {
604        let path = self
605            .home_dir
606            .join("boxes")
607            .join(&self.box_id)
608            .join("logs")
609            .join("container.json");
610        let (mut out, mut err) = (Vec::new(), Vec::new());
611        if let Ok(content) = std::fs::read_to_string(&path) {
612            for line in content.lines() {
613                if let Ok(entry) = serde_json::from_str::<a3s_box_core::log::LogEntry>(line) {
614                    if entry.stream == "stderr" {
615                        err.extend_from_slice(entry.log.as_bytes());
616                    } else {
617                        out.extend_from_slice(entry.log.as_bytes());
618                    }
619                }
620            }
621        }
622        (out, err)
623    }
624
625    /// Execute a command in the guest VM.
626    ///
627    /// Requires the VM to be in Ready, Busy, or Compacting state.
628    #[cfg(unix)]
629    #[tracing::instrument(skip(self, request), fields(box_id = %self.box_id))]
630    pub async fn exec_request(
631        &self,
632        request: &a3s_box_core::exec::ExecRequest,
633    ) -> Result<a3s_box_core::exec::ExecOutput> {
634        if request.cmd.is_empty() {
635            return Err(BoxError::ExecError(
636                "Exec request requires a non-empty command".to_string(),
637            ));
638        }
639
640        let state = self.state.read().await;
641        match *state {
642            BoxState::Ready | BoxState::Busy | BoxState::Compacting => {}
643            BoxState::Created => {
644                return Err(BoxError::ExecError("VM not yet booted".to_string()));
645            }
646            BoxState::Stopped => {
647                return Err(BoxError::ExecError("VM is stopped".to_string()));
648            }
649        }
650        drop(state);
651
652        let client = self
653            .exec_client
654            .as_ref()
655            .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?;
656
657        let exec_start = std::time::Instant::now();
658        let result = client.exec_command(request).await;
659
660        // Record Prometheus metrics
661        if let Some(ref prom) = self.prom {
662            prom.exec_total.inc();
663            prom.exec_duration
664                .observe(exec_start.elapsed().as_secs_f64());
665            if result.is_err() || result.as_ref().is_ok_and(|o| o.exit_code != 0) {
666                prom.exec_errors_total.inc();
667            }
668        }
669
670        result
671    }
672
673    /// Execute a command in the guest VM.
674    ///
675    /// Requires the VM to be in Ready, Busy, or Compacting state.
676    #[cfg(unix)]
677    #[tracing::instrument(skip(self, cmd), fields(box_id = %self.box_id))]
678    pub async fn exec_command(
679        &self,
680        cmd: Vec<String>,
681        timeout_ns: u64,
682    ) -> Result<a3s_box_core::exec::ExecOutput> {
683        let request = a3s_box_core::exec::ExecRequest {
684            cmd,
685            timeout_ns,
686            env: vec![],
687            working_dir: None,
688            rootfs: None,
689            stdin: None,
690            stdin_streaming: false,
691            user: None,
692            streaming: false,
693        };
694
695        self.exec_request(&request).await
696    }
697
698    /// Boot the VM.
699    pub async fn boot(&mut self) -> Result<()> {
700        let boot_span = tracing::info_span!("vm_boot", box_id = %self.box_id);
701        // Check and transition state: Created → booting
702        {
703            let state = self.state.read().await;
704            if *state != BoxState::Created {
705                return Err(BoxError::StateError("VM already booted".to_string()));
706            }
707        }
708
709        let boot_start = std::time::Instant::now();
710
711        tracing::info!(parent: &boot_span, box_id = %self.box_id, "Booting VM");
712
713        // 1. Prepare filesystem layout
714        let layout = match self
715            .prepare_layout()
716            .instrument(tracing::info_span!(parent: &boot_span, "prepare_layout"))
717            .await
718        {
719            Ok(layout) => layout,
720            Err(error) => {
721                self.cleanup_boot_failure().await;
722                return Err(error);
723            }
724        };
725        self.image_config = layout.oci_config.clone();
726
727        // 1.5. Override /etc/resolv.conf with configured DNS
728        let resolv_content = a3s_box_core::dns::generate_resolv_conf(&self.config.dns);
729        let resolv_path = layout.rootfs_path.join("etc/resolv.conf");
730        if let Err(e) = tokio::fs::write(&resolv_path, &resolv_content).await {
731            self.cleanup_boot_failure().await;
732            return Err(BoxError::IoError(e));
733        }
734        tracing::debug!(parent: &boot_span, dns = %resolv_content.trim(), "Configured guest DNS");
735
736        // 1.6. Apply hostname and static hosts entries before the VM starts.
737        if let Err(e) = self.write_hostname_file(&layout) {
738            self.cleanup_boot_failure().await;
739            return Err(e);
740        }
741        if let Err(e) = self.write_standalone_hosts_file(&layout) {
742            self.cleanup_boot_failure().await;
743            return Err(e);
744        }
745
746        // 2. Build InstanceSpec
747        let mut spec = match self.build_instance_spec(&layout) {
748            Ok(s) => s,
749            Err(e) => {
750                self.cleanup_boot_failure().await;
751                return Err(e);
752            }
753        };
754
755        // 2.5. Configure bridge networking if requested
756        let bridge_network = match &self.config.network {
757            a3s_box_core::NetworkMode::Bridge { network } => Some(network.clone()),
758            _ => None,
759        };
760        if let Some(network_name) = bridge_network {
761            let net_config = match self.setup_bridge_network(&network_name) {
762                Ok(n) => n,
763                Err(e) => {
764                    self.cleanup_boot_failure().await;
765                    return Err(e);
766                }
767            };
768
769            // Write /etc/hosts for DNS service discovery
770            match self.write_hosts_file(&layout, &network_name) {
771                Ok(()) => (),
772                Err(e) => {
773                    self.cleanup_boot_failure().await;
774                    return Err(e);
775                }
776            };
777
778            // Inject network env vars into entrypoint so they are passed via
779            // krun_set_exec's envp (not krun_set_env which overwrites all vars).
780            let ip_cidr = format!("{}/{}", net_config.ip_address, net_config.prefix_len);
781            spec.entrypoint
782                .env
783                .push(("A3S_NET_IP".to_string(), ip_cidr));
784            spec.entrypoint.env.push((
785                "A3S_NET_GATEWAY".to_string(),
786                net_config.gateway.to_string(),
787            ));
788            spec.entrypoint.env.push((
789                "A3S_NET_DNS".to_string(),
790                net_config
791                    .dns_servers
792                    .iter()
793                    .map(|s| s.to_string())
794                    .collect::<Vec<_>>()
795                    .join(","),
796            ));
797
798            spec.network = Some(net_config);
799        }
800
801        // 3. Initialize VMM provider (use injected provider or default to VmController)
802        if self.provider.is_none() {
803            let shim_path = match VmController::find_shim() {
804                Ok(p) => p,
805                Err(e) => {
806                    self.cleanup_boot_failure().await;
807                    return Err(e);
808                }
809            };
810            let controller = match VmController::new(shim_path) {
811                Ok(c) => c,
812                Err(e) => {
813                    self.cleanup_boot_failure().await;
814                    return Err(e);
815                }
816            };
817            self.provider = Some(Box::new(controller));
818        }
819
820        // 4. Start VM via provider
821        let handler = {
822            let provider = self
823                .provider
824                .as_ref()
825                .ok_or_else(|| BoxError::BoxBootError {
826                    message: "VMM provider not initialized".to_string(),
827                    hint: Some("Ensure VmManager has a provider set before boot".to_string()),
828                })?;
829            let vm_start_span = tracing::info_span!(parent: &boot_span, "vm_start");
830            match async { provider.start(&spec).await }
831                .instrument(vm_start_span)
832                .await
833            {
834                Ok(h) => h,
835                Err(e) => {
836                    self.cleanup_boot_failure().await;
837                    return Err(e);
838                }
839            }
840        };
841
842        // Store handler
843        *self.handler.write().await = Some(handler);
844
845        // 5. Wait for guest ready
846        {
847            let wait_span = tracing::info_span!(parent: &boot_span, "wait_for_ready");
848            if let Err(e) = async {
849                self.wait_for_vm_running().await?;
850
851                // 5b. Become ready. A snapshot-restore boot resumes an already-booted
852                // guest whose exec server won't re-signal readiness, so the cold-boot
853                // wait would stall registration on its safety cap — do one best-effort
854                // probe instead. A normal boot waits for the Heartbeat health check.
855                #[cfg(unix)]
856                if is_restore_mode(&self.config) {
857                    self.probe_exec_ready_once(&layout.exec_socket_path).await;
858                } else {
859                    self.wait_for_exec_ready(&layout.exec_socket_path).await?;
860                }
861                Ok::<(), BoxError>(())
862            }
863            .instrument(wait_span)
864            .await
865            {
866                self.cleanup_boot_failure().await;
867                return Err(e);
868            }
869        }
870
871        // Prototype: deferred-main-spawn. The guest booted IDLE (BOX_DEFERRED_MAIN);
872        // now that the exec server is ready, tell it to spawn the container command
873        // (already passed via BOX_EXEC_*) as the MAIN process — full box semantics
874        // (exit code + json-file console logs) without a cold boot.
875        // Auto-trigger spawn-main only for the env-driven `run` path, where the
876        // command is known at boot. The pool sets config.deferred_main to boot the
877        // VM IDLE but drives spawn-main EXPLICITLY per request (the per-request
878        // command isn't known at pre-warm), so a pool VM must NOT auto-trigger here.
879        // A restored guest's main is ALREADY running (captured in the snapshot), so
880        // it must never re-spawn — doing so would start a duplicate main.
881        #[cfg(unix)]
882        if !is_restore_mode(&self.config)
883            && std::env::var("BOX_DEFERRED_MAIN")
884                .map(|v| v == "1")
885                .unwrap_or(false)
886        {
887            if let Some(client) = self.exec_client.as_ref() {
888                match client.spawn_main(None).await {
889                    Ok(true) => tracing::info!("deferred container main spawned"),
890                    Ok(false) => tracing::warn!("deferred spawn-main not acknowledged"),
891                    Err(e) => tracing::warn!(error = %e, "deferred spawn-main failed"),
892                }
893            }
894        }
895
896        // 5b2. Store socket paths for CRI streaming access
897        self.exec_socket_path = Some(layout.exec_socket_path.clone());
898        self.pty_socket_path = Some(layout.pty_socket_path.clone());
899        self.port_forward_socket_path = Some(layout.port_forward_socket_path.clone());
900
901        // 5c. Initialize TEE extension for TEE environments
902        #[cfg(unix)]
903        if !matches!(self.config.tee, TeeConfig::None) {
904            self.tee = Some(Box::new(crate::tee::SnpTeeExtension::new(
905                self.box_id.clone(),
906                layout.attest_socket_path.clone(),
907            )));
908        }
909
910        // 6. Update state to Ready
911        *self.state.write().await = BoxState::Ready;
912
913        // Record Prometheus metrics
914        if let Some(ref prom) = self.prom {
915            let boot_duration = boot_start.elapsed().as_secs_f64();
916            prom.vm_boot_duration.observe(boot_duration);
917            prom.vm_created_total.inc();
918            prom.vm_count.with_label_values(&["ready"]).inc();
919        }
920
921        // Emit ready event
922        self.event_emitter.emit(BoxEvent::empty("box.ready"));
923
924        tracing::info!(parent: &boot_span, box_id = %self.box_id, "VM ready");
925
926        Ok(())
927    }
928
929    /// Destroy the VM with the default shutdown timeout and SIGTERM.
930    pub async fn destroy(&mut self) -> Result<()> {
931        self.destroy_with_options(default_stop_signal(), DEFAULT_SHUTDOWN_TIMEOUT_MS)
932            .await
933    }
934
935    /// Destroy the VM with a custom shutdown timeout and SIGTERM.
936    pub async fn destroy_with_timeout(&mut self, timeout_ms: u64) -> Result<()> {
937        self.destroy_with_options(libc::SIGTERM, timeout_ms).await
938    }
939
940    /// Destroy the VM with a specific stop signal and timeout.
941    ///
942    /// Sends `signal` to the shim process and waits up to `timeout_ms` for it
943    /// to exit gracefully before sending SIGKILL.
944    #[tracing::instrument(skip(self), fields(box_id = %self.box_id))]
945    pub async fn destroy_with_options(&mut self, signal: i32, timeout_ms: u64) -> Result<()> {
946        let mut state = self.state.write().await;
947
948        if *state == BoxState::Stopped {
949            return Ok(());
950        }
951
952        tracing::info!(box_id = %self.box_id, signal, timeout_ms, "Destroying VM");
953
954        // Mark as stopped first — ensures state is correct even if handler.stop() fails.
955        *state = BoxState::Stopped;
956
957        // Stop the VM handler and capture its exit code before it's dropped.
958        // A stop failure must NOT skip the host-resource teardown below (network
959        // backend, overlay unmount, socket + box dirs) — those are already
960        // best-effort and would otherwise leak on every wedged stop. Capture the
961        // error and surface it after teardown instead of returning early.
962        let mut stop_error = None;
963        if let Some(mut handler) = self.handler.write().await.take() {
964            if let Err(e) = handler.stop(signal, timeout_ms) {
965                tracing::error!(box_id = %self.box_id, error = %e, "Failed to stop VM handler; continuing teardown");
966                stop_error = Some(e);
967            }
968            self.shim_exit_code = handler.exit_code();
969        }
970
971        // Stop network backend if running
972        if let Some(ref mut net) = self.net_manager {
973            net.stop();
974        }
975        self.net_manager = None;
976
977        // Cleanup rootfs provider (unmount overlay if applicable)
978        let box_dir = self.home_dir.join("boxes").join(&self.box_id);
979        if let Err(e) = self
980            .rootfs_provider
981            .cleanup(&box_dir, self.config.persistent)
982        {
983            tracing::warn!(
984                box_id = %self.box_id,
985                error = %e,
986                "Failed to cleanup rootfs provider"
987            );
988        }
989
990        let socket_dir = self.socket_dir();
991        if let Err(e) = std::fs::remove_dir_all(&socket_dir) {
992            tracing::debug!(
993                box_id = %self.box_id,
994                path = %socket_dir.display(),
995                error = %e,
996                "Failed to cleanup VM socket directory"
997            );
998        }
999
1000        // Remove the box working directory itself (overlay upper/work, logs,
1001        // leftover metadata) for non-persistent boxes. Without this, ephemeral
1002        // CRI pods leak their `boxes/<id>` directory on every destroy; the
1003        // accumulation slows later RunPodSandbox calls until they time out
1004        // (observed: pod #21 after churning 20). Persistent boxes keep their
1005        // dir intentionally.
1006        if !self.config.persistent {
1007            match std::fs::remove_dir_all(&box_dir) {
1008                Ok(()) => {}
1009                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1010                Err(e) => {
1011                    tracing::warn!(
1012                        box_id = %self.box_id,
1013                        path = %box_dir.display(),
1014                        error = %e,
1015                        "Failed to remove box directory on destroy"
1016                    );
1017                }
1018            }
1019        }
1020
1021        // Record Prometheus metrics
1022        if let Some(ref prom) = self.prom {
1023            prom.vm_destroyed_total.inc();
1024            prom.vm_count.with_label_values(&["ready"]).dec();
1025        }
1026
1027        // Emit stopped event
1028        self.event_emitter.emit(BoxEvent::empty("box.stopped"));
1029
1030        // Host teardown above is complete; surface a handler-stop failure now so
1031        // the caller still learns the stop was imperfect.
1032        match stop_error {
1033            Some(e) => Err(e),
1034            None => Ok(()),
1035        }
1036    }
1037
1038    /// Transition to busy state.
1039    pub async fn set_busy(&self) -> Result<()> {
1040        let mut state = self.state.write().await;
1041
1042        if *state != BoxState::Ready {
1043            return Err(BoxError::StateError("VM not ready".to_string()));
1044        }
1045
1046        *state = BoxState::Busy;
1047        Ok(())
1048    }
1049
1050    /// Transition back to ready state.
1051    pub async fn set_ready(&self) -> Result<()> {
1052        let mut state = self.state.write().await;
1053
1054        if *state != BoxState::Busy && *state != BoxState::Compacting {
1055            return Err(BoxError::StateError("Invalid state transition".to_string()));
1056        }
1057
1058        *state = BoxState::Ready;
1059        Ok(())
1060    }
1061
1062    /// Transition to compacting state.
1063    pub async fn set_compacting(&self) -> Result<()> {
1064        let mut state = self.state.write().await;
1065
1066        if *state != BoxState::Busy {
1067            return Err(BoxError::StateError("VM not busy".to_string()));
1068        }
1069
1070        *state = BoxState::Compacting;
1071        Ok(())
1072    }
1073
1074    /// Pause the VM by sending SIGSTOP to the shim process.
1075    ///
1076    /// The VM must be in Ready, Busy, or Compacting state.
1077    #[cfg(unix)]
1078    pub async fn pause(&self) -> Result<()> {
1079        let state = self.state.read().await;
1080        match *state {
1081            BoxState::Ready | BoxState::Busy | BoxState::Compacting => {}
1082            BoxState::Created => {
1083                return Err(BoxError::StateError("VM not yet booted".to_string()));
1084            }
1085            BoxState::Stopped => {
1086                return Err(BoxError::StateError("VM is stopped".to_string()));
1087            }
1088        }
1089        drop(state);
1090
1091        if let Some(pid) = self.pid().await {
1092            // Safety: sending SIGSTOP to pause the process
1093            let ret = unsafe { libc::kill(pid as i32, libc::SIGSTOP) };
1094            if ret != 0 {
1095                let err = std::io::Error::last_os_error();
1096                return Err(BoxError::ExecError(format!(
1097                    "Failed to send SIGSTOP to pid {}: {}",
1098                    pid, err
1099                )));
1100            }
1101            tracing::info!(box_id = %self.box_id, pid, "VM paused");
1102            Ok(())
1103        } else {
1104            Err(BoxError::StateError(
1105                "VM has no running process".to_string(),
1106            ))
1107        }
1108    }
1109
1110    /// Resume the VM by sending SIGCONT to the shim process.
1111    ///
1112    /// Can be called on a paused VM to resume execution.
1113    #[cfg(unix)]
1114    pub async fn resume(&self) -> Result<()> {
1115        if let Some(pid) = self.pid().await {
1116            // Safety: sending SIGCONT to resume the process
1117            let ret = unsafe { libc::kill(pid as i32, libc::SIGCONT) };
1118            if ret != 0 {
1119                let err = std::io::Error::last_os_error();
1120                return Err(BoxError::ExecError(format!(
1121                    "Failed to send SIGCONT to pid {}: {}",
1122                    pid, err
1123                )));
1124            }
1125            tracing::info!(box_id = %self.box_id, pid, "VM resumed");
1126            Ok(())
1127        } else {
1128            Err(BoxError::StateError(
1129                "VM has no running process".to_string(),
1130            ))
1131        }
1132    }
1133
1134    /// Pause the VM (Windows stub - not yet implemented).
1135    #[cfg(windows)]
1136    pub async fn pause(&self) -> Result<()> {
1137        Err(BoxError::StateError(
1138            "VM pause is not yet supported on Windows".to_string(),
1139        ))
1140    }
1141
1142    /// Resume the VM (Windows stub - not yet implemented).
1143    #[cfg(windows)]
1144    pub async fn resume(&self) -> Result<()> {
1145        Err(BoxError::StateError(
1146            "VM resume is not yet supported on Windows".to_string(),
1147        ))
1148    }
1149
1150    /// Check if VM is healthy.
1151    pub async fn health_check(&self) -> Result<bool> {
1152        let state = self.state.read().await;
1153
1154        match *state {
1155            BoxState::Ready | BoxState::Busy | BoxState::Compacting => {
1156                // Check if handler reports VM is running
1157                if let Some(ref handler) = *self.handler.read().await {
1158                    Ok(handler.is_running())
1159                } else {
1160                    Ok(false)
1161                }
1162            }
1163            _ => Ok(false),
1164        }
1165    }
1166
1167    /// Get VM metrics.
1168    pub async fn metrics(&self) -> Option<crate::vmm::VmMetrics> {
1169        let vm_metrics = self
1170            .handler
1171            .read()
1172            .await
1173            .as_ref()
1174            .map(|handler| handler.metrics())?;
1175
1176        // Update per-VM Prometheus gauges if metrics are attached
1177        if let Some(ref prom) = self.prom {
1178            prom.vm_cpu_percent
1179                .with_label_values(&[&self.box_id])
1180                .set(vm_metrics.cpu_percent.unwrap_or(0.0) as f64);
1181            prom.vm_memory_bytes
1182                .with_label_values(&[&self.box_id])
1183                .set(vm_metrics.memory_bytes.unwrap_or(0) as f64);
1184        }
1185
1186        Some(vm_metrics)
1187    }
1188
1189    /// Get the PID of the VM shim process.
1190    pub async fn pid(&self) -> Option<u32> {
1191        self.handler
1192            .read()
1193            .await
1194            .as_ref()
1195            .map(|handler| handler.pid())
1196    }
1197
1198    /// Get the TEE extension, if TEE is configured and VM is booted.
1199    #[cfg(unix)]
1200    pub fn tee(&self) -> Option<&dyn TeeExtension> {
1201        self.tee.as_deref()
1202    }
1203
1204    /// Get the TEE extension or return an error.
1205    #[cfg(unix)]
1206    pub fn require_tee(&self) -> Result<&dyn TeeExtension> {
1207        self.tee.as_deref().ok_or_else(|| {
1208            BoxError::AttestationError("TEE is not configured for this box".to_string())
1209        })
1210    }
1211
1212    /// Apply a live resource update to the running VM.
1213    ///
1214    /// Tier 1 changes (vCPU count, memory size) are rejected with a clear error
1215    /// because libkrun does not expose a hot-resize API.
1216    ///
1217    /// Tier 2 changes (cgroup-based limits) are applied by executing shell
1218    /// commands inside the guest that write to cgroup v2 control files.
1219    #[cfg(unix)]
1220    pub async fn update_resources(
1221        &self,
1222        update: &crate::resize::ResourceUpdate,
1223    ) -> Result<crate::resize::ResizeResult> {
1224        // Reject Tier 1 changes upfront
1225        crate::resize::validate_update(update)?;
1226
1227        let mut result = crate::resize::ResizeResult {
1228            applied: Vec::new(),
1229            rejected: Vec::new(),
1230        };
1231
1232        if !update.has_tier2_changes() {
1233            return Ok(result);
1234        }
1235
1236        // Build cgroup commands and execute them inside the guest
1237        let commands = update.build_cgroup_commands();
1238        for cmd_str in &commands {
1239            let shell_cmd = vec!["sh".to_string(), "-c".to_string(), cmd_str.clone()];
1240
1241            match self.exec_command(shell_cmd, 5_000_000_000).await {
1242                Ok(output) if output.exit_code == 0 => {
1243                    result.applied.push(cmd_str.clone());
1244                }
1245                Ok(output) => {
1246                    let stderr = String::from_utf8_lossy(&output.stderr);
1247                    let reason = if stderr.trim().is_empty() {
1248                        format!("exit code {}", output.exit_code)
1249                    } else {
1250                        stderr.trim().to_string()
1251                    };
1252                    tracing::warn!(
1253                        box_id = %self.box_id,
1254                        cmd = %cmd_str,
1255                        exit_code = output.exit_code,
1256                        stderr = %stderr,
1257                        "Cgroup update failed inside guest"
1258                    );
1259                    result.rejected.push((cmd_str.clone(), reason));
1260                }
1261                Err(e) => {
1262                    tracing::warn!(
1263                        box_id = %self.box_id,
1264                        cmd = %cmd_str,
1265                        error = %e,
1266                        "Failed to exec cgroup update in guest"
1267                    );
1268                    result.rejected.push((cmd_str.clone(), e.to_string()));
1269                }
1270            }
1271        }
1272
1273        Ok(result)
1274    }
1275}
1276
1277/// Whether this boot is a snapshot-fork restore (the guest is resumed already-booted
1278/// rather than cold-booted). PER-VM: a pool / fork daemon sets `config.restore_from`
1279/// so one process can restore different VMs; the single-VM `run` path uses the
1280/// `KRUN_RESTORE_FROM` env. Either source means restore mode.
1281#[cfg(unix)]
1282fn is_restore_mode(config: &BoxConfig) -> bool {
1283    config
1284        .restore_from
1285        .as_deref()
1286        .is_some_and(|s| !s.is_empty())
1287        || std::env::var("KRUN_RESTORE_FROM")
1288            .map(|v| !v.is_empty())
1289            .unwrap_or(false)
1290}
1291
1292/// Simple FNV-1a hash for generating short deterministic hashes from strings.
1293pub(crate) fn fnv1a_hash(input: &str) -> u64 {
1294    let mut hash: u64 = 0xcbf29ce484222325;
1295    for byte in input.bytes() {
1296        hash ^= byte as u64;
1297        hash = hash.wrapping_mul(0x100000001b3);
1298    }
1299    hash
1300}
1301
1302#[cfg(unix)]
1303fn default_stop_signal() -> i32 {
1304    libc::SIGTERM
1305}
1306
1307#[cfg(windows)]
1308fn default_stop_signal() -> i32 {
1309    15
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314    use super::*;
1315    use a3s_box_core::event::EventEmitter;
1316    use std::sync::{
1317        atomic::{AtomicBool, Ordering},
1318        Arc,
1319    };
1320
1321    struct RecordingHandler {
1322        stopped: Arc<AtomicBool>,
1323    }
1324
1325    impl VmHandler for RecordingHandler {
1326        fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
1327            self.stopped.store(true, Ordering::SeqCst);
1328            Ok(())
1329        }
1330
1331        fn metrics(&self) -> crate::vmm::VmMetrics {
1332            crate::vmm::VmMetrics::default()
1333        }
1334
1335        fn is_running(&self) -> bool {
1336            true
1337        }
1338
1339        fn pid(&self) -> u32 {
1340            42
1341        }
1342    }
1343
1344    struct ExitStateHandler {
1345        exited: bool,
1346    }
1347
1348    impl VmHandler for ExitStateHandler {
1349        fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
1350            Ok(())
1351        }
1352
1353        fn metrics(&self) -> crate::vmm::VmMetrics {
1354            crate::vmm::VmMetrics::default()
1355        }
1356
1357        fn is_running(&self) -> bool {
1358            !self.exited
1359        }
1360
1361        fn has_exited(&self) -> bool {
1362            self.exited
1363        }
1364
1365        fn pid(&self) -> u32 {
1366            42
1367        }
1368    }
1369
1370    /// A handler whose `stop` always fails — models a wedged VM that won't halt.
1371    struct FailingHandler;
1372
1373    impl VmHandler for FailingHandler {
1374        fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
1375            Err(BoxError::StateError("simulated stop failure".to_string()))
1376        }
1377
1378        fn metrics(&self) -> crate::vmm::VmMetrics {
1379            crate::vmm::VmMetrics::default()
1380        }
1381
1382        fn is_running(&self) -> bool {
1383            true
1384        }
1385
1386        fn pid(&self) -> u32 {
1387            42
1388        }
1389    }
1390
1391    // Regression: a handler-stop failure must still run the host teardown
1392    // (overlay unmount, socket + box dirs). Pre-fix, destroy_with_options
1393    // returned early on the stop error and leaked the box directory on every
1394    // wedged stop.
1395    #[tokio::test]
1396    async fn test_destroy_runs_host_teardown_even_when_handler_stop_fails() {
1397        let tmp = tempfile::tempdir().unwrap();
1398        let box_id = "box-stopfail".to_string();
1399        // persistent=false (the default) → the box dir is removed on destroy.
1400        let mut vm =
1401            VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
1402        vm.home_dir = tmp.path().to_path_buf();
1403        *vm.handler.write().await = Some(Box::new(FailingHandler));
1404
1405        let box_dir = tmp.path().join("boxes").join(&box_id);
1406        std::fs::create_dir_all(box_dir.join("logs")).unwrap();
1407
1408        let result = vm.destroy_with_options(default_stop_signal(), 100).await;
1409
1410        // The stop failure is still surfaced to the caller...
1411        assert!(
1412            result.is_err(),
1413            "a handler-stop failure must still be reported"
1414        );
1415        // ...but the host teardown ran anyway: handler taken + box dir removed.
1416        assert!(vm.handler.read().await.is_none());
1417        assert!(
1418            !box_dir.exists(),
1419            "non-persistent box dir must be removed even when the stop failed"
1420        );
1421    }
1422
1423    #[tokio::test]
1424    async fn test_cleanup_boot_failure_stops_handler_and_removes_created_volumes() {
1425        let tmp = tempfile::tempdir().unwrap();
1426        let box_id = "box-test".to_string();
1427        let mut vm =
1428            VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
1429        vm.home_dir = tmp.path().to_path_buf();
1430        vm.anonymous_volumes = vec!["created-volume".to_string(), "reused-volume".to_string()];
1431        vm.created_anonymous_volumes = vec!["created-volume".to_string()];
1432
1433        let stopped = Arc::new(AtomicBool::new(false));
1434        *vm.handler.write().await = Some(Box::new(RecordingHandler {
1435            stopped: stopped.clone(),
1436        }));
1437
1438        let box_dir = tmp.path().join("boxes").join(&box_id);
1439        std::fs::create_dir_all(box_dir.join("logs")).unwrap();
1440
1441        let store = crate::volume::VolumeStore::new(
1442            tmp.path().join("volumes.json"),
1443            tmp.path().join("volumes"),
1444        );
1445        store
1446            .create(a3s_box_core::volume::VolumeConfig::new(
1447                "created-volume",
1448                "",
1449            ))
1450            .unwrap();
1451        store
1452            .create(a3s_box_core::volume::VolumeConfig::new("reused-volume", ""))
1453            .unwrap();
1454
1455        vm.cleanup_boot_failure().await;
1456
1457        assert!(stopped.load(Ordering::SeqCst));
1458        assert!(vm.handler.read().await.is_none());
1459        assert!(vm.created_anonymous_volumes.is_empty());
1460        assert_eq!(vm.anonymous_volumes, vec!["reused-volume".to_string()]);
1461        assert!(store.get("created-volume").unwrap().is_none());
1462        assert!(store.get("reused-volume").unwrap().is_some());
1463        assert!(!box_dir.exists());
1464    }
1465
1466    #[tokio::test]
1467    async fn test_wait_for_vm_running_returns_error_when_handler_exited() {
1468        let vm = VmManager::with_box_id(
1469            BoxConfig::default(),
1470            EventEmitter::new(16),
1471            "box-exited".to_string(),
1472        );
1473        *vm.handler.write().await = Some(Box::new(ExitStateHandler { exited: true }));
1474
1475        let err = vm.wait_for_vm_running().await.unwrap_err();
1476
1477        assert!(err
1478            .to_string()
1479            .contains("VM process exited immediately after start"));
1480    }
1481
1482    #[tokio::test]
1483    async fn test_wait_for_vm_running_succeeds_when_handler_stays_running() {
1484        let config = BoxConfig {
1485            restore_from: Some("snapshot-path".to_string()),
1486            ..BoxConfig::default()
1487        };
1488        let vm = VmManager::with_box_id(config, EventEmitter::new(16), "box-running".to_string());
1489        *vm.handler.write().await = Some(Box::new(ExitStateHandler { exited: false }));
1490
1491        vm.wait_for_vm_running().await.unwrap();
1492    }
1493
1494    #[tokio::test]
1495    async fn test_try_wait_exit_reads_guest_persisted_exit_code() {
1496        let tmp = tempfile::tempdir().unwrap();
1497        let box_id = "box-exit-code".to_string();
1498        let mut vm =
1499            VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
1500        vm.home_dir = tmp.path().to_path_buf();
1501
1502        let exit_path = tmp
1503            .path()
1504            .join("boxes")
1505            .join(&box_id)
1506            .join("upper")
1507            .join(".a3s_exit_code");
1508        std::fs::create_dir_all(exit_path.parent().unwrap()).unwrap();
1509        std::fs::write(&exit_path, "42\n").unwrap();
1510
1511        assert_eq!(vm.try_wait_exit().await.unwrap(), Some(42));
1512        assert_eq!(vm.exit_code(), Some(42));
1513        assert!(vm.has_exited().await);
1514    }
1515
1516    #[cfg(unix)]
1517    #[tokio::test]
1518    async fn test_wait_for_exec_ready_returns_when_handler_already_exited() {
1519        let mut vm = VmManager::with_box_id(
1520            BoxConfig::default(),
1521            EventEmitter::new(16),
1522            "box-exec-exited".to_string(),
1523        );
1524        *vm.handler.write().await = Some(Box::new(ExitStateHandler { exited: true }));
1525        let tmp = tempfile::tempdir().unwrap();
1526
1527        vm.wait_for_exec_ready(&tmp.path().join("missing-exec.sock"))
1528            .await
1529            .unwrap();
1530
1531        assert!(vm.exec_client.is_none());
1532    }
1533
1534    #[cfg(unix)]
1535    #[tokio::test]
1536    async fn test_probe_exec_ready_once_ignores_missing_socket() {
1537        let mut vm = VmManager::with_box_id(
1538            BoxConfig::default(),
1539            EventEmitter::new(16),
1540            "box-probe".to_string(),
1541        );
1542        let tmp = tempfile::tempdir().unwrap();
1543
1544        vm.probe_exec_ready_once(&tmp.path().join("missing-exec.sock"))
1545            .await;
1546
1547        assert!(vm.exec_client.is_none());
1548    }
1549
1550    #[cfg(unix)]
1551    #[tokio::test]
1552    async fn test_attach_running_process_infers_port_forward_socket_path() {
1553        let mut vm = VmManager::with_box_id(
1554            BoxConfig::default(),
1555            EventEmitter::new(16),
1556            "box-test".to_string(),
1557        );
1558        let tmp = tempfile::tempdir().unwrap();
1559        let exec_socket_path = tmp.path().join("exec.sock");
1560        let pty_socket_path = Some(tmp.path().join("pty.sock"));
1561
1562        vm.attach_running_process(
1563            std::process::id(),
1564            exec_socket_path.clone(),
1565            pty_socket_path.clone(),
1566        )
1567        .await
1568        .unwrap();
1569
1570        assert_eq!(vm.exec_socket_path(), Some(exec_socket_path.as_path()));
1571        assert_eq!(vm.pty_socket_path(), pty_socket_path.as_deref());
1572        assert_eq!(
1573            vm.port_forward_socket_path(),
1574            Some(exec_socket_path.with_file_name("portfwd.sock").as_path())
1575        );
1576        assert_eq!(vm.state().await, BoxState::Ready);
1577    }
1578}