Skip to main content

a3s_box_runtime/local_execution/
vm_backend.rs

1//! Production local execution backend backed by [`crate::VmManager`].
2
3#[path = "vm_sandbox.rs"]
4mod sandbox;
5
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8#[cfg(unix)]
9use std::time::Duration;
10
11use a3s_box_core::{
12    BoxError, EventEmitter, ExecutionBackend, ExecutionId, ExecutionManagerError,
13    ExecutionManagerResult, ExecutionState, KillOutcome, DEFAULT_SHUTDOWN_TIMEOUT_MS,
14};
15use async_trait::async_trait;
16use dashmap::mapref::entry::Entry;
17use dashmap::DashMap;
18use tokio::sync::Mutex;
19
20use super::resources::ExecutionResourceGuard;
21use super::vm_process::{locate_microvm_process, LocatedProcess};
22#[cfg(target_os = "linux")]
23use super::TransientRegistryAuthBroker;
24use super::{
25    LocalExecutionBackend, LocalExecutionHandle, LocalExecutionObservation,
26    LocalExecutionResourcePlan, LocalExecutionTermination,
27};
28use crate::vm::{TERMINAL_EXIT_POLL_INTERVAL, TERMINAL_EXIT_POLL_TIMEOUT};
29use crate::{
30    BoxRecord, ManagedExecutionMetadata, ManagedExecutionOperation, ManagedExecutionState,
31    VmManager,
32};
33
34type SharedVm = Arc<Mutex<VmManager>>;
35
36/// Runtime adapter that owns live [`VmManager`] handles and reconstructs them
37/// from durable runtime evidence after a control-plane restart.
38#[derive(Clone)]
39pub struct VmLocalExecutionBackend {
40    home_dir: PathBuf,
41    managers: Arc<DashMap<String, SharedVm>>,
42    pull_progress_fn: Option<crate::PullProgressFn>,
43    #[cfg(target_os = "linux")]
44    transient_registry_auth: Option<TransientRegistryAuthBroker>,
45}
46
47impl VmLocalExecutionBackend {
48    pub fn new(home_dir: impl Into<PathBuf>) -> Self {
49        Self {
50            home_dir: home_dir.into(),
51            managers: Arc::new(DashMap::new()),
52            pull_progress_fn: None,
53            #[cfg(target_os = "linux")]
54            transient_registry_auth: None,
55        }
56    }
57
58    pub fn with_pull_progress_fn(mut self, pull_progress_fn: crate::PullProgressFn) -> Self {
59        self.pull_progress_fn = Some(pull_progress_fn);
60        self
61    }
62
63    #[cfg(target_os = "linux")]
64    pub(crate) fn with_transient_registry_auth(
65        mut self,
66        broker: TransientRegistryAuthBroker,
67    ) -> Self {
68        self.transient_registry_auth = Some(broker);
69        self
70    }
71
72    pub fn home_dir(&self) -> &Path {
73        &self.home_dir
74    }
75
76    fn metadata<'a>(
77        &self,
78        record: &'a BoxRecord,
79    ) -> ExecutionManagerResult<&'a ManagedExecutionMetadata> {
80        self.metadata_for_route(record, crate::ManagedRuntimeRoute::BoxVm)
81    }
82
83    fn metadata_for_route<'a>(
84        &self,
85        record: &'a BoxRecord,
86        expected_route: crate::ManagedRuntimeRoute,
87    ) -> ExecutionManagerResult<&'a ManagedExecutionMetadata> {
88        uuid::Uuid::parse_str(&record.id).map_err(|error| {
89            ExecutionManagerError::Internal(format!(
90                "managed execution has an invalid internal ID {}: {error}",
91                record.id
92            ))
93        })?;
94        let expected_box_dir = self.home_dir.join("boxes").join(&record.id);
95        if record.box_dir != expected_box_dir {
96            return Err(ExecutionManagerError::Internal(format!(
97                "managed execution {} has an unexpected host directory {}",
98                record.id,
99                record.box_dir.display()
100            )));
101        }
102        let metadata = record.managed_execution.as_ref().ok_or_else(|| {
103            ExecutionManagerError::Internal(format!(
104                "execution {} lost managed lifecycle metadata",
105                record.id
106            ))
107        })?;
108        metadata
109            .validate()
110            .map_err(|error| ExecutionManagerError::Internal(error.to_string()))?;
111        let resolved_route = super::router::resolved_runtime_route(record)?;
112        if resolved_route != expected_route {
113            return Err(ExecutionManagerError::Internal(format!(
114                "managed execution {} is pinned to {:?}, not {:?}",
115                record.id, resolved_route, expected_route
116            )));
117        }
118        if record.isolation != metadata.request.config.isolation {
119            return Err(ExecutionManagerError::Internal(format!(
120                "managed execution {} has inconsistent isolation metadata",
121                record.id
122            )));
123        }
124        Ok(metadata)
125    }
126
127    fn new_manager(&self, record: &BoxRecord) -> ExecutionManagerResult<VmManager> {
128        let metadata = self.metadata(record)?;
129        self.new_manager_from_metadata(record, metadata)
130    }
131
132    pub(super) fn new_oci_preparation_manager(
133        &self,
134        record: &BoxRecord,
135    ) -> ExecutionManagerResult<VmManager> {
136        let metadata = self.metadata_for_route(record, crate::ManagedRuntimeRoute::OciSdk)?;
137        self.new_manager_from_metadata(record, metadata)
138    }
139
140    fn new_manager_from_metadata(
141        &self,
142        record: &BoxRecord,
143        metadata: &ManagedExecutionMetadata,
144    ) -> ExecutionManagerResult<VmManager> {
145        let mut config = metadata.request.config.clone();
146        // Network connect/disconnect is intentionally mutable while an
147        // execution is inactive. The managed creation request remains the
148        // immutable idempotency identity, so apply the current record-level
149        // network selection when constructing each runtime generation.
150        config.network = record.network_mode.clone();
151        if let Some(shm_size) = metadata.request.policy.shm_size {
152            let has_shared_memory_mount = config
153                .tmpfs
154                .iter()
155                .any(|entry| entry.split(':').next() == Some("/dev/shm"));
156            if !has_shared_memory_mount {
157                config.tmpfs.push(format!("/dev/shm:size={shm_size}"));
158            }
159        }
160        let mut manager = VmManager::with_box_id(config, EventEmitter::new(256), record.id.clone());
161        manager.home_dir = self.home_dir.clone();
162        manager.set_healthcheck_disabled(metadata.request.policy.healthcheck_disabled);
163        if let Some(pull_progress_fn) = self.pull_progress_fn.clone() {
164            manager.set_pull_progress_fn(pull_progress_fn);
165        }
166        manager.anonymous_volumes = record.anonymous_volumes.clone();
167        manager.set_log_config(record.log_config.clone());
168        manager.resolved_execution_plan = Some(metadata.plan.clone());
169        manager.managed_secret_root = metadata.request.policy.managed_secret_root.clone();
170        Ok(manager)
171    }
172
173    #[cfg(target_os = "linux")]
174    fn claim_transient_registry_auth_for_boot(&self, manager: &mut VmManager) {
175        manager.transient_registry_auth = self
176            .transient_registry_auth
177            .as_ref()
178            .and_then(|broker| broker.take(manager.box_id()));
179    }
180
181    fn manager(&self, execution_id: &str) -> Option<SharedVm> {
182        self.managers
183            .get(execution_id)
184            .map(|entry| Arc::clone(entry.value()))
185    }
186
187    fn remove_manager(&self, execution_id: &str, expected: &SharedVm) {
188        if let Entry::Occupied(entry) = self.managers.entry(execution_id.to_string()) {
189            if Arc::ptr_eq(entry.get(), expected) {
190                entry.remove();
191            }
192        }
193    }
194
195    async fn handle_from_manager(
196        &self,
197        record: &BoxRecord,
198        manager: &VmManager,
199    ) -> ExecutionManagerResult<LocalExecutionHandle> {
200        let execution_id = execution_id(record)?;
201        let pid = manager.pid().await.ok_or_else(|| {
202            ExecutionManagerError::Internal(format!(
203                "runtime returned no host PID for {execution_id}"
204            ))
205        })?;
206        let pid_start_time = crate::process::pid_start_time(pid);
207        #[cfg(target_os = "linux")]
208        if pid_start_time.is_none() {
209            return Err(ExecutionManagerError::NotFound(execution_id));
210        }
211        if !crate::process::is_process_alive_with_identity(pid, pid_start_time) {
212            return Err(ExecutionManagerError::NotFound(execution_id));
213        }
214        let exec_socket_path = manager
215            .exec_socket_path()
216            .map(Path::to_path_buf)
217            .ok_or_else(|| {
218                ExecutionManagerError::Internal(format!(
219                    "runtime returned no exec socket for {}",
220                    record.id
221                ))
222            })?;
223        let anonymous_volumes = if manager.anonymous_volumes().is_empty() {
224            self.anonymous_volumes_for_record(record).await
225        } else {
226            manager.anonymous_volumes().to_vec()
227        };
228        Ok(LocalExecutionHandle {
229            started_at: record.started_at.unwrap_or_else(chrono::Utc::now),
230            pid: Some(pid),
231            pid_start_time,
232            exec_socket_path,
233            console_log: record.box_dir.join("logs/console.log"),
234            anonymous_volumes,
235            oci_runtime: None,
236        })
237    }
238
239    async fn inspect_registered(
240        &self,
241        record: &BoxRecord,
242        shared: SharedVm,
243    ) -> ExecutionManagerResult<LocalExecutionObservation> {
244        let mut manager = shared.lock().await;
245        let preserve_rootfs = should_force_rootfs_preservation(record)?;
246        let exit_code = manager
247            .try_wait_exit()
248            .await
249            .map_err(|error| runtime_error("inspect", record, error))?;
250        let mut state = manager.state().await;
251        let terminal = exit_code.is_some() || state == crate::BoxState::Stopped;
252        if terminal {
253            return self
254                .finish_registered_terminal(record, &shared, manager, preserve_rootfs, exit_code)
255                .await;
256        }
257
258        if state == crate::BoxState::Created {
259            if manager.has_exited().await {
260                return self
261                    .finish_registered_terminal(record, &shared, manager, preserve_rootfs, None)
262                    .await;
263            }
264            if !self.promote_if_ready(record, &mut manager).await {
265                return Ok(LocalExecutionObservation {
266                    state: ExecutionState::Creating,
267                    handle: None,
268                    exit_code: None,
269                });
270            }
271            state = manager.state().await;
272        }
273
274        if !manager
275            .health_check()
276            .await
277            .map_err(|error| runtime_error("inspect", record, error))?
278        {
279            return self
280                .finish_registered_terminal(record, &shared, manager, preserve_rootfs, None)
281                .await;
282        }
283
284        if state != crate::BoxState::Ready
285            && state != crate::BoxState::Busy
286            && state != crate::BoxState::Compacting
287        {
288            return Err(ExecutionManagerError::Internal(format!(
289                "runtime manager for {} is in unexpected state {state:?}",
290                record.id
291            )));
292        }
293        if matches!(
294            managed_state(record)?,
295            ManagedExecutionState::Starting | ManagedExecutionState::RestartStarting
296        ) && !exec_endpoint_ready(manager.exec_socket_path()).await
297        {
298            return Ok(LocalExecutionObservation {
299                state: ExecutionState::Creating,
300                handle: None,
301                exit_code: None,
302            });
303        }
304        let visible_state = visible_active_state(record)?;
305        let handle = match self.handle_from_manager(record, &manager).await {
306            Ok(handle) => handle,
307            Err(ExecutionManagerError::NotFound(_)) => {
308                // The runtime state and health probes can still report a live
309                // Sandbox after its host process has exited. Treat the missing
310                // handle as the same terminal signal as a failed health probe
311                // and wait for the exact status before projecting provider
312                // loss. This closes the restart/replay race without creating a
313                // second lifecycle path.
314                return self
315                    .finish_registered_terminal(record, &shared, manager, preserve_rootfs, None)
316                    .await;
317            }
318            Err(error) => return Err(error),
319        };
320        Ok(LocalExecutionObservation {
321            state: visible_state,
322            handle: Some(handle),
323            exit_code: None,
324        })
325    }
326
327    async fn finish_registered_terminal(
328        &self,
329        record: &BoxRecord,
330        shared: &SharedVm,
331        mut manager: tokio::sync::MutexGuard<'_, VmManager>,
332        preserve_rootfs: bool,
333        exit_code: Option<i32>,
334    ) -> ExecutionManagerResult<LocalExecutionObservation> {
335        // A workload can exit between the first non-blocking wait and the
336        // following health probe. A3S OCI publishes the exact status shortly
337        // after the process becomes non-running, so retain its terminal record
338        // and poll within a strict bound before any teardown.
339        let deadline = tokio::time::Instant::now() + TERMINAL_EXIT_POLL_TIMEOUT;
340        let mut exit_code = manager.exit_code().or(exit_code);
341        while exit_code.is_none() {
342            exit_code = manager
343                .try_wait_exit()
344                .await
345                .map_err(|error| runtime_error("collect exit status", record, error))?;
346            if exit_code.is_some() || tokio::time::Instant::now() >= deadline {
347                break;
348            }
349            tokio::time::sleep(TERMINAL_EXIT_POLL_INTERVAL).await;
350        }
351        let exit_code = exit_code.ok_or_else(|| {
352            ExecutionManagerError::Unavailable(format!(
353                "runtime reported execution {} as terminal before its exact exit status became available",
354                record.id
355            ))
356        })?;
357        let cleanup = destroy_after_observation(&mut manager, preserve_rootfs).await;
358        drop(manager);
359        self.remove_manager(&record.id, shared);
360        cleanup.map_err(|error| runtime_error("clean up", record, error))?;
361        Ok(LocalExecutionObservation {
362            state: ExecutionState::Stopped,
363            handle: None,
364            exit_code: Some(exit_code),
365        })
366    }
367
368    async fn promote_if_ready(&self, record: &BoxRecord, manager: &mut VmManager) -> bool {
369        let socket_dir = crate::vm::runtime_socket_dir(&self.home_dir, &record.id);
370        let exec_socket = socket_dir.join("exec.sock");
371        if !exec_endpoint_ready(Some(&exec_socket)).await {
372            return false;
373        }
374        manager.exec_socket_path = Some(exec_socket);
375        manager.pty_socket_path = Some(socket_dir.join("pty.sock"));
376        manager.port_forward_socket_path = Some(socket_dir.join("portfwd.sock"));
377        *manager.state.write().await = crate::BoxState::Ready;
378        true
379    }
380
381    async fn recover_microvm(&self, record: &BoxRecord) -> ExecutionManagerResult<SharedVm> {
382        self.metadata(record)?;
383        let execution_id = execution_id(record)?;
384        let execution_id_label = record.id.clone();
385        let recorded = record.pid.map(|pid| (pid, record.pid_start_time));
386        let located = tokio::task::spawn_blocking(move || {
387            locate_microvm_process(&execution_id_label, recorded)
388        })
389        .await
390        .map_err(|error| {
391            ExecutionManagerError::Internal(format!(
392                "MicroVM process discovery task failed for {}: {error}",
393                record.id
394            ))
395        })?
396        .map_err(ExecutionManagerError::Internal)?
397        .ok_or(ExecutionManagerError::NotFound(execution_id))?;
398        self.attach_microvm(record, located).await
399    }
400
401    async fn attach_microvm(
402        &self,
403        record: &BoxRecord,
404        located: LocatedProcess,
405    ) -> ExecutionManagerResult<SharedVm> {
406        let mut manager = self.new_manager(record)?;
407        let socket_dir = crate::vm::runtime_socket_dir(&self.home_dir, &record.id);
408        manager
409            .attach_running_process(
410                located.pid,
411                socket_dir.join("exec.sock"),
412                Some(socket_dir.join("pty.sock")),
413            )
414            .await
415            .map_err(|error| runtime_error("recover", record, error))?;
416        if located.start_time.is_some()
417            && crate::process::pid_start_time(located.pid) != located.start_time
418        {
419            return Err(ExecutionManagerError::NotFound(execution_id(record)?));
420        }
421        let recovered = Arc::new(Mutex::new(manager));
422        match self.managers.entry(record.id.clone()) {
423            Entry::Occupied(entry) => Ok(Arc::clone(entry.get())),
424            Entry::Vacant(entry) => {
425                entry.insert(Arc::clone(&recovered));
426                Ok(recovered)
427            }
428        }
429    }
430
431    #[cfg(not(windows))]
432    async fn require_microvm(&self, record: &BoxRecord) -> ExecutionManagerResult<SharedVm> {
433        match self.manager(&record.id) {
434            Some(manager) => Ok(manager),
435            None => self.recover_microvm(record).await,
436        }
437    }
438
439    async fn destroy_registered(
440        &self,
441        record: &BoxRecord,
442        shared: SharedVm,
443        remove_anonymous_volumes: bool,
444        force_preserve_rootfs: bool,
445        timeout_secs: Option<u64>,
446    ) -> ExecutionManagerResult<LocalExecutionTermination> {
447        let mut manager = shared.lock().await;
448        let mut anonymous_volumes = if manager.anonymous_volumes().is_empty() {
449            record.anonymous_volumes.clone()
450        } else {
451            manager.anonymous_volumes().to_vec()
452        };
453        let result = match (
454            graceful_stop_options(record, timeout_secs)?,
455            force_preserve_rootfs,
456        ) {
457            (Some((signal, timeout_ms)), true) => {
458                manager
459                    .destroy_preserving_rootfs_with_options(signal, timeout_ms)
460                    .await
461            }
462            (Some((signal, timeout_ms)), false) => {
463                manager.destroy_with_options(signal, timeout_ms).await
464            }
465            (None, true) => manager.destroy_preserving_rootfs().await,
466            (None, false) => manager.destroy().await,
467        };
468        let exit_code = manager.exit_code();
469        drop(manager);
470        self.remove_manager(&record.id, &shared);
471        result.map_err(|error| runtime_error("kill", record, error))?;
472        if remove_anonymous_volumes {
473            if anonymous_volumes.is_empty() {
474                anonymous_volumes = self.anonymous_volumes_for_record(record).await;
475            }
476            self.cleanup_anonymous_volumes(&record.id, anonymous_volumes)
477                .await;
478        }
479        Ok(LocalExecutionTermination {
480            outcome: KillOutcome::Killed,
481            exit_code,
482        })
483    }
484
485    async fn anonymous_volumes_for_record(&self, record: &BoxRecord) -> Vec<String> {
486        if !record.anonymous_volumes.is_empty() {
487            return record.anonymous_volumes.clone();
488        }
489        let home_dir = self.home_dir.clone();
490        let execution_id = record.id.clone();
491        let short_id = record.id.chars().take(8).collect::<String>();
492        let result = tokio::task::spawn_blocking(move || -> a3s_box_core::Result<Vec<String>> {
493            let store =
494                crate::VolumeStore::new(home_dir.join("volumes.json"), home_dir.join("volumes"));
495            let prefix = format!("anon_{short_id}_");
496            let mut names = store
497                .load()?
498                .into_values()
499                .filter(|volume| {
500                    volume
501                        .labels
502                        .get("anonymous")
503                        .is_some_and(|value| value == "true")
504                        && (volume.in_use_by.iter().any(|id| id == &execution_id)
505                            || volume.name.starts_with(&prefix))
506                })
507                .map(|volume| volume.name)
508                .collect::<Vec<_>>();
509            names.sort();
510            Ok(names)
511        })
512        .await;
513        match result {
514            Ok(Ok(names)) => names,
515            Ok(Err(error)) => {
516                tracing::warn!(
517                    execution_id = %record.id,
518                    %error,
519                    "Failed to load anonymous volumes during managed cleanup"
520                );
521                Vec::new()
522            }
523            Err(error) => {
524                tracing::warn!(
525                    execution_id = %record.id,
526                    %error,
527                    "Anonymous volume recovery task failed"
528                );
529                Vec::new()
530            }
531        }
532    }
533
534    async fn cleanup_anonymous_volumes(&self, owner: &str, names: Vec<String>) {
535        if names.is_empty() {
536            return;
537        }
538        let home_dir = self.home_dir.clone();
539        let owner = owner.to_string();
540        let task = tokio::task::spawn_blocking(move || {
541            let store = crate::VolumeStore::new(
542                home_dir.join("volumes.json"),
543                home_dir.join("volumes"),
544            );
545            for name in names {
546                if let Err(error) = store.remove_anonymous(&name, &owner) {
547                    tracing::warn!(volume = %name, %error, "Failed to remove managed anonymous volume");
548                }
549            }
550        })
551        .await;
552        if let Err(error) = task {
553            tracing::warn!(%error, "Anonymous volume cleanup task failed");
554        }
555    }
556
557    async fn terminate_execution(
558        &self,
559        record: &BoxRecord,
560    ) -> ExecutionManagerResult<LocalExecutionTermination> {
561        let metadata = self.metadata(record)?;
562        let remove_anonymous_volumes = record.auto_remove;
563        let timeout_secs = record.stop_timeout;
564        if let Some(manager) = self.manager(&record.id) {
565            return self
566                .destroy_registered(
567                    record,
568                    manager,
569                    remove_anonymous_volumes,
570                    false,
571                    timeout_secs,
572                )
573                .await;
574        }
575        // A filesystem-only pause deliberately has no live provider evidence,
576        // but a later terminal kill must still apply the configured rootfs and
577        // anonymous-volume cleanup policy to the retained generation.
578        if !metadata.paused_with_memory {
579            let manager = Arc::new(Mutex::new(self.new_manager(record)?));
580            return self
581                .destroy_registered(
582                    record,
583                    manager,
584                    remove_anonymous_volumes,
585                    false,
586                    timeout_secs,
587                )
588                .await;
589        }
590        match metadata.plan.backend {
591            ExecutionBackend::A3sOci => {
592                self.destroy_detached_sandbox(record, remove_anonymous_volumes, false, timeout_secs)
593                    .await
594            }
595            ExecutionBackend::Krun => {
596                let manager = self.recover_microvm(record).await?;
597                self.destroy_registered(
598                    record,
599                    manager,
600                    remove_anonymous_volumes,
601                    false,
602                    timeout_secs,
603                )
604                .await
605            }
606        }
607    }
608}
609
610async fn destroy_after_observation(
611    manager: &mut VmManager,
612    preserve_rootfs: bool,
613) -> a3s_box_core::Result<()> {
614    if preserve_rootfs {
615        manager.destroy_preserving_rootfs().await
616    } else {
617        manager.destroy().await
618    }
619}
620
621#[async_trait]
622impl LocalExecutionBackend for VmLocalExecutionBackend {
623    fn route_for_create(
624        &self,
625        _record: &BoxRecord,
626    ) -> ExecutionManagerResult<crate::ManagedRuntimeRoute> {
627        Ok(crate::ManagedRuntimeRoute::BoxVm)
628    }
629
630    async fn plan_create_resources(
631        &self,
632        record: &BoxRecord,
633    ) -> ExecutionManagerResult<LocalExecutionResourcePlan> {
634        let metadata = self.metadata(record)?;
635        if !metadata.plan.backend.is_sandbox() {
636            return Ok(LocalExecutionResourcePlan::default());
637        }
638
639        // Resolve only the immutable image metadata needed to derive stable
640        // anonymous-volume identities. The actual VolumeStore claims remain
641        // in `start`, after the reservation has durably recorded ownership.
642        let mut manager = self.new_manager(record)?;
643        #[cfg(target_os = "linux")]
644        {
645            // A Runtime registry credential is staged under the idempotent
646            // create operation before this pre-reservation pass. Clone it for
647            // the metadata pull, while leaving the broker entry available for
648            // the later boot pull. The credential never enters the durable
649            // BoxRecord.
650            if let Some(broker) = &self.transient_registry_auth {
651                manager.transient_registry_auth = broker.clone_auth(metadata.operation_id.as_str());
652            }
653        }
654        let anonymous_volumes = manager
655            .plan_image_anonymous_volumes()
656            .await
657            .map_err(|error| match error {
658                BoxError::ConfigError(message) => ExecutionManagerError::InvalidRequest(message),
659                error => ExecutionManagerError::Unavailable(format!(
660                    "Box image resource planning failed for {}: {error}",
661                    record.id
662                )),
663            })?;
664        Ok(LocalExecutionResourcePlan { anonymous_volumes })
665    }
666
667    async fn start(&self, record: &BoxRecord) -> ExecutionManagerResult<LocalExecutionHandle> {
668        super::record::validate_record_health(record)?;
669        self.metadata(record)?;
670        let box_dir = record.box_dir.clone();
671        let execution_id = record.id.clone();
672        tokio::task::spawn_blocking(move || {
673            crate::rootfs::stage_box_terminal_rootfs_metadata(&box_dir)
674        })
675        .await
676        .map_err(|error| {
677            ExecutionManagerError::Internal(format!(
678                "rootfs metadata staging task failed for {execution_id}: {error}"
679            ))
680        })?
681        .map_err(|error| {
682            ExecutionManagerError::Internal(format!(
683                "failed to stage rootfs metadata for {execution_id}: {error}"
684            ))
685        })?;
686        let mut manager = self.new_manager(record)?;
687        let requested_persistence = manager.config.persistent;
688        if should_reuse_preserved_rootfs(record)?
689            && crate::vm::persistent_rootfs_generation_exists(&record.box_dir)
690                .map_err(|error| runtime_error("inspect retained rootfs", record, error))?
691        {
692            manager.config.persistent = true;
693        }
694        let manager = Arc::new(Mutex::new(manager));
695        match self.managers.entry(record.id.clone()) {
696            Entry::Occupied(_) => {
697                return Err(ExecutionManagerError::Unavailable(format!(
698                    "execution {} already has an in-process runtime owner",
699                    record.id
700                )))
701            }
702            Entry::Vacant(entry) => {
703                entry.insert(Arc::clone(&manager));
704            }
705        }
706
707        let mut guard = manager.lock().await;
708        let resource_home = self.home_dir.clone();
709        let resource_record = record.clone();
710        let resources = match tokio::task::spawn_blocking(move || {
711            ExecutionResourceGuard::prepare(&resource_home, &resource_record)
712        })
713        .await
714        {
715            Ok(Ok(resources)) => resources,
716            Ok(Err(error)) => {
717                drop(guard);
718                self.remove_manager(&record.id, &manager);
719                return Err(error);
720            }
721            Err(error) => {
722                drop(guard);
723                self.remove_manager(&record.id, &manager);
724                return Err(ExecutionManagerError::Internal(format!(
725                    "managed resource preparation task failed for {}: {error}",
726                    record.id
727                )));
728            }
729        };
730        #[cfg(target_os = "linux")]
731        self.claim_transient_registry_auth_for_boot(&mut guard);
732        if let Err(error) = guard.boot().await {
733            guard.config.persistent = requested_persistence;
734            // Sandbox boot cleanup synchronously asks the authoritative OCI
735            // handler for its exit status before tearing down transient host
736            // artifacts. A very short task can therefore complete while boot
737            // is still establishing readiness. Keep the manager whenever that
738            // cleanup captured an exact status so the normal inspect path can
739            // project it into the durable managed record.
740            if guard.exit_code().is_some() {
741                tracing::debug!(
742                    execution_id = %record.id,
743                    %error,
744                    "Runtime completed while startup was establishing readiness"
745                );
746                resources.disarm();
747                return Err(ExecutionManagerError::Unavailable(format!(
748                    "execution {} completed during startup",
749                    record.id
750                )));
751            }
752            drop(guard);
753            self.remove_manager(&record.id, &manager);
754            let rollback = tokio::task::spawn_blocking(move || resources.rollback()).await;
755            if let Err(rollback_error) = rollback {
756                tracing::warn!(
757                    execution_id = %record.id,
758                    %rollback_error,
759                    "Managed resource rollback task failed"
760                );
761            }
762            return Err(runtime_error("start", record, error));
763        }
764        guard.config.persistent = requested_persistence;
765        resources.disarm();
766        let exited_during_start = guard
767            .try_wait_exit()
768            .await
769            .map_err(|error| runtime_error("collect startup exit status", record, error))?
770            .is_some()
771            || guard.has_exited().await;
772        if exited_during_start {
773            return Err(ExecutionManagerError::Unavailable(format!(
774                "execution {} completed during startup",
775                record.id
776            )));
777        }
778        self.handle_from_manager(record, &guard).await
779    }
780
781    async fn inspect(
782        &self,
783        record: &BoxRecord,
784    ) -> ExecutionManagerResult<LocalExecutionObservation> {
785        let metadata = self.metadata(record)?;
786        if metadata.plan.backend.is_sandbox() {
787            return self.inspect_sandbox(record).await;
788        }
789        if let Some(manager) = self.manager(&record.id) {
790            return self.inspect_registered(record, manager).await;
791        }
792        let manager = self.recover_microvm(record).await?;
793        self.inspect_registered(record, manager).await
794    }
795
796    async fn pause(
797        &self,
798        record: &BoxRecord,
799        keep_memory: bool,
800    ) -> ExecutionManagerResult<LocalExecutionHandle> {
801        let metadata = self.metadata(record)?;
802        if metadata.plan.backend.is_sandbox() {
803            if !keep_memory {
804                return Err(unsupported(
805                    record,
806                    "pause without memory retention",
807                    "the Sandbox backend",
808                ));
809            }
810            return self.pause_sandbox(record).await;
811        }
812        if !keep_memory {
813            return Err(unsupported(
814                record,
815                "pause without memory retention",
816                "the local MicroVM backend",
817            ));
818        }
819        #[cfg(windows)]
820        {
821            Err(unsupported(
822                record,
823                "pause",
824                "the local MicroVM backend on Windows",
825            ))
826        }
827        #[cfg(not(windows))]
828        {
829            let shared = self.require_microvm(record).await?;
830            let manager = shared.lock().await;
831            require_recorded_pid(record, &manager).await?;
832            manager
833                .pause()
834                .await
835                .map_err(|error| runtime_error("pause", record, error))?;
836            self.handle_from_manager(record, &manager).await
837        }
838    }
839
840    async fn resume(&self, record: &BoxRecord) -> ExecutionManagerResult<LocalExecutionHandle> {
841        let metadata = self.metadata(record)?;
842        if metadata.plan.backend.is_sandbox() {
843            return self.resume_sandbox(record).await;
844        }
845        #[cfg(windows)]
846        {
847            Err(unsupported(
848                record,
849                "resume",
850                "the local MicroVM backend on Windows",
851            ))
852        }
853        #[cfg(not(windows))]
854        {
855            let shared = self.require_microvm(record).await?;
856            let manager = shared.lock().await;
857            require_recorded_pid(record, &manager).await?;
858            manager
859                .resume()
860                .await
861                .map_err(|error| runtime_error("resume", record, error))?;
862            self.handle_from_manager(record, &manager).await
863        }
864    }
865
866    async fn prepare_quiescent_rootfs(&self, record: &BoxRecord) -> ExecutionManagerResult<()> {
867        self.new_manager(record)?
868            .prepare_preserved_rootfs()
869            .map(|_| ())
870            .map_err(|error| runtime_error("prepare quiescent rootfs", record, error))
871    }
872
873    async fn cleanup_quiescent_rootfs(&self, record: &BoxRecord) -> ExecutionManagerResult<()> {
874        self.new_manager(record)?
875            .cleanup_preserved_rootfs()
876            .map_err(|error| runtime_error("clean up quiescent rootfs", record, error))
877    }
878
879    async fn kill(&self, record: &BoxRecord) -> ExecutionManagerResult<KillOutcome> {
880        Ok(self.terminate_execution(record).await?.outcome)
881    }
882
883    async fn kill_with_status(
884        &self,
885        record: &BoxRecord,
886    ) -> ExecutionManagerResult<LocalExecutionTermination> {
887        self.terminate_execution(record).await
888    }
889
890    async fn stop_for_restart(
891        &self,
892        record: &BoxRecord,
893        timeout_secs: Option<u64>,
894    ) -> ExecutionManagerResult<KillOutcome> {
895        let metadata = self.metadata(record)?;
896        #[cfg(target_os = "linux")]
897        if metadata.plan.backend.is_sandbox() {
898            super::snapshot::persist_sandbox_snapshot_mappings(record)?;
899        }
900        let timeout_secs = timeout_secs.or(record.stop_timeout);
901        if let Some(manager) = self.manager(&record.id) {
902            return Ok(self
903                .destroy_registered(record, manager, false, true, timeout_secs)
904                .await?
905                .outcome);
906        }
907        match metadata.plan.backend {
908            ExecutionBackend::A3sOci => Ok(self
909                .destroy_detached_sandbox(record, false, true, timeout_secs)
910                .await?
911                .outcome),
912            ExecutionBackend::Krun => {
913                let manager = self.recover_microvm(record).await?;
914                Ok(self
915                    .destroy_registered(record, manager, false, true, timeout_secs)
916                    .await?
917                    .outcome)
918            }
919        }
920    }
921}
922
923pub(super) fn should_force_rootfs_preservation(record: &BoxRecord) -> ExecutionManagerResult<bool> {
924    let state = super::support::managed_state(record)?;
925    let metadata = record.managed_execution.as_ref().ok_or_else(|| {
926        ExecutionManagerError::Internal(format!(
927            "execution {} has no managed lifecycle metadata",
928            record.id
929        ))
930    })?;
931    Ok(match state {
932        // A foreground one-shot can finish before readiness is published. Its
933        // exact status is already terminal, but the caller still has to drain
934        // stdout/stderr and archive an auto-removed result. Tear down mounts
935        // and runtime processes while retaining the box directory until that
936        // caller completes its normal terminal cleanup.
937        ManagedExecutionState::Starting => true,
938        ManagedExecutionState::Pausing => matches!(
939            metadata.pending_operation.as_ref(),
940            Some(ManagedExecutionOperation::Pause {
941                keep_memory: false,
942                ..
943            })
944        ),
945        ManagedExecutionState::Resuming => !metadata.paused_with_memory,
946        ManagedExecutionState::RestartStopping | ManagedExecutionState::RestartStarting => true,
947        _ => false,
948    })
949}
950
951fn should_reuse_preserved_rootfs(record: &BoxRecord) -> ExecutionManagerResult<bool> {
952    Ok(matches!(
953        super::support::managed_state(record)?,
954        ManagedExecutionState::Resuming | ManagedExecutionState::RestartStarting
955    ) && should_force_rootfs_preservation(record)?)
956}
957
958fn graceful_stop_options(
959    record: &BoxRecord,
960    timeout_secs: Option<u64>,
961) -> ExecutionManagerResult<Option<(i32, u64)>> {
962    if timeout_secs.is_none() && record.stop_signal.is_none() {
963        return Ok(None);
964    }
965    let timeout_ms = timeout_secs
966        .unwrap_or(DEFAULT_SHUTDOWN_TIMEOUT_MS / 1_000)
967        .checked_mul(1_000)
968        .ok_or_else(|| {
969            ExecutionManagerError::InvalidRequest(format!(
970                "stop timeout is too large for execution {}",
971                record.id
972            ))
973        })?;
974    let signal = record
975        .stop_signal
976        .as_deref()
977        .map(a3s_box_core::vmm::parse_signal_name)
978        .unwrap_or(libc::SIGTERM);
979    Ok(Some((signal, timeout_ms)))
980}
981
982#[cfg(not(windows))]
983async fn require_recorded_pid(
984    record: &BoxRecord,
985    manager: &VmManager,
986) -> ExecutionManagerResult<()> {
987    let execution_id = execution_id(record)?;
988    let pid = manager
989        .pid()
990        .await
991        .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?;
992    if record.pid != Some(pid)
993        || !crate::process::is_process_alive_with_identity(pid, record.pid_start_time)
994    {
995        return Err(ExecutionManagerError::NotFound(execution_id));
996    }
997    Ok(())
998}
999
1000fn visible_active_state(record: &BoxRecord) -> ExecutionManagerResult<ExecutionState> {
1001    match managed_state(record)? {
1002        ManagedExecutionState::Paused => Ok(ExecutionState::Paused),
1003        ManagedExecutionState::Resuming => {
1004            let metadata = record.managed_execution.as_ref().ok_or_else(|| {
1005                ExecutionManagerError::Internal(format!(
1006                    "execution {} has no managed lifecycle metadata",
1007                    record.id
1008                ))
1009            })?;
1010            if metadata.paused_with_memory {
1011                Ok(ExecutionState::Paused)
1012            } else {
1013                // A cold-paused execution has no provider process. Any live
1014                // process observed while its resume is pending is therefore
1015                // the replacement generation started before record commit.
1016                Ok(ExecutionState::Running)
1017            }
1018        }
1019        ManagedExecutionState::Starting
1020        | ManagedExecutionState::RestartStarting
1021        | ManagedExecutionState::Running
1022        | ManagedExecutionState::Pausing
1023        | ManagedExecutionState::Killing => Ok(ExecutionState::Running),
1024        ManagedExecutionState::Snapshotting => match record
1025            .managed_execution
1026            .as_ref()
1027            .and_then(|metadata| metadata.pending_operation.as_ref())
1028        {
1029            Some(ManagedExecutionOperation::Snapshot {
1030                source_state: ManagedExecutionState::Running,
1031                ..
1032            }) => Ok(ExecutionState::Running),
1033            Some(ManagedExecutionOperation::Snapshot {
1034                source_state: ManagedExecutionState::Paused,
1035                ..
1036            }) => Ok(ExecutionState::Paused),
1037            _ => Err(ExecutionManagerError::Internal(format!(
1038                "execution {} has invalid snapshot metadata",
1039                record.id
1040            ))),
1041        },
1042        ManagedExecutionState::RestartStopping => match record
1043            .managed_execution
1044            .as_ref()
1045            .and_then(|metadata| metadata.pending_operation.as_ref())
1046        {
1047            Some(ManagedExecutionOperation::Restart {
1048                source_state: ManagedExecutionState::Paused,
1049                ..
1050            }) => Ok(ExecutionState::Paused),
1051            Some(ManagedExecutionOperation::Restart {
1052                source_state: ManagedExecutionState::Running,
1053                ..
1054            }) => Ok(ExecutionState::Running),
1055            _ => Err(ExecutionManagerError::Internal(format!(
1056                "execution {} has invalid restart teardown metadata",
1057                record.id
1058            ))),
1059        },
1060        state => Err(ExecutionManagerError::Internal(format!(
1061            "execution {} has no active runtime in managed state {state}",
1062            record.id
1063        ))),
1064    }
1065}
1066
1067fn managed_state(record: &BoxRecord) -> ExecutionManagerResult<ManagedExecutionState> {
1068    record
1069        .managed_state()
1070        .map_err(|error| ExecutionManagerError::Internal(error.to_string()))?
1071        .ok_or_else(|| {
1072            ExecutionManagerError::Internal(format!("execution {} is not managed", record.id))
1073        })
1074}
1075
1076fn execution_id(record: &BoxRecord) -> ExecutionManagerResult<ExecutionId> {
1077    ExecutionId::new(record.id.clone())
1078        .map_err(|error| ExecutionManagerError::Internal(error.to_string()))
1079}
1080
1081fn runtime_error(
1082    action: &str,
1083    record: &BoxRecord,
1084    error: impl std::fmt::Display,
1085) -> ExecutionManagerError {
1086    ExecutionManagerError::Internal(format!(
1087        "failed to {action} execution {}: {error}",
1088        record.id
1089    ))
1090}
1091
1092fn unsupported(record: &BoxRecord, operation: &str, backend: &str) -> ExecutionManagerError {
1093    ExecutionManagerError::Unavailable(format!(
1094        "{operation} is not supported by {backend} for execution {}",
1095        record.id
1096    ))
1097}
1098
1099#[cfg(unix)]
1100async fn exec_endpoint_ready(path: Option<&Path>) -> bool {
1101    let Some(path) = path else {
1102        return false;
1103    };
1104    let attempt = async {
1105        let client = crate::ExecClient::connect(path).await.ok()?;
1106        client.heartbeat().await.ok().filter(|ready| *ready)
1107    };
1108    tokio::time::timeout(Duration::from_millis(500), attempt)
1109        .await
1110        .ok()
1111        .flatten()
1112        .is_some()
1113}
1114
1115#[cfg(not(unix))]
1116async fn exec_endpoint_ready(path: Option<&Path>) -> bool {
1117    path.is_some()
1118}
1119
1120#[cfg(test)]
1121#[path = "vm_backend_tests.rs"]
1122mod tests;