Skip to main content

a3s_box_runtime/vm/
mod.rs

1//! VM Manager - Lifecycle management for MicroVM instances.
2
3mod boot;
4mod execution;
5mod layout;
6mod lifecycle;
7mod maintenance;
8mod network;
9mod oci_microvm;
10mod ready;
11pub mod reap;
12mod sandbox;
13mod spec;
14#[cfg(windows)]
15mod windows_stop;
16
17pub(crate) use layout::{
18    legacy_sandbox_runtime_root, persistent_rootfs_generation_exists, runtime_socket_dir,
19    sandbox_runtime_root,
20};
21pub use maintenance::archive_stopped_guest_native_rootfs;
22
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26/// Callback type for image pull progress: `(current, total, digest, size_bytes)`.
27pub type PullProgressFn = Arc<dyn Fn(usize, usize, &str, i64) + Send + Sync>;
28
29use a3s_box_core::config::BoxConfig;
30#[cfg(unix)]
31use a3s_box_core::config::TeeConfig;
32use a3s_box_core::error::{BoxError, Result};
33use a3s_box_core::event::{BoxEvent, EventEmitter};
34use a3s_box_core::execution::ResolvedExecutionPlan;
35use serde::{Deserialize, Serialize};
36use tokio::sync::RwLock;
37use tracing::Instrument;
38
39#[cfg(unix)]
40use libc;
41
42#[cfg(unix)]
43use crate::grpc::ExecClient;
44#[cfg(unix)]
45use crate::tee::TeeExtension;
46use crate::vmm::{VmController, VmHandler, VmmProvider, DEFAULT_SHUTDOWN_TIMEOUT_MS};
47
48/// Box state machine.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum BoxState {
51    /// Config captured, no VM started
52    Created,
53
54    /// VM booted, container initialized, gRPC healthy
55    Ready,
56
57    /// A session is actively processing a prompt
58    Busy,
59
60    /// A session is compressing its context
61    Compacting,
62
63    /// VM terminated, resources freed
64    Stopped,
65}
66
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
68enum VmBootMode {
69    #[default]
70    Workload,
71    RootfsMaintenance,
72}
73
74/// Layout of directories for a box instance.
75pub(crate) struct BoxLayout {
76    /// Host staging path for a fresh directory-derived generation.
77    ///
78    /// This path is intentionally non-authoritative when `resumed_rootfs` is
79    /// present; directly assembled and resumed guest-owned disks have no host
80    /// directory view.
81    pub(crate) rootfs_path: PathBuf,
82    /// Guest-owned generation finalized before the directory staging boundary.
83    /// This covers direct OCI assembly and persistent restart.
84    pub(crate) resumed_rootfs: Option<crate::rootfs::ResumedRootfs>,
85    /// Path to the exec Unix socket
86    pub(crate) exec_socket_path: PathBuf,
87    /// Path to the PTY Unix socket
88    pub(crate) pty_socket_path: PathBuf,
89    /// Path to the attestation Unix socket
90    pub(crate) attest_socket_path: PathBuf,
91    /// Path to the CRI port-forward Unix socket
92    pub(crate) port_forward_socket_path: PathBuf,
93    /// Path to the workspace directory
94    pub(crate) workspace_path: PathBuf,
95    /// Path to console output file (optional)
96    pub(crate) console_output: Option<PathBuf>,
97    /// OCI image config (entrypoint, env, working dir, volumes)
98    pub(crate) oci_config: Option<crate::oci::OciImageConfig>,
99    /// Exact resolved OCI manifest behind a fresh image-derived generation.
100    /// Snapshot and externally prebuilt roots intentionally have no reusable
101    /// base identity until their own generation protocol is implemented.
102    #[cfg(target_os = "macos")]
103    pub(crate) oci_manifest_digest: Option<String>,
104    /// Fresh image/cache rootfs generations must ignore any terminal manifest
105    /// baked into an older malicious image. Persistent and Snapshot generations
106    /// instead prefer the terminal manifest captured after guest writes.
107    pub(crate) prefer_image_rootfs_metadata: bool,
108    /// TEE instance configuration (if TEE is enabled)
109    pub(crate) tee_instance_config: Option<crate::vmm::TeeInstanceConfig>,
110}
111
112#[cfg(target_os = "windows")]
113const WINDOWS_GUEST_EXIT_CODE: &str = ".a3s_exit_code";
114#[cfg(target_os = "windows")]
115const WINDOWS_GUEST_STDOUT: &str = "guest-init.stdout.log";
116#[cfg(target_os = "windows")]
117const WINDOWS_GUEST_STDERR: &str = "guest-init.stderr.log";
118#[cfg(target_os = "windows")]
119const WINDOWS_STOP_DELIVERY_TIMEOUT_MS: u64 = 1_000;
120#[cfg(target_os = "windows")]
121const WINDOWS_GUEST_FINALIZATION_TIMEOUT_MS: u64 = 30_000;
122#[cfg(target_os = "windows")]
123const WINDOWS_GUEST_RESULT_MARKER: &str = ".a3s_host_result_collected";
124#[cfg(target_os = "windows")]
125const WINDOWS_LIVE_LOGS_DRAINED_MARKER: &str = ".a3s_host_live_logs_drained";
126
127pub(crate) const TERMINAL_EXIT_POLL_INTERVAL: std::time::Duration =
128    std::time::Duration::from_millis(25);
129pub(crate) const TERMINAL_EXIT_POLL_TIMEOUT: std::time::Duration =
130    std::time::Duration::from_secs(5);
131
132/// Append a completed Windows guest stream to its raw host console, filtering
133/// libkrun's pre-guest C-init diagnostics while preserving arbitrary bytes.
134#[cfg(target_os = "windows")]
135fn append_windows_guest_stream(
136    source: &Path,
137    destination: &Path,
138    runtime_filter: &a3s_box_core::log::RuntimeConsoleFilter,
139) -> std::io::Result<()> {
140    use std::io::{BufRead, Write};
141
142    let input = match a3s_box_core::windows_file::open_regular_file(source, None) {
143        Ok((input, _)) => input,
144        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
145        Err(error) => return Err(error),
146    };
147    let mut reader = std::io::BufReader::new(input);
148    let mut output = std::fs::OpenOptions::new()
149        .create(true)
150        .append(true)
151        .open(destination)?;
152    let mut line = Vec::new();
153
154    loop {
155        line.clear();
156        if reader.read_until(b'\n', &mut line)? == 0 {
157            break;
158        }
159        let keep = !line.ends_with(b"\n")
160            || std::str::from_utf8(&line).map_or(true, |line| runtime_filter.keep_line(line));
161        if keep {
162            output.write_all(&line)?;
163        }
164    }
165
166    output.flush()
167}
168
169#[cfg(target_os = "windows")]
170fn windows_marker_matches(path: &Path, expected: &[u8]) -> bool {
171    use std::io::Read;
172
173    let Ok((file, _)) = a3s_box_core::windows_file::open_regular_file(path, None) else {
174        return false;
175    };
176    let mut contents = Vec::with_capacity(expected.len().saturating_add(1));
177    if file
178        .take(expected.len().saturating_add(1) as u64)
179        .read_to_end(&mut contents)
180        .is_err()
181    {
182        return false;
183    }
184    contents == expected
185}
186
187/// Read the durable workload status without treating it as provider completion.
188///
189/// The WHPX guest writes this file before libkrun necessarily returns to the
190/// shim. Readiness and boot cleanup use its presence only to distinguish a
191/// completed one-shot from a live guest that never became ready; normal wait
192/// paths still wait for the shim to finish relaying logs before collecting it.
193#[cfg(target_os = "windows")]
194fn windows_guest_persisted_exit_code(box_dir: &Path) -> Option<i32> {
195    use std::io::Read;
196
197    let exit_path = box_dir.join("rootfs").join(WINDOWS_GUEST_EXIT_CODE);
198    let (file, _) = a3s_box_core::windows_file::open_regular_file(&exit_path, None).ok()?;
199    let mut contents = String::new();
200    file.take(64).read_to_string(&mut contents).ok()?;
201    contents.trim().parse::<i32>().ok()
202}
203
204/// Collect the completed WHPX guest result after the shim process has exited.
205///
206/// Current shims drain structured logs before exiting. The runtime still owns
207/// raw-console collection and provides a completed-stream fallback for older
208/// libkrun bundles that terminate the shim with `_exit`.
209#[cfg(target_os = "windows")]
210pub fn collect_windows_guest_result(
211    box_dir: &Path,
212    log_config: &a3s_box_core::log::LogConfig,
213    shim_exit_code: i32,
214) -> Result<i32> {
215    let rootfs = box_dir.join("rootfs");
216    let logs = box_dir.join("logs");
217    let marker = rootfs.join(WINDOWS_GUEST_RESULT_MARKER);
218    let live_logs_drained = rootfs.join(WINDOWS_LIVE_LOGS_DRAINED_MARKER);
219    let stdout_source = rootfs.join(WINDOWS_GUEST_STDOUT);
220    let stderr_source = rootfs.join(WINDOWS_GUEST_STDERR);
221
222    if !windows_marker_matches(&marker, b"collected\n") {
223        std::fs::create_dir_all(&logs)?;
224        let runtime_filter = a3s_box_core::log::RuntimeConsoleFilter::new();
225
226        for (source, destination) in [
227            (&stdout_source, logs.join("console.log")),
228            (&stderr_source, logs.join("console.err.log")),
229        ] {
230            append_windows_guest_stream(source, &destination, &runtime_filter).map_err(
231                |error| BoxError::BoxBootError {
232                    message: format!(
233                        "Failed to collect Windows guest output {} into {}: {error}",
234                        source.display(),
235                        destination.display()
236                    ),
237                    hint: None,
238                },
239            )?;
240        }
241
242        // New Windows shims tail these sources live and drain them before exit.
243        // Older libkrun bundles still terminate the shim with `_exit`, so keep
244        // the completed-stream fallback when no drained marker exists. Process
245        // the sources rather than the retained raw console to avoid replaying a
246        // previous restart.
247        if !windows_marker_matches(&live_logs_drained, b"drained\n") {
248            let stopped = std::sync::atomic::AtomicBool::new(true);
249            a3s_box_core::log::run_log_processor_streams(
250                &stdout_source,
251                &stderr_source,
252                &logs,
253                log_config,
254                &stopped,
255            );
256        }
257
258        a3s_box_core::windows_file::replace_regular_file(&marker, b"collected\n").map_err(
259            |error| BoxError::BoxBootError {
260                message: format!(
261                    "Failed to mark the Windows guest result collected at {}: {error}",
262                    marker.display()
263                ),
264                hint: None,
265            },
266        )?;
267    }
268
269    let exit_path = rootfs.join(WINDOWS_GUEST_EXIT_CODE);
270    let contents = match a3s_box_core::windows_file::open_regular_file(&exit_path, None) {
271        Ok((file, _)) => {
272            use std::io::Read;
273            let mut contents = String::new();
274            file.take(64)
275                .read_to_string(&mut contents)
276                .map_err(|error| BoxError::BoxBootError {
277                    message: format!(
278                        "Failed to read the Windows guest exit code {}: {error}",
279                        exit_path.display()
280                    ),
281                    hint: None,
282                })?;
283            contents
284        }
285        Err(error) if error.kind() == std::io::ErrorKind::NotFound && shim_exit_code != 0 => {
286            return Ok(shim_exit_code);
287        }
288        Err(error) => {
289            return Err(BoxError::BoxBootError {
290                message: if error.kind() == std::io::ErrorKind::NotFound {
291                    format!(
292                        "WHPX stopped before the guest persisted its exit code ({})",
293                        exit_path.display()
294                    )
295                } else {
296                    format!(
297                        "Failed to read the Windows guest exit code {}: {error}",
298                        exit_path.display()
299                    )
300                },
301                hint: Some(
302                    "Inspect logs/init-rust.log and the shim log for the guest boot failure"
303                        .to_string(),
304                ),
305            });
306        }
307    };
308
309    contents
310        .trim()
311        .parse::<i32>()
312        .map_err(|error| BoxError::BoxBootError {
313            message: format!(
314                "Invalid Windows guest exit code in {}: {error}",
315                exit_path.display()
316            ),
317            hint: None,
318        })
319}
320
321/// VM manager - orchestrates VM lifecycle.
322pub struct VmManager {
323    /// Box configuration
324    pub(crate) config: BoxConfig,
325
326    /// Unique box identifier
327    pub(crate) box_id: String,
328
329    /// Internal boot contract. Maintenance never becomes persisted box state.
330    boot_mode: VmBootMode,
331
332    /// Current state
333    pub(crate) state: Arc<RwLock<BoxState>>,
334
335    /// Event emitter
336    pub(crate) event_emitter: EventEmitter,
337
338    /// VMM provider (spawns VMs via pluggable backend)
339    pub(crate) provider: Option<Box<dyn VmmProvider>>,
340
341    /// VM handler (runtime operations on running VM)
342    pub(crate) handler: Arc<RwLock<Option<Box<dyn VmHandler>>>>,
343
344    /// Exec client for executing commands in the guest
345    #[cfg(unix)]
346    pub(crate) exec_client: Option<ExecClient>,
347
348    /// Network backend manager for bridge networking (None if TSI mode).
349    /// Platform-specific: passt on Linux, gvproxy on macOS.
350    pub(crate) net_manager: Option<Box<dyn crate::network::NetworkBackend>>,
351
352    /// A3S home directory (~/.a3s)
353    pub(crate) home_dir: PathBuf,
354
355    /// Anonymous volume names created during boot (from OCI VOLUME directives)
356    pub(crate) anonymous_volumes: Vec<String>,
357
358    /// Anonymous volumes newly created by the current boot attempt.
359    ///
360    /// Reused anonymous volumes must survive failed restarts because they may
361    /// contain data from an existing stopped box.
362    pub(crate) created_anonymous_volumes: Vec<String>,
363
364    /// OCI image config resolved during the last successful boot.
365    pub(crate) image_config: Option<crate::oci::OciImageConfig>,
366
367    /// Exact rootfs cache entry paired with a snapshot-fork template.
368    ///
369    /// The template's guest memory and filesystem must come from the same
370    /// resolved image even when its configured tag moves later.
371    pub(crate) restore_rootfs_cache_key: Option<String>,
372
373    /// Suppress an image-defined health check for callers that explicitly
374    /// requested Docker-compatible `--no-healthcheck` semantics.
375    pub(crate) healthcheck_disabled: bool,
376
377    /// Whether this boot attempt started with an existing persistent rootfs
378    /// generation. Failed first boots may discard a partial extraction, while
379    /// failed restarts must retain the pre-existing guest data.
380    pub(crate) preserve_rootfs_on_boot_failure: bool,
381
382    /// TEE extension (attestation, sealing, secret injection)
383    #[cfg(unix)]
384    pub(crate) tee: Option<Box<dyn TeeExtension>>,
385
386    /// Rootfs preparation and transport provider.
387    pub(crate) rootfs_provider: Box<dyn crate::rootfs::RootfsProvider>,
388
389    /// Path to the exec Unix socket (set after boot)
390    pub(crate) exec_socket_path: Option<PathBuf>,
391
392    /// Path to the PTY Unix socket (set after boot)
393    pub(crate) pty_socket_path: Option<PathBuf>,
394
395    /// Path to the CRI port-forward Unix socket (set after boot)
396    pub(crate) port_forward_socket_path: Option<PathBuf>,
397
398    /// Prometheus metrics (optional, for instrumented deployments).
399    pub(crate) prom: Option<crate::prom::RuntimeMetrics>,
400
401    /// Exit code captured from the shim process after it exits.
402    pub(crate) shim_exit_code: Option<i32>,
403
404    /// Optional progress callback for image pulls: `(current, total, digest, size_bytes)`.
405    pub(crate) pull_progress_fn: Option<PullProgressFn>,
406
407    /// Logging driver config, threaded into the InstanceSpec so the shim runs
408    /// the log processor for the box's lifetime (set by the CLI via
409    /// [`VmManager::set_log_config`]).
410    pub(crate) log_config: a3s_box_core::log::LogConfig,
411
412    /// Backend-neutral resolution captured before any boot side effects.
413    pub(crate) resolved_execution_plan: Option<ResolvedExecutionPlan>,
414
415    /// Runtime-owned tmpfs root whose regular files may be prepared for the
416    /// Sandbox user namespace. Arbitrary external bind mounts remain immutable.
417    pub(crate) managed_secret_root: Option<PathBuf>,
418
419    /// One in-memory registry authorization selected for this boot attempt.
420    /// It is consumed and zeroized while preparing the image layout.
421    pub(crate) transient_registry_auth: Option<crate::oci::RegistryAuth>,
422}
423
424impl VmManager {
425    /// Create a new VM manager.
426    pub fn new(config: BoxConfig, event_emitter: EventEmitter) -> Self {
427        let box_id = uuid::Uuid::new_v4().to_string();
428        let home_dir = a3s_box_core::dirs_home();
429        let rootfs_provider =
430            crate::rootfs::default_provider_for_boot(rootfs_snapshot_requested(&config));
431
432        Self {
433            config,
434            box_id,
435            boot_mode: VmBootMode::Workload,
436            state: Arc::new(RwLock::new(BoxState::Created)),
437            event_emitter,
438            provider: None,
439            handler: Arc::new(RwLock::new(None)),
440            #[cfg(unix)]
441            exec_client: None,
442            net_manager: None,
443            home_dir,
444            anonymous_volumes: Vec::new(),
445            created_anonymous_volumes: Vec::new(),
446            image_config: None,
447            restore_rootfs_cache_key: None,
448            healthcheck_disabled: false,
449            preserve_rootfs_on_boot_failure: false,
450            #[cfg(unix)]
451            tee: None,
452            rootfs_provider,
453            exec_socket_path: None,
454            pty_socket_path: None,
455            port_forward_socket_path: None,
456            prom: None,
457            shim_exit_code: None,
458            pull_progress_fn: None,
459            log_config: a3s_box_core::log::LogConfig::default(),
460            resolved_execution_plan: None,
461            managed_secret_root: None,
462            transient_registry_auth: None,
463        }
464    }
465
466    /// Create a new VM manager with a specific box ID.
467    pub fn with_box_id(config: BoxConfig, event_emitter: EventEmitter, box_id: String) -> Self {
468        let home_dir = a3s_box_core::dirs_home();
469        let rootfs_provider = crate::rootfs::default_provider_for_box_boot(
470            &home_dir.join("boxes").join(&box_id),
471            rootfs_snapshot_requested(&config),
472        );
473
474        Self {
475            config,
476            box_id,
477            boot_mode: VmBootMode::Workload,
478            state: Arc::new(RwLock::new(BoxState::Created)),
479            event_emitter,
480            provider: None,
481            handler: Arc::new(RwLock::new(None)),
482            #[cfg(unix)]
483            exec_client: None,
484            net_manager: None,
485            home_dir,
486            anonymous_volumes: Vec::new(),
487            created_anonymous_volumes: Vec::new(),
488            image_config: None,
489            restore_rootfs_cache_key: None,
490            healthcheck_disabled: false,
491            preserve_rootfs_on_boot_failure: false,
492            #[cfg(unix)]
493            tee: None,
494            rootfs_provider,
495            exec_socket_path: None,
496            pty_socket_path: None,
497            port_forward_socket_path: None,
498            prom: None,
499            shim_exit_code: None,
500            pull_progress_fn: None,
501            log_config: a3s_box_core::log::LogConfig::default(),
502            resolved_execution_plan: None,
503            managed_secret_root: None,
504            transient_registry_auth: None,
505        }
506    }
507
508    /// Remove host-side boot artifacts after a failed boot attempt.
509    async fn cleanup_boot_failure(&mut self) {
510        let box_dir = self.home_dir.join("boxes").join(&self.box_id);
511
512        #[cfg(target_os = "windows")]
513        let guest_exit_before_cleanup = windows_guest_persisted_exit_code(&box_dir);
514
515        if let Some(mut handler) = self.handler.write().await.take() {
516            // A short-lived workload can finish before the runtime publishes
517            // its readiness endpoint. That is a normal terminal completion,
518            // not a failed rootfs build. Preserve its writable generation so a
519            // managed restart observes the same persistent filesystem while
520            // ephemeral mounts are recreated by the next runtime generation.
521            // Process termination and publication of the exact wait result are
522            // separate events. Try once before cleanup, let `stop` collect its
523            // owned child, then wait within the common terminal bound only when
524            // the workload was already known to have exited naturally. A live
525            // boot failure stopped by Box must not be reclassified as success.
526            let exited_before_cleanup = handler.has_exited();
527            let collected_before_cleanup = match handler.try_wait_exit() {
528                Ok(Some(exit_code)) => {
529                    self.shim_exit_code = Some(exit_code);
530                    true
531                }
532                Ok(None) => false,
533                Err(error) => {
534                    tracing::debug!(
535                        box_id = %self.box_id,
536                        error = %error,
537                        "Failed to collect a terminal status before boot cleanup"
538                    );
539                    false
540                }
541            };
542            if let Err(error) = handler.stop(default_stop_signal(), DEFAULT_SHUTDOWN_TIMEOUT_MS) {
543                tracing::warn!(
544                    box_id = %self.box_id,
545                    error = %error,
546                    "Failed to stop VM handler after boot failure"
547                );
548            }
549            let provider_exit_code = handler.exit_code().or(self.shim_exit_code);
550            #[cfg(not(target_os = "windows"))]
551            {
552                self.shim_exit_code =
553                    crate::rootfs::resolve_workload_exit_code(&box_dir, provider_exit_code);
554            }
555            #[cfg(target_os = "windows")]
556            {
557                let completed_before_cleanup = collected_before_cleanup
558                    || exited_before_cleanup
559                    || guest_exit_before_cleanup.is_some();
560                if completed_before_cleanup {
561                    let fallback_exit_code = guest_exit_before_cleanup.or(provider_exit_code);
562                    if let Some(fallback_exit_code) = fallback_exit_code {
563                        match collect_windows_guest_result(
564                            &box_dir,
565                            &self.log_config,
566                            fallback_exit_code,
567                        ) {
568                            Ok(exit_code) => self.shim_exit_code = Some(exit_code),
569                            Err(error) => {
570                                tracing::warn!(
571                                    box_id = %self.box_id,
572                                    error = %error,
573                                    "Failed to collect the completed Windows guest during boot cleanup"
574                                );
575                                self.shim_exit_code =
576                                    guest_exit_before_cleanup.or(provider_exit_code);
577                            }
578                        }
579                    } else {
580                        // A provider can report process exit before its owned
581                        // child status becomes collectable. Keep the status
582                        // pending so the delayed terminal poll below can reap it.
583                        self.shim_exit_code = None;
584                    }
585                } else {
586                    self.shim_exit_code = provider_exit_code;
587                }
588            }
589            if exited_before_cleanup && self.shim_exit_code.is_none() {
590                self.shim_exit_code =
591                    wait_for_delayed_terminal_exit(handler.as_mut(), &box_dir, &self.box_id).await;
592            }
593            let completed_before_cleanup = collected_before_cleanup || exited_before_cleanup;
594            #[cfg(target_os = "windows")]
595            let completed_before_cleanup =
596                completed_before_cleanup || guest_exit_before_cleanup.is_some();
597            if self.config.persistent && self.shim_exit_code.is_some() && completed_before_cleanup {
598                self.preserve_rootfs_on_boot_failure = true;
599            }
600        }
601
602        if let Some(mut net_manager) = self.net_manager.take() {
603            net_manager.stop();
604        }
605
606        self.cleanup_created_anonymous_volumes();
607        self.cleanup_box_dir();
608    }
609
610    fn cleanup_created_anonymous_volumes(&mut self) {
611        if self.created_anonymous_volumes.is_empty() {
612            return;
613        }
614
615        let created = std::mem::take(&mut self.created_anonymous_volumes);
616        let created_set: std::collections::HashSet<_> = created.iter().cloned().collect();
617        let store = crate::volume::VolumeStore::new(
618            self.home_dir.join("volumes.json"),
619            self.home_dir.join("volumes"),
620        );
621
622        for volume_name in &created {
623            if let Err(error) = store.remove_anonymous(volume_name, &self.box_id) {
624                tracing::debug!(
625                    box_id = %self.box_id,
626                    volume = volume_name,
627                    error = %error,
628                    "Failed to remove anonymous volume after boot failure"
629                );
630            }
631        }
632
633        self.anonymous_volumes
634            .retain(|name| !created_set.contains(name));
635    }
636
637    /// Remove transient host boot artifacts, retaining persistent guest data.
638    fn cleanup_box_dir(&self) {
639        let box_dir = self.home_dir.join("boxes").join(&self.box_id);
640        let socket_dir = self.socket_dir();
641        let mount_aliases_clean = match self.cleanup_sandbox_mount_aliases() {
642            Ok(()) => true,
643            Err(error) => {
644                tracing::warn!(
645                    box_id = %self.box_id,
646                    %error,
647                    "Failed to cleanup Sandbox attachment aliases after boot failure"
648                );
649                false
650            }
651        };
652
653        // Reap the box's passt daemon (Linux bridge mode) BEFORE removing its
654        // socket dir. A boot that fails after passt spawned but before
655        // `self.net_manager` was assigned leaves `net_manager.stop()` a no-op, so
656        // passt would otherwise survive holding the published port — the
657        // "Address already in use" on the next start. terminate_passt reads
658        // `socket_dir/passt.pid` and is a no-op when there is no passt.
659        #[cfg(target_os = "linux")]
660        crate::network::terminate_passt(&self.socket_dir());
661
662        let preserve_rootfs_on_boot_failure = self.preserve_rootfs_on_boot_failure
663            || self.rootfs_provider.preserve_on_boot_failure(&box_dir);
664        if let Err(error) = self
665            .rootfs_provider
666            .cleanup(&box_dir, preserve_rootfs_on_boot_failure)
667        {
668            tracing::warn!(
669                box_id = %self.box_id,
670                path = %box_dir.display(),
671                error = %error,
672                "Failed to cleanup rootfs provider after boot failure"
673            );
674        }
675
676        match std::fs::remove_dir_all(&socket_dir) {
677            Ok(()) => {}
678            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
679            Err(error) => {
680                tracing::debug!(
681                    box_id = %self.box_id,
682                    path = %socket_dir.display(),
683                    error = %error,
684                    "Failed to cleanup socket directory after boot failure"
685                );
686            }
687        }
688
689        // A failed restart must never erase a persistent writable rootfs. The
690        // provider cleanup above detaches transient mounts while retaining the
691        // persistent generation; only ephemeral boxes are removed wholesale.
692        if !self.config.persistent && mount_aliases_clean {
693            match std::fs::remove_dir_all(&box_dir) {
694                Ok(()) => {}
695                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
696                Err(error) => {
697                    tracing::warn!(
698                        box_id = %self.box_id,
699                        path = %box_dir.display(),
700                        error = %error,
701                        "Failed to cleanup box directory after boot failure"
702                    );
703                }
704            }
705        }
706    }
707
708    fn cleanup_sandbox_mount_aliases(&self) -> Result<()> {
709        if self.config.isolation.is_sandbox() {
710            crate::sandbox::cleanup_sandbox_mount_aliases(&self.home_dir, &self.box_id)
711        } else {
712            Ok(())
713        }
714    }
715
716    /// Create a new VM manager with a custom VMM provider.
717    pub fn with_provider(
718        config: BoxConfig,
719        event_emitter: EventEmitter,
720        provider: Box<dyn VmmProvider>,
721    ) -> Self {
722        let box_id = uuid::Uuid::new_v4().to_string();
723        let home_dir = a3s_box_core::dirs_home();
724        let rootfs_provider =
725            crate::rootfs::default_provider_for_boot(rootfs_snapshot_requested(&config));
726        Self {
727            config,
728            box_id,
729            boot_mode: VmBootMode::Workload,
730            state: Arc::new(RwLock::new(BoxState::Created)),
731            event_emitter,
732            provider: Some(provider),
733            handler: Arc::new(RwLock::new(None)),
734            #[cfg(unix)]
735            exec_client: None,
736            net_manager: None,
737            home_dir,
738            anonymous_volumes: Vec::new(),
739            created_anonymous_volumes: Vec::new(),
740            image_config: None,
741            restore_rootfs_cache_key: None,
742            healthcheck_disabled: false,
743            preserve_rootfs_on_boot_failure: false,
744            #[cfg(unix)]
745            tee: None,
746            rootfs_provider,
747            exec_socket_path: None,
748            pty_socket_path: None,
749            port_forward_socket_path: None,
750            prom: None,
751            shim_exit_code: None,
752            pull_progress_fn: None,
753            log_config: a3s_box_core::log::LogConfig::default(),
754            resolved_execution_plan: None,
755            managed_secret_root: None,
756            transient_registry_auth: None,
757        }
758    }
759
760    /// Get the box ID.
761    pub fn box_id(&self) -> &str {
762        &self.box_id
763    }
764
765    /// Get current state.
766    pub async fn state(&self) -> BoxState {
767        *self.state.read().await
768    }
769}
770
771/// Whether this launch couples the rootfs to a VMM memory snapshot.
772///
773/// The provider must be selected before layout preparation, so keep every
774/// snapshot entry point in one predicate. `KRUN_RESTORE_FROM` remains the
775/// compatibility input for the single-VM restore path.
776fn rootfs_snapshot_requested(config: &BoxConfig) -> bool {
777    if config.snapshot_mem_file.is_some()
778        || config.snapshot_sock.is_some()
779        || config.restore_from.is_some()
780    {
781        return true;
782    }
783
784    #[cfg(unix)]
785    {
786        std::env::var_os("KRUN_RESTORE_FROM").is_some_and(|value| !value.as_os_str().is_empty())
787    }
788
789    #[cfg(not(unix))]
790    {
791        false
792    }
793}
794
795/// Whether the vendored libkrun snapshot state contract exists for this build.
796///
797/// Its serialized VM/vCPU/device state is intentionally compiled only for
798/// Linux KVM on x86_64. Detect this before layout preparation so unsupported
799/// hosts do not pull an image, allocate file-backed RAM, or create a temporary
800/// rootfs transport for an operation that cannot produce a restorable state.
801pub(crate) const fn native_snapshot_fork_supported() -> bool {
802    cfg!(all(target_os = "linux", target_arch = "x86_64"))
803}
804
805fn validate_snapshot_launch(config: &BoxConfig) -> Result<()> {
806    let memory = config
807        .snapshot_mem_file
808        .as_deref()
809        .filter(|value| !value.is_empty())
810        .map(str::to_owned)
811        .or_else(|| snapshot_env_nonempty("KRUN_SNAPSHOT_MEM_FILE"));
812    let trigger = config
813        .snapshot_sock
814        .as_deref()
815        .filter(|value| !value.is_empty())
816        .map(str::to_owned)
817        .or_else(|| snapshot_env_nonempty("KRUN_SNAPSHOT_SOCK"));
818    let restore = config
819        .restore_from
820        .as_deref()
821        .filter(|value| !value.is_empty())
822        .map(str::to_owned)
823        .or_else(|| snapshot_env_nonempty("KRUN_RESTORE_FROM"));
824
825    validate_snapshot_launch_shape(memory.is_some(), trigger.is_some(), restore.is_some())
826}
827
828fn validate_snapshot_launch_shape(memory: bool, trigger: bool, restore: bool) -> Result<()> {
829    let requested = memory || trigger || restore;
830    if !requested {
831        return Ok(());
832    }
833    if !native_snapshot_fork_supported() {
834        return Err(BoxError::ConfigError(
835            "native VM snapshot-fork is supported only by the Linux x86_64 KVM build".to_string(),
836        ));
837    }
838    match (memory, trigger, restore) {
839        (true, true, false) | (true, false, true) => Ok(()),
840        _ => Err(BoxError::ConfigError(
841            "invalid native VM snapshot configuration: template mode requires memory + trigger socket, while restore mode requires memory + state file"
842                .to_string(),
843        )),
844    }
845}
846
847fn snapshot_env_nonempty(name: &str) -> Option<String> {
848    std::env::var(name).ok().filter(|value| !value.is_empty())
849}
850
851/// Whether this boot is a snapshot-fork restore (the guest is resumed already-booted
852/// rather than cold-booted). PER-VM: a pool / fork daemon sets `config.restore_from`
853/// so one process can restore different VMs; the single-VM `run` path uses the
854/// `KRUN_RESTORE_FROM` env. Either source means restore mode.
855#[cfg(unix)]
856fn is_restore_mode(config: &BoxConfig) -> bool {
857    config
858        .restore_from
859        .as_deref()
860        .is_some_and(|s| !s.is_empty())
861        || std::env::var("KRUN_RESTORE_FROM")
862            .map(|v| !v.is_empty())
863            .unwrap_or(false)
864}
865
866/// Simple FNV-1a hash for generating short deterministic hashes from strings.
867pub(crate) fn fnv1a_hash(input: &str) -> u64 {
868    let mut hash: u64 = 0xcbf29ce484222325;
869    for byte in input.bytes() {
870        hash ^= byte as u64;
871        hash = hash.wrapping_mul(0x100000001b3);
872    }
873    hash
874}
875
876#[cfg(unix)]
877fn default_stop_signal() -> i32 {
878    libc::SIGTERM
879}
880
881#[cfg(windows)]
882fn default_stop_signal() -> i32 {
883    15
884}
885
886async fn wait_for_delayed_terminal_exit(
887    handler: &mut dyn VmHandler,
888    box_dir: &Path,
889    box_id: &str,
890) -> Option<i32> {
891    let deadline = tokio::time::Instant::now() + TERMINAL_EXIT_POLL_TIMEOUT;
892    let mut reported_wait_error = false;
893    loop {
894        if let Some(exit_code) = handler.exit_code() {
895            return Some(exit_code);
896        }
897        if let Some(exit_code) = boot_failure_persisted_exit_code(box_dir) {
898            return Some(exit_code);
899        }
900        match handler.try_wait_exit() {
901            Ok(Some(exit_code)) => return Some(exit_code),
902            Ok(None) => {}
903            Err(error) => {
904                if !reported_wait_error {
905                    tracing::debug!(
906                        %box_id,
907                        %error,
908                        "Terminal status remained unavailable after boot cleanup"
909                    );
910                    reported_wait_error = true;
911                }
912            }
913        }
914        if tokio::time::Instant::now() >= deadline {
915            return None;
916        }
917        tokio::time::sleep(TERMINAL_EXIT_POLL_INTERVAL).await;
918    }
919}
920
921#[cfg(not(target_os = "windows"))]
922fn boot_failure_persisted_exit_code(box_dir: &Path) -> Option<i32> {
923    crate::rootfs::read_persisted_exit_code(box_dir)
924}
925
926#[cfg(target_os = "windows")]
927fn boot_failure_persisted_exit_code(box_dir: &Path) -> Option<i32> {
928    windows_guest_persisted_exit_code(box_dir)
929}
930
931#[cfg(test)]
932#[path = "tests.rs"]
933mod tests;