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