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#[cfg(windows)]
10mod windows_stop;
11
12pub(crate) use layout::{persistent_rootfs_generation_exists, runtime_socket_dir};
13
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17/// Callback type for image pull progress: `(current, total, digest, size_bytes)`.
18pub type PullProgressFn = Arc<dyn Fn(usize, usize, &str, i64) + Send + Sync>;
19
20use a3s_box_core::config::BoxConfig;
21#[cfg(unix)]
22use a3s_box_core::config::TeeConfig;
23use a3s_box_core::error::{BoxError, Result};
24use a3s_box_core::event::{BoxEvent, EventEmitter};
25use a3s_box_core::execution::{ExecutionBackend, ResolvedExecutionPlan};
26use serde::{Deserialize, Serialize};
27use tokio::sync::RwLock;
28use tracing::Instrument;
29
30#[cfg(unix)]
31use libc;
32
33#[cfg(unix)]
34use crate::grpc::ExecClient;
35#[cfg(unix)]
36use crate::tee::TeeExtension;
37use crate::vmm::{VmController, VmHandler, VmmProvider, DEFAULT_SHUTDOWN_TIMEOUT_MS};
38
39/// Box state machine.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41pub enum BoxState {
42    /// Config captured, no VM started
43    Created,
44
45    /// VM booted, container initialized, gRPC healthy
46    Ready,
47
48    /// A session is actively processing a prompt
49    Busy,
50
51    /// A session is compressing its context
52    Compacting,
53
54    /// VM terminated, resources freed
55    Stopped,
56}
57
58/// Layout of directories for a box instance.
59pub(crate) struct BoxLayout {
60    /// Path to the root filesystem
61    pub(crate) rootfs_path: PathBuf,
62    /// Path to the exec Unix socket
63    pub(crate) exec_socket_path: PathBuf,
64    /// Path to the PTY Unix socket
65    pub(crate) pty_socket_path: PathBuf,
66    /// Path to the attestation Unix socket
67    pub(crate) attest_socket_path: PathBuf,
68    /// Path to the CRI port-forward Unix socket
69    pub(crate) port_forward_socket_path: PathBuf,
70    /// Path to the workspace directory
71    pub(crate) workspace_path: PathBuf,
72    /// Path to console output file (optional)
73    pub(crate) console_output: Option<PathBuf>,
74    /// OCI image config (entrypoint, env, working dir, volumes)
75    pub(crate) oci_config: Option<crate::oci::OciImageConfig>,
76    /// Fresh image/cache rootfs generations must ignore any terminal manifest
77    /// baked into an older malicious image. Persistent and Snapshot generations
78    /// instead prefer the terminal manifest captured after guest writes.
79    pub(crate) prefer_image_rootfs_metadata: bool,
80    /// TEE instance configuration (if TEE is enabled)
81    pub(crate) tee_instance_config: Option<crate::vmm::TeeInstanceConfig>,
82}
83
84#[cfg(target_os = "windows")]
85const WINDOWS_GUEST_EXIT_CODE: &str = ".a3s_exit_code";
86#[cfg(target_os = "windows")]
87const WINDOWS_GUEST_STDOUT: &str = "guest-init.stdout.log";
88#[cfg(target_os = "windows")]
89const WINDOWS_GUEST_STDERR: &str = "guest-init.stderr.log";
90#[cfg(target_os = "windows")]
91const WINDOWS_STOP_DELIVERY_TIMEOUT_MS: u64 = 1_000;
92#[cfg(target_os = "windows")]
93const WINDOWS_GUEST_FINALIZATION_TIMEOUT_MS: u64 = 30_000;
94#[cfg(target_os = "windows")]
95const WINDOWS_GUEST_RESULT_MARKER: &str = ".a3s_host_result_collected";
96#[cfg(target_os = "windows")]
97const WINDOWS_LIVE_LOGS_DRAINED_MARKER: &str = ".a3s_host_live_logs_drained";
98
99/// Append a completed Windows guest stream to its raw host console, filtering
100/// libkrun's pre-guest C-init diagnostics while preserving arbitrary bytes.
101#[cfg(target_os = "windows")]
102fn append_windows_guest_stream(
103    source: &Path,
104    destination: &Path,
105    runtime_filter: &a3s_box_core::log::RuntimeConsoleFilter,
106) -> std::io::Result<()> {
107    use std::io::{BufRead, Write};
108
109    let input = match a3s_box_core::windows_file::open_regular_file(source, None) {
110        Ok((input, _)) => input,
111        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
112        Err(error) => return Err(error),
113    };
114    let mut reader = std::io::BufReader::new(input);
115    let mut output = std::fs::OpenOptions::new()
116        .create(true)
117        .append(true)
118        .open(destination)?;
119    let mut line = Vec::new();
120
121    loop {
122        line.clear();
123        if reader.read_until(b'\n', &mut line)? == 0 {
124            break;
125        }
126        let keep = !line.ends_with(b"\n")
127            || std::str::from_utf8(&line).map_or(true, |line| runtime_filter.keep_line(line));
128        if keep {
129            output.write_all(&line)?;
130        }
131    }
132
133    output.flush()
134}
135
136#[cfg(target_os = "windows")]
137fn windows_marker_matches(path: &Path, expected: &[u8]) -> bool {
138    use std::io::Read;
139
140    let Ok((file, _)) = a3s_box_core::windows_file::open_regular_file(path, None) else {
141        return false;
142    };
143    let mut contents = Vec::with_capacity(expected.len().saturating_add(1));
144    if file
145        .take(expected.len().saturating_add(1) as u64)
146        .read_to_end(&mut contents)
147        .is_err()
148    {
149        return false;
150    }
151    contents == expected
152}
153
154/// Collect the completed WHPX guest result after the shim process has exited.
155///
156/// Current shims drain structured logs before exiting. The runtime still owns
157/// raw-console collection and provides a completed-stream fallback for older
158/// libkrun bundles that terminate the shim with `_exit`.
159#[cfg(target_os = "windows")]
160pub fn collect_windows_guest_result(
161    box_dir: &Path,
162    log_config: &a3s_box_core::log::LogConfig,
163    shim_exit_code: i32,
164) -> Result<i32> {
165    let rootfs = box_dir.join("rootfs");
166    let logs = box_dir.join("logs");
167    let marker = rootfs.join(WINDOWS_GUEST_RESULT_MARKER);
168    let live_logs_drained = rootfs.join(WINDOWS_LIVE_LOGS_DRAINED_MARKER);
169    let stdout_source = rootfs.join(WINDOWS_GUEST_STDOUT);
170    let stderr_source = rootfs.join(WINDOWS_GUEST_STDERR);
171
172    if !windows_marker_matches(&marker, b"collected\n") {
173        std::fs::create_dir_all(&logs)?;
174        let runtime_filter = a3s_box_core::log::RuntimeConsoleFilter::new();
175
176        for (source, destination) in [
177            (&stdout_source, logs.join("console.log")),
178            (&stderr_source, logs.join("console.err.log")),
179        ] {
180            append_windows_guest_stream(source, &destination, &runtime_filter).map_err(
181                |error| BoxError::BoxBootError {
182                    message: format!(
183                        "Failed to collect Windows guest output {} into {}: {error}",
184                        source.display(),
185                        destination.display()
186                    ),
187                    hint: None,
188                },
189            )?;
190        }
191
192        // New Windows shims tail these sources live and drain them before exit.
193        // Older libkrun bundles still terminate the shim with `_exit`, so keep
194        // the completed-stream fallback when no drained marker exists. Process
195        // the sources rather than the retained raw console to avoid replaying a
196        // previous restart.
197        if !windows_marker_matches(&live_logs_drained, b"drained\n") {
198            let stopped = std::sync::atomic::AtomicBool::new(true);
199            a3s_box_core::log::run_log_processor_streams(
200                &stdout_source,
201                &stderr_source,
202                &logs,
203                log_config,
204                &stopped,
205            );
206        }
207
208        a3s_box_core::windows_file::replace_regular_file(&marker, b"collected\n").map_err(
209            |error| BoxError::BoxBootError {
210                message: format!(
211                    "Failed to mark the Windows guest result collected at {}: {error}",
212                    marker.display()
213                ),
214                hint: None,
215            },
216        )?;
217    }
218
219    let exit_path = rootfs.join(WINDOWS_GUEST_EXIT_CODE);
220    let contents = match a3s_box_core::windows_file::open_regular_file(&exit_path, None) {
221        Ok((file, _)) => {
222            use std::io::Read;
223            let mut contents = String::new();
224            file.take(64)
225                .read_to_string(&mut contents)
226                .map_err(|error| BoxError::BoxBootError {
227                    message: format!(
228                        "Failed to read the Windows guest exit code {}: {error}",
229                        exit_path.display()
230                    ),
231                    hint: None,
232                })?;
233            contents
234        }
235        Err(error) if error.kind() == std::io::ErrorKind::NotFound && shim_exit_code != 0 => {
236            return Ok(shim_exit_code);
237        }
238        Err(error) => {
239            return Err(BoxError::BoxBootError {
240                message: if error.kind() == std::io::ErrorKind::NotFound {
241                    format!(
242                        "WHPX stopped before the guest persisted its exit code ({})",
243                        exit_path.display()
244                    )
245                } else {
246                    format!(
247                        "Failed to read the Windows guest exit code {}: {error}",
248                        exit_path.display()
249                    )
250                },
251                hint: Some(
252                    "Inspect logs/init-rust.log and the shim log for the guest boot failure"
253                        .to_string(),
254                ),
255            });
256        }
257    };
258
259    contents
260        .trim()
261        .parse::<i32>()
262        .map_err(|error| BoxError::BoxBootError {
263            message: format!(
264                "Invalid Windows guest exit code in {}: {error}",
265                exit_path.display()
266            ),
267            hint: None,
268        })
269}
270
271/// VM manager - orchestrates VM lifecycle.
272pub struct VmManager {
273    /// Box configuration
274    pub(crate) config: BoxConfig,
275
276    /// Unique box identifier
277    pub(crate) box_id: String,
278
279    /// Current state
280    pub(crate) state: Arc<RwLock<BoxState>>,
281
282    /// Event emitter
283    pub(crate) event_emitter: EventEmitter,
284
285    /// VMM provider (spawns VMs via pluggable backend)
286    pub(crate) provider: Option<Box<dyn VmmProvider>>,
287
288    /// VM handler (runtime operations on running VM)
289    pub(crate) handler: Arc<RwLock<Option<Box<dyn VmHandler>>>>,
290
291    /// Exec client for executing commands in the guest
292    #[cfg(unix)]
293    pub(crate) exec_client: Option<ExecClient>,
294
295    /// Network backend manager for bridge networking (None if TSI mode).
296    /// Platform-specific: passt on Linux, gvproxy on macOS.
297    pub(crate) net_manager: Option<Box<dyn crate::network::NetworkBackend>>,
298
299    /// A3S home directory (~/.a3s)
300    pub(crate) home_dir: PathBuf,
301
302    /// Anonymous volume names created during boot (from OCI VOLUME directives)
303    pub(crate) anonymous_volumes: Vec<String>,
304
305    /// Anonymous volumes newly created by the current boot attempt.
306    ///
307    /// Reused anonymous volumes must survive failed restarts because they may
308    /// contain data from an existing stopped box.
309    pub(crate) created_anonymous_volumes: Vec<String>,
310
311    /// OCI image config resolved during the last successful boot.
312    pub(crate) image_config: Option<crate::oci::OciImageConfig>,
313
314    /// Suppress an image-defined health check for callers that explicitly
315    /// requested Docker-compatible `--no-healthcheck` semantics.
316    pub(crate) healthcheck_disabled: bool,
317
318    /// Whether this boot attempt started with an existing persistent rootfs
319    /// generation. Failed first boots may discard a partial extraction, while
320    /// failed restarts must retain the pre-existing guest data.
321    pub(crate) preserve_rootfs_on_boot_failure: bool,
322
323    /// TEE extension (attestation, sealing, secret injection)
324    #[cfg(unix)]
325    pub(crate) tee: Option<Box<dyn TeeExtension>>,
326
327    /// Rootfs provider (overlay or copy)
328    pub(crate) rootfs_provider: Box<dyn crate::rootfs::RootfsProvider>,
329
330    /// Path to the exec Unix socket (set after boot)
331    pub(crate) exec_socket_path: Option<PathBuf>,
332
333    /// Path to the PTY Unix socket (set after boot)
334    pub(crate) pty_socket_path: Option<PathBuf>,
335
336    /// Path to the CRI port-forward Unix socket (set after boot)
337    pub(crate) port_forward_socket_path: Option<PathBuf>,
338
339    /// Prometheus metrics (optional, for instrumented deployments).
340    pub(crate) prom: Option<crate::prom::RuntimeMetrics>,
341
342    /// Exit code captured from the shim process after it exits.
343    pub(crate) shim_exit_code: Option<i32>,
344
345    /// Optional progress callback for image pulls: `(current, total, digest, size_bytes)`.
346    pub(crate) pull_progress_fn: Option<PullProgressFn>,
347
348    /// Logging driver config, threaded into the InstanceSpec so the shim runs
349    /// the log processor for the box's lifetime (set by the CLI via
350    /// [`VmManager::set_log_config`]).
351    pub(crate) log_config: a3s_box_core::log::LogConfig,
352
353    /// Backend-neutral resolution captured before any boot side effects.
354    pub(crate) resolved_execution_plan: Option<ResolvedExecutionPlan>,
355}
356
357impl VmManager {
358    /// Create a new VM manager.
359    pub fn new(config: BoxConfig, event_emitter: EventEmitter) -> Self {
360        let box_id = uuid::Uuid::new_v4().to_string();
361        let home_dir = a3s_box_core::dirs_home();
362
363        Self {
364            config,
365            box_id,
366            state: Arc::new(RwLock::new(BoxState::Created)),
367            event_emitter,
368            provider: None,
369            handler: Arc::new(RwLock::new(None)),
370            #[cfg(unix)]
371            exec_client: None,
372            net_manager: None,
373            home_dir,
374            anonymous_volumes: Vec::new(),
375            created_anonymous_volumes: Vec::new(),
376            image_config: None,
377            healthcheck_disabled: false,
378            preserve_rootfs_on_boot_failure: false,
379            #[cfg(unix)]
380            tee: None,
381            rootfs_provider: crate::rootfs::default_provider(),
382            exec_socket_path: None,
383            pty_socket_path: None,
384            port_forward_socket_path: None,
385            prom: None,
386            shim_exit_code: None,
387            pull_progress_fn: None,
388            log_config: a3s_box_core::log::LogConfig::default(),
389            resolved_execution_plan: None,
390        }
391    }
392
393    /// Create a new VM manager with a specific box ID.
394    pub fn with_box_id(config: BoxConfig, event_emitter: EventEmitter, box_id: String) -> Self {
395        let home_dir = a3s_box_core::dirs_home();
396
397        Self {
398            config,
399            box_id,
400            state: Arc::new(RwLock::new(BoxState::Created)),
401            event_emitter,
402            provider: None,
403            handler: Arc::new(RwLock::new(None)),
404            #[cfg(unix)]
405            exec_client: None,
406            net_manager: None,
407            home_dir,
408            anonymous_volumes: Vec::new(),
409            created_anonymous_volumes: Vec::new(),
410            image_config: None,
411            healthcheck_disabled: false,
412            preserve_rootfs_on_boot_failure: false,
413            #[cfg(unix)]
414            tee: None,
415            rootfs_provider: crate::rootfs::default_provider(),
416            exec_socket_path: None,
417            pty_socket_path: None,
418            port_forward_socket_path: None,
419            prom: None,
420            shim_exit_code: None,
421            pull_progress_fn: None,
422            log_config: a3s_box_core::log::LogConfig::default(),
423            resolved_execution_plan: None,
424        }
425    }
426
427    /// Remove host-side boot artifacts after a failed boot attempt.
428    async fn cleanup_boot_failure(&mut self) {
429        if let Some(mut handler) = self.handler.write().await.take() {
430            if let Err(error) = handler.stop(default_stop_signal(), DEFAULT_SHUTDOWN_TIMEOUT_MS) {
431                tracing::warn!(
432                    box_id = %self.box_id,
433                    error = %error,
434                    "Failed to stop VM handler after boot failure"
435                );
436            }
437            self.shim_exit_code = handler.exit_code();
438        }
439
440        if let Some(mut net_manager) = self.net_manager.take() {
441            net_manager.stop();
442        }
443
444        self.cleanup_created_anonymous_volumes();
445        self.cleanup_box_dir();
446    }
447
448    fn cleanup_created_anonymous_volumes(&mut self) {
449        if self.created_anonymous_volumes.is_empty() {
450            return;
451        }
452
453        let created = std::mem::take(&mut self.created_anonymous_volumes);
454        let created_set: std::collections::HashSet<_> = created.iter().cloned().collect();
455        let store = crate::volume::VolumeStore::new(
456            self.home_dir.join("volumes.json"),
457            self.home_dir.join("volumes"),
458        );
459
460        for volume_name in &created {
461            if let Err(error) = store.remove(volume_name, true) {
462                tracing::debug!(
463                    box_id = %self.box_id,
464                    volume = volume_name,
465                    error = %error,
466                    "Failed to remove anonymous volume after boot failure"
467                );
468            }
469        }
470
471        self.anonymous_volumes
472            .retain(|name| !created_set.contains(name));
473    }
474
475    /// Remove transient host boot artifacts, retaining persistent guest data.
476    fn cleanup_box_dir(&self) {
477        let box_dir = self.home_dir.join("boxes").join(&self.box_id);
478        let socket_dir = self.socket_dir();
479
480        // Reap the box's passt daemon (Linux bridge mode) BEFORE removing its
481        // socket dir. A boot that fails after passt spawned but before
482        // `self.net_manager` was assigned leaves `net_manager.stop()` a no-op, so
483        // passt would otherwise survive holding the published port — the
484        // "Address already in use" on the next start. terminate_passt reads
485        // `socket_dir/passt.pid` and is a no-op when there is no passt.
486        #[cfg(target_os = "linux")]
487        crate::network::terminate_passt(&self.socket_dir());
488
489        if let Err(error) = self
490            .rootfs_provider
491            .cleanup(&box_dir, self.preserve_rootfs_on_boot_failure)
492        {
493            tracing::warn!(
494                box_id = %self.box_id,
495                path = %box_dir.display(),
496                error = %error,
497                "Failed to cleanup rootfs provider after boot failure"
498            );
499        }
500
501        match std::fs::remove_dir_all(&socket_dir) {
502            Ok(()) => {}
503            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
504            Err(error) => {
505                tracing::debug!(
506                    box_id = %self.box_id,
507                    path = %socket_dir.display(),
508                    error = %error,
509                    "Failed to cleanup socket directory after boot failure"
510                );
511            }
512        }
513
514        // A failed restart must never erase a persistent writable rootfs. The
515        // provider cleanup above detaches transient mounts while retaining the
516        // persistent generation; only ephemeral boxes are removed wholesale.
517        if !self.config.persistent {
518            match std::fs::remove_dir_all(&box_dir) {
519                Ok(()) => {}
520                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
521                Err(error) => {
522                    tracing::warn!(
523                        box_id = %self.box_id,
524                        path = %box_dir.display(),
525                        error = %error,
526                        "Failed to cleanup box directory after boot failure"
527                    );
528                }
529            }
530        }
531    }
532
533    /// Create a new VM manager with a custom VMM provider.
534    pub fn with_provider(
535        config: BoxConfig,
536        event_emitter: EventEmitter,
537        provider: Box<dyn VmmProvider>,
538    ) -> Self {
539        let box_id = uuid::Uuid::new_v4().to_string();
540        let home_dir = a3s_box_core::dirs_home();
541        Self {
542            config,
543            box_id,
544            state: Arc::new(RwLock::new(BoxState::Created)),
545            event_emitter,
546            provider: Some(provider),
547            handler: Arc::new(RwLock::new(None)),
548            #[cfg(unix)]
549            exec_client: None,
550            net_manager: None,
551            home_dir,
552            anonymous_volumes: Vec::new(),
553            created_anonymous_volumes: Vec::new(),
554            image_config: None,
555            healthcheck_disabled: false,
556            preserve_rootfs_on_boot_failure: false,
557            #[cfg(unix)]
558            tee: None,
559            rootfs_provider: crate::rootfs::default_provider(),
560            exec_socket_path: None,
561            pty_socket_path: None,
562            port_forward_socket_path: None,
563            prom: None,
564            shim_exit_code: None,
565            pull_progress_fn: None,
566            log_config: a3s_box_core::log::LogConfig::default(),
567            resolved_execution_plan: None,
568        }
569    }
570
571    /// Get the box ID.
572    pub fn box_id(&self) -> &str {
573        &self.box_id
574    }
575
576    /// Get current state.
577    pub async fn state(&self) -> BoxState {
578        *self.state.read().await
579    }
580
581    /// Get the exec client, if connected.
582    #[cfg(unix)]
583    pub fn exec_client(&self) -> Option<&ExecClient> {
584        self.exec_client.as_ref()
585    }
586
587    #[cfg(unix)]
588    async fn connect_exec_client_for_request(socket_path: &Path) -> Result<ExecClient> {
589        const ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
590
591        let client = ExecClient::connect(socket_path).await?;
592        match tokio::time::timeout(ATTEMPT_TIMEOUT, client.heartbeat()).await {
593            Ok(Ok(true)) => Ok(client),
594            Ok(Ok(false)) => Err(BoxError::ExecError(format!(
595                "Exec client not connected: heartbeat failed at {}",
596                socket_path.display()
597            ))),
598            Ok(Err(error)) => Err(error),
599            Err(_) => Err(BoxError::ExecError(format!(
600                "Exec client not connected: heartbeat timed out at {}",
601                socket_path.display()
602            ))),
603        }
604    }
605
606    /// Wait until the guest exec server can complete a heartbeat.
607    ///
608    /// Cold foreground boots may proceed after the short diagnostic readiness
609    /// cap so logs remain visible. A warm pool has a stronger contract: an idle
610    /// VM must actually be executable before it is published to callers.
611    #[cfg(unix)]
612    pub async fn wait_for_exec_available(&mut self, timeout: std::time::Duration) -> Result<()> {
613        let socket_path = self
614            .exec_socket_path
615            .clone()
616            .ok_or_else(|| BoxError::ExecError("Exec socket path is unavailable".to_string()))?;
617        let deadline = tokio::time::Instant::now() + timeout;
618        loop {
619            match Self::connect_exec_client_for_request(&socket_path).await {
620                Ok(client) => {
621                    self.exec_client = Some(client);
622                    return Ok(());
623                }
624                Err(error) if tokio::time::Instant::now() < deadline => {
625                    tracing::debug!(%error, "Waiting for pooled VM exec readiness");
626                    tokio::time::sleep(std::time::Duration::from_millis(200)).await;
627                }
628                Err(error) => return Err(error),
629            }
630        }
631    }
632
633    #[cfg(not(unix))]
634    pub async fn wait_for_exec_available(&mut self, _timeout: std::time::Duration) -> Result<()> {
635        Ok(())
636    }
637
638    /// Attach this manager to an already-running shim process.
639    ///
640    /// This is useful for crash recovery or control-plane restart flows where
641    /// the workload VM is still alive and only the host-side manager state
642    /// needs to be reconstructed.
643    #[cfg(unix)]
644    pub async fn attach_running_process(
645        &mut self,
646        pid: u32,
647        exec_socket_path: PathBuf,
648        pty_socket_path: Option<PathBuf>,
649    ) -> Result<()> {
650        let port_forward_socket_path = exec_socket_path.with_file_name("portfwd.sock");
651        let handler = crate::vmm::ShimHandler::from_pid(pid, self.box_id.clone());
652        if !handler.is_running() {
653            return Err(BoxError::StateError(format!(
654                "Cannot attach to non-running VM process {pid}"
655            )));
656        }
657
658        self.exec_client = match ExecClient::connect(&exec_socket_path).await {
659            Ok(client) => Some(client),
660            Err(error) => {
661                tracing::debug!(
662                    box_id = %self.box_id,
663                    socket_path = %exec_socket_path.display(),
664                    error = %error,
665                    "Failed to reconnect exec client while attaching to running VM"
666                );
667                None
668            }
669        };
670        self.exec_socket_path = Some(exec_socket_path);
671        self.pty_socket_path = pty_socket_path;
672        self.port_forward_socket_path = Some(port_forward_socket_path);
673        *self.handler.write().await = Some(Box::new(handler));
674        *self.state.write().await = BoxState::Ready;
675        Ok(())
676    }
677
678    /// Attach this manager to an already-running Windows shim process.
679    #[cfg(windows)]
680    pub async fn attach_running_process(
681        &mut self,
682        pid: u32,
683        exec_socket_path: PathBuf,
684        pty_socket_path: Option<PathBuf>,
685    ) -> Result<()> {
686        let handler = crate::vmm::ShimHandler::from_pid(pid, self.box_id.clone());
687        if !handler.is_running() {
688            return Err(BoxError::StateError(format!(
689                "Cannot attach to non-running VM process {pid}"
690            )));
691        }
692
693        self.exec_socket_path = Some(exec_socket_path);
694        self.pty_socket_path = pty_socket_path;
695        self.port_forward_socket_path = None;
696        *self.handler.write().await = Some(Box::new(handler));
697        *self.state.write().await = BoxState::Ready;
698        Ok(())
699    }
700
701    /// Get the exec socket path, if the VM has been booted.
702    pub fn exec_socket_path(&self) -> Option<&Path> {
703        self.exec_socket_path.as_deref()
704    }
705
706    /// Get the PTY socket path, if the VM has been booted.
707    pub fn pty_socket_path(&self) -> Option<&Path> {
708        self.pty_socket_path.as_deref()
709    }
710
711    /// Get the CRI port-forward socket path, if the VM has been booted.
712    pub fn port_forward_socket_path(&self) -> Option<&Path> {
713        self.port_forward_socket_path.as_deref()
714    }
715
716    /// Inject a custom VMM provider (e.g., a VmController with a known shim path).
717    ///
718    /// If set before `boot()`, the injected provider is used instead of the
719    /// default `VmController::find_shim()` fallback.
720    pub fn set_provider(&mut self, provider: Box<dyn VmmProvider>) {
721        self.provider = Some(provider);
722    }
723
724    /// Override the rootfs provider (overlay or copy).
725    ///
726    /// By default, `default_provider()` auto-detects the best available provider.
727    /// Call this before `boot()` to force a specific provider.
728    pub fn set_rootfs_provider(&mut self, provider: Box<dyn crate::rootfs::RootfsProvider>) {
729        self.rootfs_provider = provider;
730    }
731
732    /// Get the name of the active rootfs provider.
733    pub fn rootfs_provider_name(&self) -> &str {
734        self.rootfs_provider.name()
735    }
736
737    /// Set a progress callback for image pulls: `(current, total, digest, size_bytes)`.
738    /// Called once per layer when `run` pulls an image that is not yet cached.
739    pub fn set_pull_progress_fn(&mut self, f: PullProgressFn) {
740        self.pull_progress_fn = Some(f);
741    }
742
743    /// Attach Prometheus metrics to this VM manager.
744    pub fn set_metrics(&mut self, metrics: crate::prom::RuntimeMetrics) {
745        self.prom = Some(metrics);
746    }
747
748    /// Set the logging driver config. Threaded into the InstanceSpec so the shim
749    /// runs the log processor for the box's lifetime.
750    pub fn set_log_config(&mut self, log_config: a3s_box_core::log::LogConfig) {
751        self.log_config = log_config;
752    }
753
754    /// Set whether an image-defined health check is explicitly disabled.
755    pub fn set_healthcheck_disabled(&mut self, disabled: bool) {
756        self.healthcheck_disabled = disabled;
757    }
758
759    /// Get the attached Prometheus metrics (if any).
760    pub fn metrics_prom(&self) -> Option<&crate::prom::RuntimeMetrics> {
761        self.prom.as_ref()
762    }
763
764    /// Get the names of anonymous volumes created during boot.
765    ///
766    /// These are auto-created from OCI VOLUME directives and should be tracked
767    /// for cleanup when the box is removed.
768    pub fn anonymous_volumes(&self) -> &[String] {
769        &self.anonymous_volumes
770    }
771
772    /// Get the OCI image config resolved during boot.
773    pub fn image_config(&self) -> Option<&crate::oci::OciImageConfig> {
774        self.image_config.as_ref()
775    }
776
777    /// Return the immutable execution resolution captured for this boot.
778    pub fn resolved_execution_plan(&self) -> Option<&ResolvedExecutionPlan> {
779        self.resolved_execution_plan.as_ref()
780    }
781
782    /// Get the exit code of the container, if it has exited.
783    ///
784    /// Returns `Some(code)` after `destroy()` has been called and the shim
785    /// process exited naturally (not killed). Returns `None` if the VM has not
786    /// yet stopped or the exit code could not be determined.
787    pub fn exit_code(&self) -> Option<i32> {
788        self.shim_exit_code
789    }
790
791    #[cfg(not(target_os = "windows"))]
792    fn persisted_exit_code(&self) -> Option<i32> {
793        crate::rootfs::read_persisted_exit_code(&self.home_dir.join("boxes").join(&self.box_id))
794    }
795
796    /// Poll the owned VM process for natural exit without sending a signal.
797    ///
798    /// This is used by foreground CLI flows where the container command may
799    /// finish on its own and the CLI should clean up instead of waiting for
800    /// a Ctrl-C.
801    pub async fn try_wait_exit(&mut self) -> Result<Option<i32>> {
802        if let Some(code) = self.shim_exit_code {
803            return Ok(Some(code));
804        }
805
806        // Unix guests expose the overlay exit file only once their shutdown is
807        // effectively complete. On Windows the shared-rootfs file appears while
808        // the shim still has to relay guest stdout/stderr into the host logs, so
809        // treating it as completion here races teardown against that relay.
810        #[cfg(not(target_os = "windows"))]
811        if let Some(code) = self.persisted_exit_code() {
812            self.shim_exit_code = Some(code);
813            return Ok(Some(code));
814        }
815
816        let mut handler = self.handler.write().await;
817        let Some(handler) = handler.as_mut() else {
818            return Ok(self.shim_exit_code);
819        };
820
821        if let Some(code) = handler.try_wait_exit()? {
822            #[cfg(target_os = "windows")]
823            let code = collect_windows_guest_result(
824                &self.home_dir.join("boxes").join(&self.box_id),
825                &self.log_config,
826                code,
827            )?;
828            self.shim_exit_code = Some(code);
829            return Ok(Some(code));
830        }
831
832        Ok(None)
833    }
834
835    /// Return true once the foreground container is known to have finished, even
836    /// if the shim exit status has not been reaped yet.
837    pub async fn has_exited(&self) -> bool {
838        if self.shim_exit_code.is_some() {
839            return true;
840        }
841
842        #[cfg(not(target_os = "windows"))]
843        if self.persisted_exit_code().is_some() {
844            return true;
845        }
846
847        self.handler
848            .read()
849            .await
850            .as_ref()
851            .map(|handler| handler.has_exited())
852            .unwrap_or(false)
853    }
854
855    /// Run a command as the container MAIN in an IDLE-booted (deferred-main) VM.
856    ///
857    /// Sends the `spawn-main` control frame carrying `spec_json` (the command),
858    /// waits for the main to exit (which halts the VM), and returns its real exit
859    /// code + the box's json-file console logs split by stream. This is the full-
860    /// box-semantics counterpart to [`Self::exec_command`] (whose output is piped
861    /// over the exec stream, not the json-file logs).
862    #[cfg(unix)]
863    pub async fn run_deferred_main(
864        &mut self,
865        spec_json: &[u8],
866        timeout: std::time::Duration,
867    ) -> Result<a3s_box_core::exec::ExecOutput> {
868        let log_dir = self.home_dir.join("boxes").join(&self.box_id).join("logs");
869        let console_out_path = log_dir.join("console.log");
870        let console_err_path = a3s_box_core::log::stderr_console_path(&console_out_path);
871        let console_out_start = std::fs::metadata(&console_out_path)
872            .map(|metadata| metadata.len())
873            .unwrap_or(0);
874        let console_err_start = std::fs::metadata(&console_err_path)
875            .map(|metadata| metadata.len())
876            .unwrap_or(0);
877
878        let acked = {
879            let owned_client;
880            let client = if let Some(client) = self.exec_client.as_ref() {
881                client
882            } else {
883                let socket_path = self
884                    .exec_socket_path
885                    .as_deref()
886                    .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?;
887                owned_client = Self::connect_exec_client_for_request(socket_path).await?;
888                &owned_client
889            };
890            client.spawn_main(Some(spec_json)).await?
891        };
892        let exit_wait_timeout = if acked {
893            timeout
894        } else {
895            // Very short deferred mains can exit and halt the VM before the
896            // guest's ACK frame makes it back to the host. Treat a missing ACK as
897            // provisional: if the VM exits promptly, the spawn succeeded and the
898            // real exit code/logs are authoritative; otherwise fail quickly
899            // instead of waiting the full command timeout for an IDLE VM.
900            tracing::debug!(
901                box_id = %self.box_id,
902                "spawn-main was not acknowledged; waiting briefly for main exit"
903            );
904            timeout.min(std::time::Duration::from_secs(2))
905        };
906
907        // Wait for the main to exit — guest-init persists the code and halts the VM.
908        let start = std::time::Instant::now();
909        let exit_code = loop {
910            if let Some(code) = self.try_wait_exit().await? {
911                break code;
912            }
913            if start.elapsed() >= exit_wait_timeout {
914                let message = if acked {
915                    "deferred main did not exit within the timeout"
916                } else {
917                    "spawn-main was not acknowledged by the guest"
918                };
919                return Err(BoxError::ExecError(message.to_string()));
920            }
921            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
922        };
923
924        // Let the shim's log processor finish draining console.log into the json
925        // file (it flushes as the VM halts). A single short "stable length"
926        // sample is not enough here: deferred-main can persist its exit code
927        // before the final stdout/stderr bytes have reached the host tailer,
928        // especially with pre-warmed pools. Require a small quiet window before
929        // reading logs, bounded so no-output commands still return promptly.
930        let json_path = log_dir.join("container.json");
931        let drain_start = std::time::Instant::now();
932        let max_wait = std::time::Duration::from_secs(2);
933        let min_wait = std::time::Duration::from_millis(500);
934        let quiet_window = std::time::Duration::from_millis(200);
935        let mut last_len: Option<u64> = None;
936        let mut last_change = drain_start;
937        loop {
938            let len = std::fs::metadata(&json_path).map(|m| m.len()).unwrap_or(0);
939            if last_len != Some(len) {
940                last_len = Some(len);
941                last_change = std::time::Instant::now();
942            }
943            let elapsed = drain_start.elapsed();
944            if elapsed >= max_wait || (elapsed >= min_wait && last_change.elapsed() >= quiet_window)
945            {
946                break;
947            }
948            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
949        }
950        let (mut stdout, mut stderr) = self.read_container_logs();
951        if stdout.is_empty() {
952            stdout = Self::read_file_from_offset(&console_out_path, console_out_start);
953        }
954        if stderr.is_empty() {
955            stderr = Self::read_file_from_offset(&console_err_path, console_err_start);
956        }
957        let truncated = stdout.len() > a3s_box_core::exec::MAX_OUTPUT_BYTES
958            || stderr.len() > a3s_box_core::exec::MAX_OUTPUT_BYTES;
959        stdout.truncate(a3s_box_core::exec::MAX_OUTPUT_BYTES);
960        stderr.truncate(a3s_box_core::exec::MAX_OUTPUT_BYTES);
961        Ok(a3s_box_core::exec::ExecOutput {
962            stdout,
963            stderr,
964            exit_code,
965            truncated,
966        })
967    }
968
969    fn read_file_from_offset(path: &Path, offset: u64) -> Vec<u8> {
970        use std::io::{Read, Seek, SeekFrom};
971
972        let mut file = match std::fs::File::open(path) {
973            Ok(file) => file,
974            Err(_) => return vec![],
975        };
976        if file.seek(SeekFrom::Start(offset)).is_err() {
977            return vec![];
978        }
979
980        let mut bytes = Vec::new();
981        if file.read_to_end(&mut bytes).is_err() {
982            return vec![];
983        }
984        bytes
985    }
986
987    /// Read the box's json-file console logs, split into stdout/stderr by stream.
988    #[cfg(unix)]
989    fn read_container_logs(&self) -> (Vec<u8>, Vec<u8>) {
990        let path = self
991            .home_dir
992            .join("boxes")
993            .join(&self.box_id)
994            .join("logs")
995            .join("container.json");
996        let (mut out, mut err) = (Vec::new(), Vec::new());
997        if let Ok(content) = std::fs::read_to_string(&path) {
998            for line in content.lines() {
999                if let Ok(entry) = serde_json::from_str::<a3s_box_core::log::LogEntry>(line) {
1000                    if entry.stream == "stderr" {
1001                        err.extend_from_slice(entry.log.as_bytes());
1002                    } else {
1003                        out.extend_from_slice(entry.log.as_bytes());
1004                    }
1005                }
1006            }
1007        }
1008        (out, err)
1009    }
1010
1011    /// Execute a command in the guest VM.
1012    ///
1013    /// Requires the VM to be in Ready, Busy, or Compacting state.
1014    #[cfg(unix)]
1015    #[tracing::instrument(skip(self, request), fields(box_id = %self.box_id))]
1016    pub async fn exec_request(
1017        &self,
1018        request: &a3s_box_core::exec::ExecRequest,
1019    ) -> Result<a3s_box_core::exec::ExecOutput> {
1020        if request.cmd.is_empty() {
1021            return Err(BoxError::ExecError(
1022                "Exec request requires a non-empty command".to_string(),
1023            ));
1024        }
1025
1026        let state = self.state.read().await;
1027        match *state {
1028            BoxState::Ready | BoxState::Busy | BoxState::Compacting => {}
1029            BoxState::Created => {
1030                return Err(BoxError::ExecError("VM not yet booted".to_string()));
1031            }
1032            BoxState::Stopped => {
1033                return Err(BoxError::ExecError("VM is stopped".to_string()));
1034            }
1035        }
1036        drop(state);
1037
1038        let owned_client;
1039        let client = if let Some(client) = self.exec_client.as_ref() {
1040            client
1041        } else {
1042            let socket_path = self
1043                .exec_socket_path
1044                .as_deref()
1045                .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?;
1046            owned_client = Self::connect_exec_client_for_request(socket_path).await?;
1047            &owned_client
1048        };
1049
1050        let exec_start = std::time::Instant::now();
1051        let result = client.exec_command(request).await;
1052
1053        // Record Prometheus metrics
1054        if let Some(ref prom) = self.prom {
1055            prom.exec_total.inc();
1056            prom.exec_duration
1057                .observe(exec_start.elapsed().as_secs_f64());
1058            if result.is_err() || result.as_ref().is_ok_and(|o| o.exit_code != 0) {
1059                prom.exec_errors_total.inc();
1060            }
1061        }
1062
1063        result
1064    }
1065
1066    /// Execute a command in the guest VM.
1067    ///
1068    /// Requires the VM to be in Ready, Busy, or Compacting state.
1069    #[cfg(unix)]
1070    #[tracing::instrument(skip(self, cmd), fields(box_id = %self.box_id))]
1071    pub async fn exec_command(
1072        &self,
1073        cmd: Vec<String>,
1074        timeout_ns: u64,
1075    ) -> Result<a3s_box_core::exec::ExecOutput> {
1076        let request = a3s_box_core::exec::ExecRequest {
1077            request_id: None,
1078            cmd,
1079            timeout_ns,
1080            env: vec![],
1081            working_dir: None,
1082            rootfs: None,
1083            stdin: None,
1084            stdin_streaming: false,
1085            user: None,
1086            streaming: false,
1087        };
1088
1089        self.exec_request(&request).await
1090    }
1091
1092    /// Boot the VM.
1093    pub async fn boot(&mut self) -> Result<()> {
1094        let boot_span = tracing::info_span!("vm_boot", box_id = %self.box_id);
1095        // Check and transition state: Created → booting
1096        {
1097            let state = self.state.read().await;
1098            if *state != BoxState::Created {
1099                return Err(BoxError::StateError("VM already booted".to_string()));
1100            }
1101        }
1102
1103        let box_dir = self.home_dir.join("boxes").join(&self.box_id);
1104        self.preserve_rootfs_on_boot_failure =
1105            self.config.persistent && layout::persistent_rootfs_generation_exists(&box_dir)?;
1106
1107        let execution_plan = a3s_box_core::resolve_execution(&self.config)?;
1108        self.resolved_execution_plan = Some(execution_plan.clone());
1109        if execution_plan.backend == ExecutionBackend::Crun {
1110            let boot_start = std::time::Instant::now();
1111            return self
1112                .boot_sandbox(execution_plan, &boot_span, boot_start)
1113                .await;
1114        }
1115
1116        let boot_start = std::time::Instant::now();
1117
1118        tracing::info!(parent: &boot_span, box_id = %self.box_id, "Booting VM");
1119
1120        // 1. Prepare filesystem layout
1121        let layout = match self
1122            .prepare_layout()
1123            .instrument(tracing::info_span!(parent: &boot_span, "prepare_layout"))
1124            .await
1125        {
1126            Ok(layout) => layout,
1127            Err(error) => {
1128                self.cleanup_boot_failure().await;
1129                return Err(error);
1130            }
1131        };
1132        self.image_config = layout.oci_config.clone();
1133
1134        // `prepare_layout` may only now have mounted a Snapshot lower through
1135        // this box's overlay. Stage via the exact guest-visible root so rename
1136        // copy-ups into the per-box upper before any guest process can launch.
1137        if let Err(error) = a3s_box_core::rootfs_metadata::stage_terminal_rootfs_metadata_for_boot(
1138            &layout.rootfs_path,
1139        ) {
1140            self.cleanup_boot_failure().await;
1141            return Err(BoxError::IoError(error));
1142        }
1143
1144        // 1.5. Override /etc/resolv.conf with configured DNS
1145        let resolv_content = a3s_box_core::dns::generate_resolv_conf(&self.config.dns);
1146        if let Err(e) = crate::oci::rootfs::write_guest_file(
1147            &layout.rootfs_path,
1148            "etc/resolv.conf",
1149            &resolv_content,
1150        ) {
1151            self.cleanup_boot_failure().await;
1152            return Err(e);
1153        }
1154        tracing::debug!(parent: &boot_span, dns = %resolv_content.trim(), "Configured guest DNS");
1155
1156        // 1.6. Apply hostname and static hosts entries before the VM starts.
1157        if let Err(e) = self.write_hostname_file(&layout) {
1158            self.cleanup_boot_failure().await;
1159            return Err(e);
1160        }
1161        if let Err(e) = self.write_standalone_hosts_file(&layout) {
1162            self.cleanup_boot_failure().await;
1163            return Err(e);
1164        }
1165
1166        // 2. Build InstanceSpec
1167        let mut spec = match self.build_instance_spec(&layout) {
1168            Ok(s) => s,
1169            Err(e) => {
1170                self.cleanup_boot_failure().await;
1171                return Err(e);
1172            }
1173        };
1174
1175        // 2.5. Configure bridge networking if requested
1176        let bridge_network = match &self.config.network {
1177            a3s_box_core::NetworkMode::Bridge { network } => Some(network.clone()),
1178            _ => None,
1179        };
1180        if let Some(network_name) = bridge_network {
1181            let net_config = match self.setup_bridge_network(&network_name) {
1182                Ok(n) => n,
1183                Err(e) => {
1184                    self.cleanup_boot_failure().await;
1185                    return Err(e);
1186                }
1187            };
1188
1189            // Write /etc/hosts for DNS service discovery
1190            match self.write_hosts_file(&layout, &network_name) {
1191                Ok(()) => (),
1192                Err(e) => {
1193                    self.cleanup_boot_failure().await;
1194                    return Err(e);
1195                }
1196            };
1197
1198            // Inject network env vars into entrypoint so they are passed via
1199            // krun_set_exec's envp (not krun_set_env which overwrites all vars).
1200            let ip_cidr = format!("{}/{}", net_config.ip_address, net_config.prefix_len);
1201            spec.entrypoint
1202                .env
1203                .push(("A3S_NET_IP".to_string(), ip_cidr));
1204            spec.entrypoint.env.push((
1205                "A3S_NET_GATEWAY".to_string(),
1206                net_config.gateway.to_string(),
1207            ));
1208            spec.entrypoint.env.push((
1209                "A3S_NET_DNS".to_string(),
1210                net_config
1211                    .dns_servers
1212                    .iter()
1213                    .map(|s| s.to_string())
1214                    .collect::<Vec<_>>()
1215                    .join(","),
1216            ));
1217
1218            spec.network = Some(net_config);
1219        }
1220
1221        #[cfg(target_os = "macos")]
1222        if spec.network.is_none()
1223            && matches!(self.config.network, a3s_box_core::NetworkMode::Tsi)
1224            && !self.config.port_map.is_empty()
1225        {
1226            let net_config = match self.setup_published_default_network() {
1227                Ok(network) => network,
1228                Err(error) => {
1229                    self.cleanup_boot_failure().await;
1230                    return Err(error);
1231                }
1232            };
1233            let ip_cidr = format!("{}/{}", net_config.ip_address, net_config.prefix_len);
1234            spec.entrypoint
1235                .env
1236                .push(("A3S_NET_IP".to_string(), ip_cidr));
1237            spec.entrypoint.env.push((
1238                "A3S_NET_GATEWAY".to_string(),
1239                net_config.gateway.to_string(),
1240            ));
1241            spec.entrypoint.env.push((
1242                "A3S_NET_DNS".to_string(),
1243                net_config
1244                    .dns_servers
1245                    .iter()
1246                    .map(ToString::to_string)
1247                    .collect::<Vec<_>>()
1248                    .join(","),
1249            ));
1250            spec.network = Some(net_config);
1251        }
1252
1253        // 3. Initialize VMM provider (use injected provider or default to VmController)
1254        if self.provider.is_none() {
1255            let shim_path = match VmController::find_shim() {
1256                Ok(p) => p,
1257                Err(e) => {
1258                    self.cleanup_boot_failure().await;
1259                    return Err(e);
1260                }
1261            };
1262            let controller = match VmController::new(shim_path) {
1263                Ok(c) => c,
1264                Err(e) => {
1265                    self.cleanup_boot_failure().await;
1266                    return Err(e);
1267                }
1268            };
1269            self.provider = Some(Box::new(controller));
1270        }
1271
1272        // 4. Start VM via provider
1273        let handler = {
1274            let provider = self
1275                .provider
1276                .as_ref()
1277                .ok_or_else(|| BoxError::BoxBootError {
1278                    message: "VMM provider not initialized".to_string(),
1279                    hint: Some("Ensure VmManager has a provider set before boot".to_string()),
1280                })?;
1281            let vm_start_span = tracing::info_span!(parent: &boot_span, "vm_start");
1282            match async { provider.start(&spec).await }
1283                .instrument(vm_start_span)
1284                .await
1285            {
1286                Ok(h) => h,
1287                Err(e) => {
1288                    self.cleanup_boot_failure().await;
1289                    return Err(e);
1290                }
1291            }
1292        };
1293
1294        // Store handler
1295        *self.handler.write().await = Some(handler);
1296
1297        // 5. Wait for guest ready
1298        {
1299            let wait_span = tracing::info_span!(parent: &boot_span, "wait_for_ready");
1300            if let Err(e) = async {
1301                self.wait_for_vm_running().await?;
1302
1303                // 5b. Become ready. A snapshot-restore boot resumes an already-booted
1304                // guest whose exec server won't re-signal readiness, so the cold-boot
1305                // wait would stall registration on its safety cap — do one best-effort
1306                // probe instead. A normal boot waits for the Heartbeat health check.
1307                #[cfg(unix)]
1308                if is_restore_mode(&self.config) {
1309                    self.probe_exec_ready_once(&layout.exec_socket_path).await;
1310                } else {
1311                    self.wait_for_exec_ready(&layout.exec_socket_path).await?;
1312                }
1313                Ok::<(), BoxError>(())
1314            }
1315            .instrument(wait_span)
1316            .await
1317            {
1318                self.cleanup_boot_failure().await;
1319                return Err(e);
1320            }
1321        }
1322
1323        // Prototype: deferred-main-spawn. The guest booted IDLE (BOX_DEFERRED_MAIN);
1324        // now that the exec server is ready, tell it to spawn the container command
1325        // (already passed via BOX_EXEC_*) as the MAIN process — full box semantics
1326        // (exit code + json-file console logs) without a cold boot.
1327        // Auto-trigger spawn-main only for the env-driven `run` path, where the
1328        // command is known at boot. The pool sets config.deferred_main to boot the
1329        // VM IDLE but drives spawn-main EXPLICITLY per request (the per-request
1330        // command isn't known at pre-warm), so a pool VM must NOT auto-trigger here.
1331        // A restored guest's main is ALREADY running (captured in the snapshot), so
1332        // it must never re-spawn — doing so would start a duplicate main.
1333        #[cfg(unix)]
1334        if !is_restore_mode(&self.config)
1335            && std::env::var("BOX_DEFERRED_MAIN")
1336                .map(|v| v == "1")
1337                .unwrap_or(false)
1338        {
1339            if let Some(client) = self.exec_client.as_ref() {
1340                match client.spawn_main(None).await {
1341                    Ok(true) => tracing::info!("deferred container main spawned"),
1342                    Ok(false) => tracing::warn!("deferred spawn-main not acknowledged"),
1343                    Err(e) => tracing::warn!(error = %e, "deferred spawn-main failed"),
1344                }
1345            }
1346        }
1347
1348        // 5b2. Store socket paths for CRI streaming access
1349        self.exec_socket_path = Some(layout.exec_socket_path.clone());
1350        self.pty_socket_path = Some(layout.pty_socket_path.clone());
1351        self.port_forward_socket_path = Some(layout.port_forward_socket_path.clone());
1352
1353        // 5c. Initialize TEE extension for TEE environments
1354        #[cfg(unix)]
1355        if !matches!(self.config.tee, TeeConfig::None) {
1356            self.tee = Some(Box::new(crate::tee::SnpTeeExtension::new(
1357                self.box_id.clone(),
1358                layout.attest_socket_path.clone(),
1359            )));
1360        }
1361
1362        // 6. Update state to Ready
1363        *self.state.write().await = BoxState::Ready;
1364
1365        // Record Prometheus metrics
1366        if let Some(ref prom) = self.prom {
1367            let boot_duration = boot_start.elapsed().as_secs_f64();
1368            prom.vm_boot_duration.observe(boot_duration);
1369            prom.vm_created_total.inc();
1370            prom.vm_count.with_label_values(&["ready"]).inc();
1371        }
1372
1373        // Emit ready event
1374        self.event_emitter.emit(BoxEvent::empty("box.ready"));
1375
1376        tracing::info!(parent: &boot_span, box_id = %self.box_id, "VM ready");
1377
1378        Ok(())
1379    }
1380
1381    /// Destroy the VM with the default shutdown timeout and SIGTERM.
1382    pub async fn destroy(&mut self) -> Result<()> {
1383        self.destroy_with_options(default_stop_signal(), DEFAULT_SHUTDOWN_TIMEOUT_MS)
1384            .await
1385    }
1386
1387    /// Destroy the VM with a custom shutdown timeout and SIGTERM.
1388    pub async fn destroy_with_timeout(&mut self, timeout_ms: u64) -> Result<()> {
1389        self.destroy_with_options(default_stop_signal(), timeout_ms)
1390            .await
1391    }
1392
1393    /// Destroy the VM with a specific stop signal and timeout.
1394    ///
1395    /// Sends `signal` to the shim process and waits up to `timeout_ms` for it
1396    /// to exit gracefully before sending SIGKILL.
1397    #[tracing::instrument(skip(self), fields(box_id = %self.box_id))]
1398    pub async fn destroy_with_options(&mut self, signal: i32, timeout_ms: u64) -> Result<()> {
1399        let preserve_rootfs = self.config.persistent;
1400        self.destroy_with_rootfs_policy(signal, timeout_ms, preserve_rootfs)
1401            .await
1402    }
1403
1404    /// Stop the runtime while retaining its writable rootfs for a managed
1405    /// restart or filesystem-only pause.
1406    pub(crate) async fn destroy_preserving_rootfs_with_options(
1407        &mut self,
1408        signal: i32,
1409        timeout_ms: u64,
1410    ) -> Result<()> {
1411        self.destroy_with_rootfs_policy(signal, timeout_ms, true)
1412            .await
1413    }
1414
1415    pub(crate) async fn destroy_preserving_rootfs(&mut self) -> Result<()> {
1416        self.destroy_with_rootfs_policy(default_stop_signal(), DEFAULT_SHUTDOWN_TIMEOUT_MS, true)
1417            .await
1418    }
1419
1420    async fn destroy_with_rootfs_policy(
1421        &mut self,
1422        signal: i32,
1423        timeout_ms: u64,
1424        preserve_rootfs: bool,
1425    ) -> Result<()> {
1426        let mut state = self.state.write().await;
1427
1428        if *state == BoxState::Stopped {
1429            return Ok(());
1430        }
1431
1432        tracing::info!(box_id = %self.box_id, signal, timeout_ms, "Destroying VM");
1433
1434        // Mark as stopped first — ensures state is correct even if handler.stop() fails.
1435        *state = BoxState::Stopped;
1436
1437        // Stop the VM handler and capture its exit code before it's dropped.
1438        // A stop failure must NOT skip the host-resource teardown below (network
1439        // backend, overlay unmount, socket + box dirs) — those are already
1440        // best-effort and would otherwise leak on every wedged stop. Capture the
1441        // error and surface it after teardown instead of returning early.
1442        let mut stop_error = None;
1443        if let Some(mut handler) = self.handler.write().await.take() {
1444            #[cfg(windows)]
1445            let stop_request = match windows_stop::stage(&self.socket_dir(), signal) {
1446                Ok(path) => {
1447                    tracing::debug!(
1448                        box_id = %self.box_id,
1449                        signal,
1450                        path = %path.display(),
1451                        "Staged Windows guest stop request"
1452                    );
1453                    Some(path)
1454                }
1455                Err(error) => {
1456                    tracing::warn!(
1457                        box_id = %self.box_id,
1458                        signal,
1459                        error = %error,
1460                        "Failed to stage Windows guest stop request; force-stop fallback remains active"
1461                    );
1462                    None
1463                }
1464            };
1465
1466            #[cfg(windows)]
1467            let handler_timeout_ms = if timeout_ms == 0 {
1468                0
1469            } else if let Some(request) = stop_request.as_deref() {
1470                let delivery_started = std::time::Instant::now();
1471                let delivery_timeout = std::time::Duration::from_millis(
1472                    timeout_ms.min(WINDOWS_STOP_DELIVERY_TIMEOUT_MS),
1473                );
1474                let delivered =
1475                    match windows_stop::wait_until_delivered(request, delivery_timeout).await {
1476                        Ok(delivered) => delivered,
1477                        Err(error) => {
1478                            tracing::warn!(
1479                                box_id = %self.box_id,
1480                                error = %error,
1481                                "Failed while waiting for Windows guest stop request delivery"
1482                            );
1483                            false
1484                        }
1485                    };
1486                let delivery_elapsed_ms =
1487                    u64::try_from(delivery_started.elapsed().as_millis()).unwrap_or(u64::MAX);
1488                let remaining_timeout_ms = timeout_ms.saturating_sub(delivery_elapsed_ms);
1489                if delivered {
1490                    let finalization_timeout_ms = if self.config.persistent {
1491                        WINDOWS_GUEST_FINALIZATION_TIMEOUT_MS
1492                    } else {
1493                        0
1494                    };
1495                    let handler_timeout_ms =
1496                        remaining_timeout_ms.saturating_add(finalization_timeout_ms);
1497                    tracing::debug!(
1498                        box_id = %self.box_id,
1499                        delivery_elapsed_ms,
1500                        handler_timeout_ms,
1501                        "Delivered Windows stop request to the guest"
1502                    );
1503                    handler_timeout_ms
1504                } else {
1505                    tracing::warn!(
1506                        box_id = %self.box_id,
1507                        delivery_elapsed_ms,
1508                        "Windows guest stop request was not delivered before the forwarding deadline"
1509                    );
1510                    remaining_timeout_ms
1511                }
1512            } else {
1513                timeout_ms
1514            };
1515            #[cfg(not(windows))]
1516            let handler_timeout_ms = timeout_ms;
1517
1518            let _handler_stopped = match handler.stop(signal, handler_timeout_ms) {
1519                Ok(()) => true,
1520                Err(e) => {
1521                    tracing::error!(box_id = %self.box_id, error = %e, "Failed to stop VM handler; continuing teardown");
1522                    stop_error = Some(e);
1523                    false
1524                }
1525            };
1526            self.shim_exit_code = handler.exit_code();
1527
1528            #[cfg(windows)]
1529            if stop_request.is_some() {
1530                if let Err(error) = windows_stop::clear(&self.socket_dir()) {
1531                    tracing::warn!(
1532                        box_id = %self.box_id,
1533                        error = %error,
1534                        "Failed to clear Windows guest stop request"
1535                    );
1536                }
1537            }
1538
1539            #[cfg(windows)]
1540            if _handler_stopped && self.config.persistent {
1541                let rootfs = self
1542                    .home_dir
1543                    .join("boxes")
1544                    .join(&self.box_id)
1545                    .join("rootfs");
1546                match a3s_box_core::rootfs_metadata::finalize_terminal_rootfs_metadata(&rootfs) {
1547                    Ok(true) => tracing::info!(
1548                        box_id = %self.box_id,
1549                        path = %rootfs.display(),
1550                        "Published terminal rootfs metadata after Windows guest exit"
1551                    ),
1552                    Ok(false) => tracing::debug!(
1553                        box_id = %self.box_id,
1554                        path = %rootfs.display(),
1555                        "No Windows terminal rootfs metadata required host finalization"
1556                    ),
1557                    Err(error) => tracing::warn!(
1558                        box_id = %self.box_id,
1559                        path = %rootfs.display(),
1560                        error = %error,
1561                        "Refused to publish invalid Windows terminal rootfs metadata"
1562                    ),
1563                }
1564            }
1565        }
1566
1567        // Stop network backend if running
1568        if let Some(ref mut net) = self.net_manager {
1569            net.stop();
1570        }
1571        self.net_manager = None;
1572
1573        // Cleanup rootfs provider (unmount overlay if applicable)
1574        let box_dir = self.home_dir.join("boxes").join(&self.box_id);
1575        if let Err(e) = self.rootfs_provider.cleanup(&box_dir, preserve_rootfs) {
1576            tracing::warn!(
1577                box_id = %self.box_id,
1578                error = %e,
1579                "Failed to cleanup rootfs provider"
1580            );
1581        }
1582
1583        let socket_dir = self.socket_dir();
1584        if let Err(e) = std::fs::remove_dir_all(&socket_dir) {
1585            tracing::debug!(
1586                box_id = %self.box_id,
1587                path = %socket_dir.display(),
1588                error = %e,
1589                "Failed to cleanup VM socket directory"
1590            );
1591        }
1592
1593        // Remove the box working directory itself (overlay upper/work, logs,
1594        // leftover metadata) for non-persistent boxes. Without this, ephemeral
1595        // CRI pods leak their `boxes/<id>` directory on every destroy; the
1596        // accumulation slows later RunPodSandbox calls until they time out
1597        // (observed: pod #21 after churning 20). Persistent boxes keep their
1598        // dir intentionally.
1599        if !preserve_rootfs {
1600            match std::fs::remove_dir_all(&box_dir) {
1601                Ok(()) => {}
1602                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1603                Err(e) => {
1604                    tracing::warn!(
1605                        box_id = %self.box_id,
1606                        path = %box_dir.display(),
1607                        error = %e,
1608                        "Failed to remove box directory on destroy"
1609                    );
1610                }
1611            }
1612        }
1613
1614        // Record Prometheus metrics
1615        if let Some(ref prom) = self.prom {
1616            prom.vm_destroyed_total.inc();
1617            prom.vm_count.with_label_values(&["ready"]).dec();
1618        }
1619
1620        // Emit stopped event
1621        self.event_emitter.emit(BoxEvent::empty("box.stopped"));
1622
1623        // Host teardown above is complete; surface a handler-stop failure now so
1624        // the caller still learns the stop was imperfect.
1625        match stop_error {
1626            Some(e) => Err(e),
1627            None => Ok(()),
1628        }
1629    }
1630
1631    /// Transition to busy state.
1632    pub async fn set_busy(&self) -> Result<()> {
1633        let mut state = self.state.write().await;
1634
1635        if *state != BoxState::Ready {
1636            return Err(BoxError::StateError("VM not ready".to_string()));
1637        }
1638
1639        *state = BoxState::Busy;
1640        Ok(())
1641    }
1642
1643    /// Transition back to ready state.
1644    pub async fn set_ready(&self) -> Result<()> {
1645        let mut state = self.state.write().await;
1646
1647        if *state != BoxState::Busy && *state != BoxState::Compacting {
1648            return Err(BoxError::StateError("Invalid state transition".to_string()));
1649        }
1650
1651        *state = BoxState::Ready;
1652        Ok(())
1653    }
1654
1655    /// Transition to compacting state.
1656    pub async fn set_compacting(&self) -> Result<()> {
1657        let mut state = self.state.write().await;
1658
1659        if *state != BoxState::Busy {
1660            return Err(BoxError::StateError("VM not busy".to_string()));
1661        }
1662
1663        *state = BoxState::Compacting;
1664        Ok(())
1665    }
1666
1667    /// Pause the VM by sending SIGSTOP to the shim process.
1668    ///
1669    /// The VM must be in Ready, Busy, or Compacting state.
1670    #[cfg(unix)]
1671    pub async fn pause(&self) -> Result<()> {
1672        let state = self.state.read().await;
1673        match *state {
1674            BoxState::Ready | BoxState::Busy | BoxState::Compacting => {}
1675            BoxState::Created => {
1676                return Err(BoxError::StateError("VM not yet booted".to_string()));
1677            }
1678            BoxState::Stopped => {
1679                return Err(BoxError::StateError("VM is stopped".to_string()));
1680            }
1681        }
1682        drop(state);
1683
1684        if self
1685            .resolved_execution_plan
1686            .as_ref()
1687            .is_some_and(|plan| plan.backend == ExecutionBackend::Crun)
1688            || self.config.isolation.is_sandbox()
1689        {
1690            return Err(BoxError::StateError(
1691                "Pause is not supported by the Sandbox backend yet".to_string(),
1692            ));
1693        }
1694
1695        if let Some(pid) = self.pid().await {
1696            // Safety: sending SIGSTOP to pause the process
1697            let ret = unsafe { libc::kill(pid as i32, libc::SIGSTOP) };
1698            if ret != 0 {
1699                let err = std::io::Error::last_os_error();
1700                return Err(BoxError::ExecError(format!(
1701                    "Failed to send SIGSTOP to pid {}: {}",
1702                    pid, err
1703                )));
1704            }
1705            tracing::info!(box_id = %self.box_id, pid, "VM paused");
1706            Ok(())
1707        } else {
1708            Err(BoxError::StateError(
1709                "VM has no running process".to_string(),
1710            ))
1711        }
1712    }
1713
1714    /// Resume the VM by sending SIGCONT to the shim process.
1715    ///
1716    /// Can be called on a paused VM to resume execution.
1717    #[cfg(unix)]
1718    pub async fn resume(&self) -> Result<()> {
1719        if self
1720            .resolved_execution_plan
1721            .as_ref()
1722            .is_some_and(|plan| plan.backend == ExecutionBackend::Crun)
1723            || self.config.isolation.is_sandbox()
1724        {
1725            return Err(BoxError::StateError(
1726                "Resume is not supported by the Sandbox backend yet".to_string(),
1727            ));
1728        }
1729        if let Some(pid) = self.pid().await {
1730            // Safety: sending SIGCONT to resume the process
1731            let ret = unsafe { libc::kill(pid as i32, libc::SIGCONT) };
1732            if ret != 0 {
1733                let err = std::io::Error::last_os_error();
1734                return Err(BoxError::ExecError(format!(
1735                    "Failed to send SIGCONT to pid {}: {}",
1736                    pid, err
1737                )));
1738            }
1739            tracing::info!(box_id = %self.box_id, pid, "VM resumed");
1740            Ok(())
1741        } else {
1742            Err(BoxError::StateError(
1743                "VM has no running process".to_string(),
1744            ))
1745        }
1746    }
1747
1748    /// Pause the VM (Windows stub - not yet implemented).
1749    #[cfg(windows)]
1750    pub async fn pause(&self) -> Result<()> {
1751        Err(BoxError::StateError(
1752            "VM pause is not yet supported on Windows".to_string(),
1753        ))
1754    }
1755
1756    /// Resume the VM (Windows stub - not yet implemented).
1757    #[cfg(windows)]
1758    pub async fn resume(&self) -> Result<()> {
1759        Err(BoxError::StateError(
1760            "VM resume is not yet supported on Windows".to_string(),
1761        ))
1762    }
1763
1764    /// Check if VM is healthy.
1765    pub async fn health_check(&self) -> Result<bool> {
1766        let state = self.state.read().await;
1767
1768        match *state {
1769            BoxState::Ready | BoxState::Busy | BoxState::Compacting => {
1770                // Check if handler reports VM is running
1771                if let Some(ref handler) = *self.handler.read().await {
1772                    Ok(handler.is_running())
1773                } else {
1774                    Ok(false)
1775                }
1776            }
1777            _ => Ok(false),
1778        }
1779    }
1780
1781    /// Get VM metrics.
1782    pub async fn metrics(&self) -> Option<crate::vmm::VmMetrics> {
1783        let vm_metrics = self
1784            .handler
1785            .read()
1786            .await
1787            .as_ref()
1788            .map(|handler| handler.metrics())?;
1789
1790        // Update per-VM Prometheus gauges if metrics are attached
1791        if let Some(ref prom) = self.prom {
1792            prom.vm_cpu_percent
1793                .with_label_values(&[&self.box_id])
1794                .set(vm_metrics.cpu_percent.unwrap_or(0.0) as f64);
1795            prom.vm_memory_bytes
1796                .with_label_values(&[&self.box_id])
1797                .set(vm_metrics.memory_bytes.unwrap_or(0) as f64);
1798        }
1799
1800        Some(vm_metrics)
1801    }
1802
1803    /// Get the PID of the VM shim process.
1804    pub async fn pid(&self) -> Option<u32> {
1805        self.handler
1806            .read()
1807            .await
1808            .as_ref()
1809            .map(|handler| handler.pid())
1810    }
1811
1812    /// Get the TEE extension, if TEE is configured and VM is booted.
1813    #[cfg(unix)]
1814    pub fn tee(&self) -> Option<&dyn TeeExtension> {
1815        self.tee.as_deref()
1816    }
1817
1818    /// Get the TEE extension or return an error.
1819    #[cfg(unix)]
1820    pub fn require_tee(&self) -> Result<&dyn TeeExtension> {
1821        self.tee.as_deref().ok_or_else(|| {
1822            BoxError::AttestationError("TEE is not configured for this box".to_string())
1823        })
1824    }
1825
1826    /// Apply a live resource update to the running VM.
1827    ///
1828    /// Tier 1 changes (vCPU count, memory size) are rejected with a clear error
1829    /// because libkrun does not expose a hot-resize API.
1830    ///
1831    /// Tier 2 changes (cgroup-based limits) are applied by executing shell
1832    /// commands inside the guest that write to cgroup v2 control files.
1833    #[cfg(unix)]
1834    pub async fn update_resources(
1835        &self,
1836        update: &crate::resize::ResourceUpdate,
1837    ) -> Result<crate::resize::ResizeResult> {
1838        if self
1839            .resolved_execution_plan
1840            .as_ref()
1841            .is_some_and(|plan| plan.backend == ExecutionBackend::Crun)
1842            || self.config.isolation.is_sandbox()
1843        {
1844            return Err(BoxError::StateError(
1845                "Live resource updates are not supported by the Sandbox backend yet".to_string(),
1846            ));
1847        }
1848        // Reject Tier 1 changes upfront
1849        crate::resize::validate_update(update)?;
1850
1851        let mut result = crate::resize::ResizeResult {
1852            applied: Vec::new(),
1853            rejected: Vec::new(),
1854        };
1855
1856        if !update.has_tier2_changes() {
1857            return Ok(result);
1858        }
1859
1860        // Build cgroup commands and execute them inside the guest
1861        let commands = update.build_cgroup_commands();
1862        for cmd_str in &commands {
1863            let shell_cmd = vec!["sh".to_string(), "-c".to_string(), cmd_str.clone()];
1864
1865            match self.exec_command(shell_cmd, 5_000_000_000).await {
1866                Ok(output) if output.exit_code == 0 => {
1867                    result.applied.push(cmd_str.clone());
1868                }
1869                Ok(output) => {
1870                    let stderr = String::from_utf8_lossy(&output.stderr);
1871                    let reason = if stderr.trim().is_empty() {
1872                        format!("exit code {}", output.exit_code)
1873                    } else {
1874                        stderr.trim().to_string()
1875                    };
1876                    tracing::warn!(
1877                        box_id = %self.box_id,
1878                        cmd = %cmd_str,
1879                        exit_code = output.exit_code,
1880                        stderr = %stderr,
1881                        "Cgroup update failed inside guest"
1882                    );
1883                    result.rejected.push((cmd_str.clone(), reason));
1884                }
1885                Err(e) => {
1886                    tracing::warn!(
1887                        box_id = %self.box_id,
1888                        cmd = %cmd_str,
1889                        error = %e,
1890                        "Failed to exec cgroup update in guest"
1891                    );
1892                    result.rejected.push((cmd_str.clone(), e.to_string()));
1893                }
1894            }
1895        }
1896
1897        Ok(result)
1898    }
1899}
1900
1901/// Whether this boot is a snapshot-fork restore (the guest is resumed already-booted
1902/// rather than cold-booted). PER-VM: a pool / fork daemon sets `config.restore_from`
1903/// so one process can restore different VMs; the single-VM `run` path uses the
1904/// `KRUN_RESTORE_FROM` env. Either source means restore mode.
1905#[cfg(unix)]
1906fn is_restore_mode(config: &BoxConfig) -> bool {
1907    config
1908        .restore_from
1909        .as_deref()
1910        .is_some_and(|s| !s.is_empty())
1911        || std::env::var("KRUN_RESTORE_FROM")
1912            .map(|v| !v.is_empty())
1913            .unwrap_or(false)
1914}
1915
1916/// Simple FNV-1a hash for generating short deterministic hashes from strings.
1917pub(crate) fn fnv1a_hash(input: &str) -> u64 {
1918    let mut hash: u64 = 0xcbf29ce484222325;
1919    for byte in input.bytes() {
1920        hash ^= byte as u64;
1921        hash = hash.wrapping_mul(0x100000001b3);
1922    }
1923    hash
1924}
1925
1926#[cfg(unix)]
1927fn default_stop_signal() -> i32 {
1928    libc::SIGTERM
1929}
1930
1931#[cfg(windows)]
1932fn default_stop_signal() -> i32 {
1933    15
1934}
1935
1936#[cfg(test)]
1937mod tests {
1938    use super::*;
1939    use a3s_box_core::event::EventEmitter;
1940    use std::sync::{
1941        atomic::{AtomicBool, Ordering},
1942        Arc,
1943    };
1944
1945    struct RecordingHandler {
1946        stopped: Arc<AtomicBool>,
1947    }
1948
1949    impl VmHandler for RecordingHandler {
1950        fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
1951            self.stopped.store(true, Ordering::SeqCst);
1952            Ok(())
1953        }
1954
1955        fn metrics(&self) -> crate::vmm::VmMetrics {
1956            crate::vmm::VmMetrics::default()
1957        }
1958
1959        fn is_running(&self) -> bool {
1960            true
1961        }
1962
1963        fn pid(&self) -> u32 {
1964            42
1965        }
1966    }
1967
1968    struct ExitStateHandler {
1969        exited: bool,
1970    }
1971
1972    impl VmHandler for ExitStateHandler {
1973        fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
1974            Ok(())
1975        }
1976
1977        fn metrics(&self) -> crate::vmm::VmMetrics {
1978            crate::vmm::VmMetrics::default()
1979        }
1980
1981        fn is_running(&self) -> bool {
1982            !self.exited
1983        }
1984
1985        fn has_exited(&self) -> bool {
1986            self.exited
1987        }
1988
1989        fn pid(&self) -> u32 {
1990            42
1991        }
1992    }
1993
1994    struct CompletedHandler {
1995        code: i32,
1996    }
1997
1998    impl VmHandler for CompletedHandler {
1999        fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
2000            Ok(())
2001        }
2002
2003        fn metrics(&self) -> crate::vmm::VmMetrics {
2004            crate::vmm::VmMetrics::default()
2005        }
2006
2007        fn is_running(&self) -> bool {
2008            false
2009        }
2010
2011        fn has_exited(&self) -> bool {
2012            true
2013        }
2014
2015        fn pid(&self) -> u32 {
2016            42
2017        }
2018
2019        fn try_wait_exit(&mut self) -> Result<Option<i32>> {
2020            Ok(Some(self.code))
2021        }
2022    }
2023
2024    /// A handler whose `stop` always fails — models a wedged VM that won't halt.
2025    struct FailingHandler;
2026
2027    impl VmHandler for FailingHandler {
2028        fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
2029            Err(BoxError::StateError("simulated stop failure".to_string()))
2030        }
2031
2032        fn metrics(&self) -> crate::vmm::VmMetrics {
2033            crate::vmm::VmMetrics::default()
2034        }
2035
2036        fn is_running(&self) -> bool {
2037            true
2038        }
2039
2040        fn pid(&self) -> u32 {
2041            42
2042        }
2043    }
2044
2045    // Regression: a handler-stop failure must still run the host teardown
2046    // (overlay unmount, socket + box dirs). Pre-fix, destroy_with_options
2047    // returned early on the stop error and leaked the box directory on every
2048    // wedged stop.
2049    #[tokio::test]
2050    async fn test_destroy_runs_host_teardown_even_when_handler_stop_fails() {
2051        let tmp = tempfile::tempdir().unwrap();
2052        let box_id = "box-stopfail".to_string();
2053        // persistent=false (the default) → the box dir is removed on destroy.
2054        let mut vm =
2055            VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
2056        vm.home_dir = tmp.path().to_path_buf();
2057        *vm.handler.write().await = Some(Box::new(FailingHandler));
2058
2059        let box_dir = tmp.path().join("boxes").join(&box_id);
2060        std::fs::create_dir_all(box_dir.join("logs")).unwrap();
2061
2062        let result = vm.destroy_with_options(default_stop_signal(), 100).await;
2063
2064        // The stop failure is still surfaced to the caller...
2065        assert!(
2066            result.is_err(),
2067            "a handler-stop failure must still be reported"
2068        );
2069        // ...but the host teardown ran anyway: handler taken + box dir removed.
2070        assert!(vm.handler.read().await.is_none());
2071        assert!(
2072            !box_dir.exists(),
2073            "non-persistent box dir must be removed even when the stop failed"
2074        );
2075    }
2076
2077    #[tokio::test]
2078    async fn test_cleanup_boot_failure_stops_handler_and_removes_created_volumes() {
2079        let tmp = tempfile::tempdir().unwrap();
2080        let box_id = "box-test".to_string();
2081        let mut vm =
2082            VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
2083        vm.home_dir = tmp.path().to_path_buf();
2084        vm.anonymous_volumes = vec!["created-volume".to_string(), "reused-volume".to_string()];
2085        vm.created_anonymous_volumes = vec!["created-volume".to_string()];
2086
2087        let stopped = Arc::new(AtomicBool::new(false));
2088        *vm.handler.write().await = Some(Box::new(RecordingHandler {
2089            stopped: stopped.clone(),
2090        }));
2091
2092        let box_dir = tmp.path().join("boxes").join(&box_id);
2093        std::fs::create_dir_all(box_dir.join("logs")).unwrap();
2094
2095        let store = crate::volume::VolumeStore::new(
2096            tmp.path().join("volumes.json"),
2097            tmp.path().join("volumes"),
2098        );
2099        store
2100            .create(a3s_box_core::volume::VolumeConfig::new(
2101                "created-volume",
2102                "",
2103            ))
2104            .unwrap();
2105        store
2106            .create(a3s_box_core::volume::VolumeConfig::new("reused-volume", ""))
2107            .unwrap();
2108
2109        vm.cleanup_boot_failure().await;
2110
2111        assert!(stopped.load(Ordering::SeqCst));
2112        assert!(vm.handler.read().await.is_none());
2113        assert!(vm.created_anonymous_volumes.is_empty());
2114        assert_eq!(vm.anonymous_volumes, vec!["reused-volume".to_string()]);
2115        assert!(store.get("created-volume").unwrap().is_none());
2116        assert!(store.get("reused-volume").unwrap().is_some());
2117        assert!(!box_dir.exists());
2118    }
2119
2120    #[tokio::test]
2121    async fn test_cleanup_boot_failure_preserves_persistent_rootfs() {
2122        let tmp = tempfile::tempdir().unwrap();
2123        let box_id = "box-persistent-boot-failure".to_string();
2124        let config = BoxConfig {
2125            persistent: true,
2126            ..BoxConfig::default()
2127        };
2128        let mut vm = VmManager::with_box_id(config, EventEmitter::new(16), box_id.clone());
2129        vm.home_dir = tmp.path().to_path_buf();
2130        vm.set_rootfs_provider(Box::new(crate::rootfs::CopyProvider));
2131
2132        let box_dir = tmp.path().join("boxes").join(&box_id);
2133        let sentinel = box_dir.join("rootfs/var/lib/application/data.db");
2134        std::fs::create_dir_all(sentinel.parent().unwrap()).unwrap();
2135        std::fs::write(&sentinel, b"persistent guest data").unwrap();
2136        std::fs::create_dir_all(vm.socket_dir()).unwrap();
2137        std::fs::write(vm.socket_dir().join("stale.sock"), b"stale").unwrap();
2138        vm.preserve_rootfs_on_boot_failure = true;
2139
2140        vm.cleanup_boot_failure().await;
2141
2142        assert_eq!(
2143            std::fs::read(&sentinel).unwrap(),
2144            b"persistent guest data",
2145            "a failed restart must not erase the persistent writable rootfs"
2146        );
2147        assert!(box_dir.exists());
2148        assert!(
2149            !vm.socket_dir().exists(),
2150            "transient sockets should still be removed after a failed restart"
2151        );
2152    }
2153
2154    #[tokio::test]
2155    async fn test_cleanup_boot_failure_discards_partial_first_persistent_rootfs() {
2156        let tmp = tempfile::tempdir().unwrap();
2157        let box_id = "box-partial-first-boot".to_string();
2158        let config = BoxConfig {
2159            persistent: true,
2160            ..BoxConfig::default()
2161        };
2162        let mut vm = VmManager::with_box_id(config, EventEmitter::new(16), box_id.clone());
2163        vm.home_dir = tmp.path().to_path_buf();
2164        vm.set_rootfs_provider(Box::new(crate::rootfs::CopyProvider));
2165
2166        let box_dir = tmp.path().join("boxes").join(&box_id);
2167        let partial = box_dir.join("rootfs/partially-extracted");
2168        std::fs::create_dir_all(partial.parent().unwrap()).unwrap();
2169        std::fs::write(&partial, b"incomplete").unwrap();
2170
2171        vm.cleanup_boot_failure().await;
2172
2173        assert!(box_dir.exists(), "a retained box keeps its host directory");
2174        assert!(
2175            !box_dir.join("rootfs").exists(),
2176            "a failed first boot must discard its incomplete rootfs generation"
2177        );
2178    }
2179
2180    #[tokio::test]
2181    async fn test_wait_for_vm_running_returns_error_when_handler_exited() {
2182        let vm = VmManager::with_box_id(
2183            BoxConfig::default(),
2184            EventEmitter::new(16),
2185            "box-exited".to_string(),
2186        );
2187        *vm.handler.write().await = Some(Box::new(ExitStateHandler { exited: true }));
2188
2189        let err = vm.wait_for_vm_running().await.unwrap_err();
2190
2191        assert!(err
2192            .to_string()
2193            .contains("VM process exited immediately after start"));
2194    }
2195
2196    #[tokio::test]
2197    async fn test_wait_for_vm_running_succeeds_when_handler_stays_running() {
2198        let config = BoxConfig {
2199            restore_from: Some("snapshot-path".to_string()),
2200            ..BoxConfig::default()
2201        };
2202        let vm = VmManager::with_box_id(config, EventEmitter::new(16), "box-running".to_string());
2203        *vm.handler.write().await = Some(Box::new(ExitStateHandler { exited: false }));
2204
2205        vm.wait_for_vm_running().await.unwrap();
2206    }
2207
2208    #[cfg(not(target_os = "windows"))]
2209    #[tokio::test]
2210    async fn test_try_wait_exit_reads_guest_persisted_exit_code() {
2211        let tmp = tempfile::tempdir().unwrap();
2212        let box_id = "box-exit-code".to_string();
2213        let mut vm =
2214            VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
2215        vm.home_dir = tmp.path().to_path_buf();
2216
2217        let exit_path = tmp
2218            .path()
2219            .join("boxes")
2220            .join(&box_id)
2221            .join("upper")
2222            .join(".a3s_exit_code");
2223        std::fs::create_dir_all(exit_path.parent().unwrap()).unwrap();
2224        std::fs::write(&exit_path, "42\n").unwrap();
2225
2226        assert_eq!(vm.try_wait_exit().await.unwrap(), Some(42));
2227        assert_eq!(vm.exit_code(), Some(42));
2228        assert!(vm.has_exited().await);
2229    }
2230
2231    #[tokio::test]
2232    async fn test_try_wait_exit_reads_windows_rootfs_exit_code() {
2233        let tmp = tempfile::tempdir().unwrap();
2234        let box_id = "box-windows-exit-code".to_string();
2235        let mut vm =
2236            VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
2237        vm.home_dir = tmp.path().to_path_buf();
2238        *vm.handler.write().await = Some(Box::new(CompletedHandler { code: 23 }));
2239
2240        let exit_path = tmp
2241            .path()
2242            .join("boxes")
2243            .join(&box_id)
2244            .join("rootfs")
2245            .join(".a3s_exit_code");
2246        std::fs::create_dir_all(exit_path.parent().unwrap()).unwrap();
2247        std::fs::write(&exit_path, "23\n").unwrap();
2248        #[cfg(target_os = "windows")]
2249        {
2250            std::fs::write(
2251                exit_path.parent().unwrap().join(WINDOWS_GUEST_STDOUT),
2252                "guest stdout\n",
2253            )
2254            .unwrap();
2255            std::fs::write(
2256                exit_path.parent().unwrap().join(WINDOWS_GUEST_STDERR),
2257                concat!(
2258                    "init.krun: mount_filesystems ok\n",
2259                    "init.krun: execvp(/bin/app) starting\n",
2260                    "guest stderr\n",
2261                ),
2262            )
2263            .unwrap();
2264        }
2265
2266        assert_eq!(vm.try_wait_exit().await.unwrap(), Some(23));
2267        assert_eq!(vm.exit_code(), Some(23));
2268        assert!(vm.has_exited().await);
2269        #[cfg(target_os = "windows")]
2270        {
2271            let logs = tmp.path().join("boxes").join(&box_id).join("logs");
2272            assert_eq!(
2273                std::fs::read_to_string(logs.join("console.log")).unwrap(),
2274                "guest stdout\n"
2275            );
2276            assert_eq!(
2277                std::fs::read_to_string(logs.join("console.err.log")).unwrap(),
2278                "guest stderr\n"
2279            );
2280            let json = std::fs::read_to_string(logs.join("container.json")).unwrap();
2281            assert!(json.contains("\"log\":\"guest stdout\\n\""));
2282            assert!(json.contains("\"log\":\"guest stderr\\n\""));
2283            assert!(!json.contains("init.krun"));
2284        }
2285    }
2286
2287    #[cfg(target_os = "windows")]
2288    #[test]
2289    fn test_append_windows_guest_stream_uses_shared_phase_and_keeps_partial_lines() {
2290        let tmp = tempfile::tempdir().unwrap();
2291        let stdout_source = tmp.path().join("stdout.source");
2292        let stderr_source = tmp.path().join("stderr.source");
2293        let stdout_destination = tmp.path().join("stdout.destination");
2294        let stderr_destination = tmp.path().join("stderr.destination");
2295        std::fs::write(
2296            &stdout_source,
2297            concat!(
2298                "init.krun: mount_filesystems ok\n",
2299                "init.krun: business\n",
2300                "init.krun: config parsed",
2301            ),
2302        )
2303        .unwrap();
2304        std::fs::write(
2305            &stderr_source,
2306            concat!(
2307                "init.krun: execvp(/bin/app) starting\n",
2308                "init.krun: mount_filesystems ok\n",
2309            ),
2310        )
2311        .unwrap();
2312
2313        let filter = a3s_box_core::log::RuntimeConsoleFilter::new();
2314        append_windows_guest_stream(&stdout_source, &stdout_destination, &filter).unwrap();
2315        append_windows_guest_stream(&stderr_source, &stderr_destination, &filter).unwrap();
2316
2317        assert_eq!(
2318            std::fs::read_to_string(stdout_destination).unwrap(),
2319            "init.krun: business\ninit.krun: config parsed"
2320        );
2321        assert_eq!(
2322            std::fs::read_to_string(stderr_destination).unwrap(),
2323            "init.krun: mount_filesystems ok\n"
2324        );
2325    }
2326
2327    #[cfg(target_os = "windows")]
2328    #[test]
2329    fn test_collect_windows_guest_result_is_idempotent() {
2330        let tmp = tempfile::tempdir().unwrap();
2331        let box_dir = tmp.path().join("box");
2332        let rootfs = box_dir.join("rootfs");
2333        std::fs::create_dir_all(&rootfs).unwrap();
2334        std::fs::write(rootfs.join(WINDOWS_GUEST_STDOUT), "once\n").unwrap();
2335        std::fs::write(rootfs.join(WINDOWS_GUEST_STDERR), "error once\n").unwrap();
2336        std::fs::write(rootfs.join(WINDOWS_GUEST_EXIT_CODE), "7\n").unwrap();
2337
2338        let config = a3s_box_core::log::LogConfig::default();
2339        assert_eq!(
2340            collect_windows_guest_result(&box_dir, &config, 0).unwrap(),
2341            7
2342        );
2343        assert_eq!(
2344            collect_windows_guest_result(&box_dir, &config, 0).unwrap(),
2345            7
2346        );
2347
2348        let logs = box_dir.join("logs");
2349        assert_eq!(
2350            std::fs::read_to_string(logs.join("console.log")).unwrap(),
2351            "once\n"
2352        );
2353        assert_eq!(
2354            std::fs::read_to_string(logs.join("console.err.log")).unwrap(),
2355            "error once\n"
2356        );
2357        let json = std::fs::read_to_string(logs.join("container.json")).unwrap();
2358        assert_eq!(json.matches("\"log\":\"once\\n\"").count(), 1);
2359        assert_eq!(json.matches("\"log\":\"error once\\n\"").count(), 1);
2360    }
2361
2362    #[cfg(target_os = "windows")]
2363    #[test]
2364    fn test_collect_windows_guest_result_does_not_replay_drained_live_logs() {
2365        let tmp = tempfile::tempdir().unwrap();
2366        let box_dir = tmp.path().join("box");
2367        let rootfs = box_dir.join("rootfs");
2368        let logs = box_dir.join("logs");
2369        std::fs::create_dir_all(&rootfs).unwrap();
2370        std::fs::create_dir_all(&logs).unwrap();
2371        std::fs::write(rootfs.join(WINDOWS_GUEST_STDOUT), "live once\n").unwrap();
2372        std::fs::write(rootfs.join(WINDOWS_GUEST_STDERR), "live error once\n").unwrap();
2373        std::fs::write(rootfs.join(WINDOWS_GUEST_EXIT_CODE), "4\n").unwrap();
2374        std::fs::write(rootfs.join(WINDOWS_LIVE_LOGS_DRAINED_MARKER), "drained\n").unwrap();
2375        let live_json =
2376            "{\"log\":\"live once\\n\",\"stream\":\"stdout\",\"time\":\"2026-01-01T00:00:00Z\"}\n";
2377        std::fs::write(logs.join("container.json"), live_json).unwrap();
2378
2379        let config = a3s_box_core::log::LogConfig::default();
2380        assert_eq!(
2381            collect_windows_guest_result(&box_dir, &config, 0).unwrap(),
2382            4
2383        );
2384
2385        assert_eq!(
2386            std::fs::read_to_string(logs.join("container.json")).unwrap(),
2387            live_json
2388        );
2389        assert_eq!(
2390            std::fs::read_to_string(logs.join("console.log")).unwrap(),
2391            "live once\n"
2392        );
2393        assert_eq!(
2394            std::fs::read_to_string(logs.join("console.err.log")).unwrap(),
2395            "live error once\n"
2396        );
2397        assert!(rootfs.join(WINDOWS_GUEST_RESULT_MARKER).exists());
2398    }
2399
2400    #[cfg(target_os = "windows")]
2401    #[test]
2402    fn test_collect_windows_guest_result_replaces_marker_symlink_without_touching_target() {
2403        let tmp = tempfile::tempdir().unwrap();
2404        let box_dir = tmp.path().join("box");
2405        let rootfs = box_dir.join("rootfs");
2406        std::fs::create_dir_all(&rootfs).unwrap();
2407        std::fs::write(rootfs.join(WINDOWS_GUEST_STDOUT), "safe output\n").unwrap();
2408        std::fs::write(rootfs.join(WINDOWS_GUEST_STDERR), "").unwrap();
2409        std::fs::write(rootfs.join(WINDOWS_GUEST_EXIT_CODE), "0\n").unwrap();
2410
2411        let host_target = tmp.path().join("host-target.txt");
2412        std::fs::write(&host_target, "host secret").unwrap();
2413        let marker = rootfs.join(WINDOWS_GUEST_RESULT_MARKER);
2414        match std::os::windows::fs::symlink_file(&host_target, &marker) {
2415            Ok(()) => {}
2416            Err(error) if error.raw_os_error() == Some(1314) => return,
2417            Err(error) => panic!("failed to create marker symlink: {error}"),
2418        }
2419
2420        let config = a3s_box_core::log::LogConfig::default();
2421        assert_eq!(
2422            collect_windows_guest_result(&box_dir, &config, 0).unwrap(),
2423            0
2424        );
2425        assert_eq!(
2426            std::fs::read_to_string(&host_target).unwrap(),
2427            "host secret"
2428        );
2429        assert_eq!(std::fs::read_to_string(&marker).unwrap(), "collected\n");
2430        assert!(!std::fs::symlink_metadata(marker)
2431            .unwrap()
2432            .file_type()
2433            .is_symlink());
2434    }
2435
2436    #[cfg(target_os = "windows")]
2437    #[test]
2438    fn test_collect_windows_guest_result_refuses_stream_symlink() {
2439        let tmp = tempfile::tempdir().unwrap();
2440        let box_dir = tmp.path().join("box");
2441        let rootfs = box_dir.join("rootfs");
2442        std::fs::create_dir_all(&rootfs).unwrap();
2443        let host_secret = tmp.path().join("host-secret.txt");
2444        std::fs::write(&host_secret, "must not be logged\n").unwrap();
2445        match std::os::windows::fs::symlink_file(&host_secret, rootfs.join(WINDOWS_GUEST_STDOUT)) {
2446            Ok(()) => {}
2447            Err(error) if error.raw_os_error() == Some(1314) => return,
2448            Err(error) => panic!("failed to create stream symlink: {error}"),
2449        }
2450        std::fs::write(rootfs.join(WINDOWS_GUEST_STDERR), "").unwrap();
2451        std::fs::write(rootfs.join(WINDOWS_GUEST_EXIT_CODE), "0\n").unwrap();
2452
2453        let config = a3s_box_core::log::LogConfig::default();
2454        let error = collect_windows_guest_result(&box_dir, &config, 0)
2455            .unwrap_err()
2456            .to_string();
2457
2458        assert!(error.contains("Failed to collect Windows guest output"));
2459        let console = box_dir.join("logs").join("console.log");
2460        assert!(
2461            !console.exists()
2462                || !std::fs::read_to_string(console)
2463                    .unwrap()
2464                    .contains("must not")
2465        );
2466    }
2467
2468    #[cfg(target_os = "windows")]
2469    #[test]
2470    fn test_collect_windows_guest_result_rejects_false_success() {
2471        let tmp = tempfile::tempdir().unwrap();
2472        let box_dir = tmp.path().join("box");
2473        std::fs::create_dir_all(box_dir.join("rootfs")).unwrap();
2474        let config = a3s_box_core::log::LogConfig::default();
2475
2476        let error = collect_windows_guest_result(&box_dir, &config, 0)
2477            .unwrap_err()
2478            .to_string();
2479        assert!(error.contains("before the guest persisted its exit code"));
2480        assert_eq!(
2481            collect_windows_guest_result(&box_dir, &config, 9).unwrap(),
2482            9
2483        );
2484    }
2485
2486    #[cfg(target_os = "windows")]
2487    #[tokio::test]
2488    async fn test_windows_exit_file_waits_for_shim_log_relay() {
2489        let tmp = tempfile::tempdir().unwrap();
2490        let box_id = "box-windows-pending-relay".to_string();
2491        let mut vm =
2492            VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
2493        vm.home_dir = tmp.path().to_path_buf();
2494        *vm.handler.write().await = Some(Box::new(RecordingHandler {
2495            stopped: Arc::new(AtomicBool::new(false)),
2496        }));
2497
2498        let exit_path = tmp
2499            .path()
2500            .join("boxes")
2501            .join(&box_id)
2502            .join("rootfs")
2503            .join(".a3s_exit_code");
2504        std::fs::create_dir_all(exit_path.parent().unwrap()).unwrap();
2505        std::fs::write(exit_path, "0\n").unwrap();
2506
2507        assert_eq!(vm.try_wait_exit().await.unwrap(), None);
2508        assert_eq!(vm.exit_code(), None);
2509        assert!(!vm.has_exited().await);
2510    }
2511
2512    #[cfg(unix)]
2513    #[tokio::test]
2514    async fn test_wait_for_exec_ready_returns_when_handler_already_exited() {
2515        let mut vm = VmManager::with_box_id(
2516            BoxConfig::default(),
2517            EventEmitter::new(16),
2518            "box-exec-exited".to_string(),
2519        );
2520        *vm.handler.write().await = Some(Box::new(ExitStateHandler { exited: true }));
2521        let tmp = tempfile::tempdir().unwrap();
2522
2523        vm.wait_for_exec_ready(&tmp.path().join("missing-exec.sock"))
2524            .await
2525            .unwrap();
2526
2527        assert!(vm.exec_client.is_none());
2528    }
2529
2530    #[cfg(unix)]
2531    #[tokio::test]
2532    async fn test_wait_for_exec_ready_returns_when_guest_exit_code_persisted() {
2533        let tmp = tempfile::tempdir().unwrap();
2534        let box_id = "box-exec-finished".to_string();
2535        let mut vm =
2536            VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
2537        vm.home_dir = tmp.path().to_path_buf();
2538
2539        let exit_path = tmp
2540            .path()
2541            .join("boxes")
2542            .join(&box_id)
2543            .join("upper")
2544            .join(".a3s_exit_code");
2545        std::fs::create_dir_all(exit_path.parent().unwrap()).unwrap();
2546        std::fs::write(&exit_path, "17\n").unwrap();
2547
2548        tokio::time::timeout(
2549            std::time::Duration::from_secs(1),
2550            vm.wait_for_exec_ready(&tmp.path().join("missing-exec.sock")),
2551        )
2552        .await
2553        .unwrap()
2554        .unwrap();
2555
2556        assert_eq!(vm.exit_code(), Some(17));
2557        assert!(vm.exec_client.is_none());
2558    }
2559
2560    #[cfg(unix)]
2561    #[tokio::test]
2562    async fn test_probe_exec_ready_once_ignores_missing_socket() {
2563        let mut vm = VmManager::with_box_id(
2564            BoxConfig::default(),
2565            EventEmitter::new(16),
2566            "box-probe".to_string(),
2567        );
2568        let tmp = tempfile::tempdir().unwrap();
2569
2570        vm.probe_exec_ready_once(&tmp.path().join("missing-exec.sock"))
2571            .await;
2572
2573        assert!(vm.exec_client.is_none());
2574    }
2575
2576    #[cfg(unix)]
2577    #[tokio::test]
2578    async fn test_attach_running_process_infers_port_forward_socket_path() {
2579        let mut vm = VmManager::with_box_id(
2580            BoxConfig::default(),
2581            EventEmitter::new(16),
2582            "box-test".to_string(),
2583        );
2584        let tmp = tempfile::tempdir().unwrap();
2585        let exec_socket_path = tmp.path().join("exec.sock");
2586        let pty_socket_path = Some(tmp.path().join("pty.sock"));
2587
2588        vm.attach_running_process(
2589            std::process::id(),
2590            exec_socket_path.clone(),
2591            pty_socket_path.clone(),
2592        )
2593        .await
2594        .unwrap();
2595
2596        assert_eq!(vm.exec_socket_path(), Some(exec_socket_path.as_path()));
2597        assert_eq!(vm.pty_socket_path(), pty_socket_path.as_deref());
2598        assert_eq!(
2599            vm.port_forward_socket_path(),
2600            Some(exec_socket_path.with_file_name("portfwd.sock").as_path())
2601        );
2602        assert_eq!(vm.state().await, BoxState::Ready);
2603    }
2604}