Skip to main content

a3s_box_runtime/vm/
lifecycle.rs

1//! VM teardown, state transitions, pause/resume, health, and resizing.
2
3use super::*;
4
5#[cfg(unix)]
6const GUEST_STOP_DELIVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
7#[cfg(unix)]
8const GUEST_STOP_FINALIZATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
9#[cfg(unix)]
10const GUEST_STOP_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(25);
11
12#[cfg(unix)]
13async fn wait_for_provider_exit(
14    handler: &mut dyn VmHandler,
15    timeout: std::time::Duration,
16) -> Result<bool> {
17    let deadline = tokio::time::Instant::now() + timeout;
18    loop {
19        if handler.try_wait_exit()?.is_some() || handler.has_exited() || !handler.is_running() {
20            return Ok(true);
21        }
22        let now = tokio::time::Instant::now();
23        if now >= deadline {
24            return Ok(false);
25        }
26        tokio::time::sleep(GUEST_STOP_POLL_INTERVAL.min(deadline - now)).await;
27    }
28}
29
30impl VmManager {
31    /// Destroy the VM with the default shutdown timeout and SIGTERM.
32    pub async fn destroy(&mut self) -> Result<()> {
33        self.destroy_with_options(default_stop_signal(), DEFAULT_SHUTDOWN_TIMEOUT_MS)
34            .await
35    }
36
37    /// Destroy the VM with a custom shutdown timeout and SIGTERM.
38    pub async fn destroy_with_timeout(&mut self, timeout_ms: u64) -> Result<()> {
39        self.destroy_with_options(default_stop_signal(), timeout_ms)
40            .await
41    }
42
43    /// Destroy the VM with a specific stop signal and timeout.
44    ///
45    /// Delivers `signal` to the workload through the private guest control
46    /// channel and waits up to `timeout_ms` for it to exit. If it does not,
47    /// asks the guest to SIGKILL the workload and gives PID 1 a bounded window
48    /// to flush and quiesce the root disk. Signalling the shim is the final
49    /// fallback only when guest-owned shutdown cannot complete.
50    #[tracing::instrument(skip(self), fields(box_id = %self.box_id))]
51    pub async fn destroy_with_options(&mut self, signal: i32, timeout_ms: u64) -> Result<()> {
52        let preserve_rootfs = self.config.persistent;
53        self.destroy_with_rootfs_policy(signal, timeout_ms, preserve_rootfs)
54            .await
55    }
56
57    /// Stop the runtime while retaining its writable rootfs for a managed
58    /// restart or filesystem-only pause.
59    pub(crate) async fn destroy_preserving_rootfs_with_options(
60        &mut self,
61        signal: i32,
62        timeout_ms: u64,
63    ) -> Result<()> {
64        self.destroy_with_rootfs_policy(signal, timeout_ms, true)
65            .await
66    }
67
68    pub(crate) async fn destroy_preserving_rootfs(&mut self) -> Result<()> {
69        self.destroy_with_rootfs_policy(default_stop_signal(), DEFAULT_SHUTDOWN_TIMEOUT_MS, true)
70            .await
71    }
72
73    async fn destroy_with_rootfs_policy(
74        &mut self,
75        signal: i32,
76        timeout_ms: u64,
77        preserve_rootfs: bool,
78    ) -> Result<()> {
79        let mut state = self.state.write().await;
80
81        if *state == BoxState::Stopped {
82            return Ok(());
83        }
84
85        tracing::info!(box_id = %self.box_id, signal, timeout_ms, "Destroying VM");
86
87        // Mark as stopped first — ensures state is correct even if handler.stop() fails.
88        *state = BoxState::Stopped;
89
90        let box_dir = self.home_dir.join("boxes").join(&self.box_id);
91
92        // Stop the VM handler and capture its exit code before it's dropped.
93        // A stop failure must NOT skip the host-resource teardown below (network
94        // backend, overlay unmount, socket + box dirs) — those are already
95        // best-effort and would otherwise leak on every wedged stop. Capture the
96        // error and surface it after teardown instead of returning early.
97        let mut stop_error = None;
98        #[cfg(unix)]
99        let requires_guest_rootfs_handoff =
100            if preserve_rootfs && self.boot_mode != VmBootMode::RootfsMaintenance {
101                match crate::rootfs::guest_native_ext4_generation_exists(&box_dir) {
102                    Ok(exists) => exists,
103                    Err(error) => {
104                        stop_error = Some(error);
105                        false
106                    }
107                }
108            } else {
109                false
110            };
111        if let Some(mut handler) = self.handler.write().await.take() {
112            #[cfg(windows)]
113            let stop_request = match windows_stop::stage(&self.socket_dir(), signal) {
114                Ok(path) => {
115                    tracing::debug!(
116                        box_id = %self.box_id,
117                        signal,
118                        path = %path.display(),
119                        "Staged Windows guest stop request"
120                    );
121                    Some(path)
122                }
123                Err(error) => {
124                    tracing::warn!(
125                        box_id = %self.box_id,
126                        signal,
127                        error = %error,
128                        "Failed to stage Windows guest stop request; force-stop fallback remains active"
129                    );
130                    None
131                }
132            };
133
134            #[cfg(windows)]
135            let handler_timeout_ms = if timeout_ms == 0 {
136                0
137            } else if let Some(request) = stop_request.as_deref() {
138                let delivery_started = std::time::Instant::now();
139                let delivery_timeout = std::time::Duration::from_millis(
140                    timeout_ms.min(WINDOWS_STOP_DELIVERY_TIMEOUT_MS),
141                );
142                let delivered =
143                    match windows_stop::wait_until_delivered(request, delivery_timeout).await {
144                        Ok(delivered) => delivered,
145                        Err(error) => {
146                            tracing::warn!(
147                                box_id = %self.box_id,
148                                error = %error,
149                                "Failed while waiting for Windows guest stop request delivery"
150                            );
151                            false
152                        }
153                    };
154                let delivery_elapsed_ms =
155                    u64::try_from(delivery_started.elapsed().as_millis()).unwrap_or(u64::MAX);
156                let remaining_timeout_ms = timeout_ms.saturating_sub(delivery_elapsed_ms);
157                if delivered {
158                    let finalization_timeout_ms = if self.config.persistent {
159                        WINDOWS_GUEST_FINALIZATION_TIMEOUT_MS
160                    } else {
161                        0
162                    };
163                    let handler_timeout_ms =
164                        remaining_timeout_ms.saturating_add(finalization_timeout_ms);
165                    tracing::debug!(
166                        box_id = %self.box_id,
167                        delivery_elapsed_ms,
168                        handler_timeout_ms,
169                        "Delivered Windows stop request to the guest"
170                    );
171                    handler_timeout_ms
172                } else {
173                    tracing::warn!(
174                        box_id = %self.box_id,
175                        delivery_elapsed_ms,
176                        "Windows guest stop request was not delivered before the forwarding deadline"
177                    );
178                    remaining_timeout_ms
179                }
180            } else {
181                timeout_ms
182            };
183            #[cfg(unix)]
184            let guest_stop_delivered = if self.boot_mode == VmBootMode::RootfsMaintenance {
185                self.deliver_rootfs_maintenance_shutdown().await
186            } else {
187                self.deliver_guest_stop_signal(signal).await
188            };
189            #[cfg(unix)]
190            let _provider_exited = if guest_stop_delivered {
191                let graceful_wait = if signal == libc::SIGKILL {
192                    std::time::Duration::ZERO
193                } else {
194                    std::time::Duration::from_millis(timeout_ms)
195                };
196                let exited = match wait_for_provider_exit(handler.as_mut(), graceful_wait).await {
197                    Ok(exited) => exited,
198                    Err(error) => {
199                        tracing::warn!(
200                            box_id = %self.box_id,
201                            %error,
202                            "Failed while waiting for guest-owned shutdown"
203                        );
204                        if stop_error.is_none() {
205                            stop_error = Some(error);
206                        }
207                        false
208                    }
209                };
210                if exited {
211                    true
212                } else {
213                    let force_delivered = signal == libc::SIGKILL
214                        || self.deliver_guest_stop_signal(libc::SIGKILL).await;
215                    if force_delivered {
216                        match wait_for_provider_exit(
217                            handler.as_mut(),
218                            GUEST_STOP_FINALIZATION_TIMEOUT,
219                        )
220                        .await
221                        {
222                            Ok(exited) => exited,
223                            Err(error) => {
224                                tracing::warn!(
225                                    box_id = %self.box_id,
226                                    %error,
227                                    "Failed while waiting for forced guest finalization"
228                                );
229                                if stop_error.is_none() {
230                                    stop_error = Some(error);
231                                }
232                                false
233                            }
234                        }
235                    } else {
236                        false
237                    }
238                }
239            } else {
240                false
241            };
242
243            #[cfg(unix)]
244            let handler_signal = if guest_stop_delivered {
245                libc::SIGKILL
246            } else {
247                signal
248            };
249            #[cfg(windows)]
250            let handler_signal = signal;
251            #[cfg(unix)]
252            let handler_timeout_ms = if guest_stop_delivered { 0 } else { timeout_ms };
253
254            // Observing provider exit proves that the workload stopped, but it
255            // does not finalize the backend. In particular, A3S OCI owns its
256            // terminal generation and private endpoint until `stop` performs
257            // the authoritative delete. Every handler implementation treats an
258            // already-exited process idempotently, so always run this finalizer;
259            // the zero timeout prevents a second graceful-wait interval.
260            let _handler_stopped = match handler.stop(handler_signal, handler_timeout_ms) {
261                Ok(()) => true,
262                Err(e) => {
263                    tracing::error!(box_id = %self.box_id, error = %e, "Failed to stop VM handler; continuing teardown");
264                    if stop_error.is_none() {
265                        stop_error = Some(e);
266                    }
267                    false
268                }
269            };
270            #[cfg(not(windows))]
271            {
272                self.shim_exit_code =
273                    crate::rootfs::resolve_workload_exit_code(&box_dir, handler.exit_code());
274            }
275            #[cfg(windows)]
276            {
277                self.shim_exit_code = handler.exit_code();
278            }
279
280            #[cfg(unix)]
281            let clean_guest_rootfs_handoff = _handler_stopped
282                && requires_guest_rootfs_handoff
283                && crate::rootfs::guest_rootfs_handoff_complete(&box_dir);
284            #[cfg(unix)]
285            if _handler_stopped && requires_guest_rootfs_handoff && !clean_guest_rootfs_handoff {
286                let error = BoxError::StateError(format!(
287                    "Guest-owned persistent rootfs for {} stopped without a verified read-only handoff; the raw disk was retained but must not be treated as clean",
288                    self.box_id
289                ));
290                tracing::error!(box_id = %self.box_id, %error);
291                if stop_error.is_none() {
292                    stop_error = Some(error);
293                }
294            }
295
296            #[cfg(unix)]
297            if clean_guest_rootfs_handoff && stop_error.is_none() {
298                if let Err(error) = self.rootfs_provider.record_clean_stop(&box_dir) {
299                    tracing::error!(
300                        box_id = %self.box_id,
301                        %error,
302                        "Failed to publish the verified rootfs clean-stop transition"
303                    );
304                    stop_error = Some(error);
305                }
306            }
307
308            #[cfg(windows)]
309            if stop_request.is_some() {
310                if let Err(error) = windows_stop::clear(&self.socket_dir()) {
311                    tracing::warn!(
312                        box_id = %self.box_id,
313                        error = %error,
314                        "Failed to clear Windows guest stop request"
315                    );
316                }
317            }
318
319            #[cfg(windows)]
320            if _handler_stopped && self.config.persistent {
321                let rootfs = self
322                    .home_dir
323                    .join("boxes")
324                    .join(&self.box_id)
325                    .join("rootfs");
326                match a3s_box_core::rootfs_metadata::finalize_terminal_rootfs_metadata(&rootfs) {
327                    Ok(true) => tracing::info!(
328                        box_id = %self.box_id,
329                        path = %rootfs.display(),
330                        "Published terminal rootfs metadata after Windows guest exit"
331                    ),
332                    Ok(false) => tracing::debug!(
333                        box_id = %self.box_id,
334                        path = %rootfs.display(),
335                        "No Windows terminal rootfs metadata required host finalization"
336                    ),
337                    Err(error) => tracing::warn!(
338                        box_id = %self.box_id,
339                        path = %rootfs.display(),
340                        error = %error,
341                        "Refused to publish invalid Windows terminal rootfs metadata"
342                    ),
343                }
344            }
345        }
346
347        // Stop network backend if running
348        if let Some(ref mut net) = self.net_manager {
349            net.stop();
350        }
351        self.net_manager = None;
352
353        let mount_aliases_clean = match self.cleanup_sandbox_mount_aliases() {
354            Ok(()) => true,
355            Err(error) => {
356                tracing::error!(
357                    box_id = %self.box_id,
358                    %error,
359                    "Failed to cleanup Sandbox attachment aliases"
360                );
361                if stop_error.is_none() {
362                    stop_error = Some(error);
363                }
364                false
365            }
366        };
367
368        let socket_dir = self.socket_dir();
369        // A detached CLI invocation recovers the shim but has no in-memory
370        // PasstManager child handle. Reap passt from its durable PID file before
371        // removing the socket directory that contains that identity; otherwise
372        // a later managed remove cannot find the daemon and it keeps published
373        // ports bound indefinitely.
374        #[cfg(target_os = "linux")]
375        crate::network::terminate_passt(&socket_dir);
376
377        // Cleanup rootfs provider (unmount overlay if applicable)
378        if let Err(e) = self.rootfs_provider.cleanup(&box_dir, preserve_rootfs) {
379            tracing::warn!(
380                box_id = %self.box_id,
381                error = %e,
382                "Failed to cleanup rootfs provider"
383            );
384        }
385
386        if let Err(e) = std::fs::remove_dir_all(&socket_dir) {
387            tracing::debug!(
388                box_id = %self.box_id,
389                path = %socket_dir.display(),
390                error = %e,
391                "Failed to cleanup VM socket directory"
392            );
393        }
394
395        // Remove the box working directory itself (overlay upper/work, logs,
396        // leftover metadata) for non-persistent boxes. Without this, ephemeral
397        // CRI pods leak their `boxes/<id>` directory on every destroy; the
398        // accumulation slows later RunPodSandbox calls until they time out
399        // (observed: pod #21 after churning 20). Persistent boxes keep their
400        // dir intentionally.
401        if !preserve_rootfs && mount_aliases_clean {
402            match std::fs::remove_dir_all(&box_dir) {
403                Ok(()) => {}
404                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
405                Err(e) => {
406                    tracing::warn!(
407                        box_id = %self.box_id,
408                        path = %box_dir.display(),
409                        error = %e,
410                        "Failed to remove box directory on destroy"
411                    );
412                }
413            }
414        }
415
416        // Record Prometheus metrics
417        if let Some(ref prom) = self.prom {
418            prom.vm_destroyed_total.inc();
419            prom.vm_count.with_label_values(&["ready"]).dec();
420        }
421
422        // Emit stopped event
423        self.event_emitter.emit(BoxEvent::empty("box.stopped"));
424
425        // Host teardown above is complete; surface a handler-stop failure now so
426        // the caller still learns the stop was imperfect.
427        match stop_error {
428            Some(e) => Err(e),
429            None => Ok(()),
430        }
431    }
432
433    #[cfg(unix)]
434    async fn deliver_guest_stop_signal(&self, signal: i32) -> bool {
435        let socket_path = self
436            .exec_socket_path
437            .as_deref()
438            .or_else(|| self.exec_client.as_ref().map(ExecClient::socket_path));
439        let Some(socket_path) = socket_path else {
440            return false;
441        };
442        let client = ExecClient::for_socket(socket_path);
443        match tokio::time::timeout(GUEST_STOP_DELIVERY_TIMEOUT, client.signal_main(signal)).await {
444            Ok(Ok(true)) => {
445                tracing::debug!(
446                    box_id = %self.box_id,
447                    signal,
448                    socket_path = %socket_path.display(),
449                    "Delivered stop signal to the workload through guest control"
450                );
451                true
452            }
453            Ok(Ok(false)) => {
454                tracing::warn!(
455                    box_id = %self.box_id,
456                    signal,
457                    socket_path = %socket_path.display(),
458                    "Guest did not acknowledge the workload stop signal"
459                );
460                false
461            }
462            Ok(Err(error)) => {
463                tracing::warn!(
464                    box_id = %self.box_id,
465                    signal,
466                    socket_path = %socket_path.display(),
467                    %error,
468                    "Failed to deliver the workload stop signal through guest control"
469                );
470                false
471            }
472            Err(_) => {
473                tracing::warn!(
474                    box_id = %self.box_id,
475                    signal,
476                    socket_path = %socket_path.display(),
477                    "Timed out delivering the workload stop signal through guest control"
478                );
479                false
480            }
481        }
482    }
483
484    #[cfg(unix)]
485    async fn deliver_rootfs_maintenance_shutdown(&self) -> bool {
486        let socket_path = self
487            .exec_socket_path
488            .as_deref()
489            .or_else(|| self.exec_client.as_ref().map(ExecClient::socket_path));
490        let Some(socket_path) = socket_path else {
491            return false;
492        };
493        let client = ExecClient::for_socket(socket_path);
494        match tokio::time::timeout(
495            GUEST_STOP_DELIVERY_TIMEOUT,
496            client.shutdown_rootfs_maintenance(),
497        )
498        .await
499        {
500            Ok(Ok(true)) => {
501                tracing::debug!(
502                    box_id = %self.box_id,
503                    socket_path = %socket_path.display(),
504                    "Requested clean rootfs maintenance guest shutdown"
505                );
506                true
507            }
508            Ok(Ok(false)) | Ok(Err(_)) | Err(_) => {
509                tracing::warn!(
510                    box_id = %self.box_id,
511                    socket_path = %socket_path.display(),
512                    "Rootfs maintenance guest did not acknowledge shutdown"
513                );
514                false
515            }
516        }
517    }
518
519    /// Transition to busy state.
520    pub async fn set_busy(&self) -> Result<()> {
521        let mut state = self.state.write().await;
522
523        if *state != BoxState::Ready {
524            return Err(BoxError::StateError("VM not ready".to_string()));
525        }
526
527        *state = BoxState::Busy;
528        Ok(())
529    }
530
531    /// Transition back to ready state.
532    pub async fn set_ready(&self) -> Result<()> {
533        let mut state = self.state.write().await;
534
535        if *state != BoxState::Busy && *state != BoxState::Compacting {
536            return Err(BoxError::StateError("Invalid state transition".to_string()));
537        }
538
539        *state = BoxState::Ready;
540        Ok(())
541    }
542
543    /// Transition to compacting state.
544    pub async fn set_compacting(&self) -> Result<()> {
545        let mut state = self.state.write().await;
546
547        if *state != BoxState::Busy {
548            return Err(BoxError::StateError("VM not busy".to_string()));
549        }
550
551        *state = BoxState::Compacting;
552        Ok(())
553    }
554
555    /// Pause the VM by sending SIGSTOP to the shim process.
556    ///
557    /// The VM must be in Ready, Busy, or Compacting state.
558    #[cfg(unix)]
559    pub async fn pause(&self) -> Result<()> {
560        let state = self.state.read().await;
561        match *state {
562            BoxState::Ready | BoxState::Busy | BoxState::Compacting => {}
563            BoxState::Created => {
564                return Err(BoxError::StateError("VM not yet booted".to_string()));
565            }
566            BoxState::Stopped => {
567                return Err(BoxError::StateError("VM is stopped".to_string()));
568            }
569        }
570        drop(state);
571
572        if self
573            .resolved_execution_plan
574            .as_ref()
575            .is_some_and(|plan| plan.backend.is_sandbox())
576            || self.config.isolation.is_sandbox()
577        {
578            return Err(BoxError::StateError(
579                "Pause is not supported by the Sandbox backend yet".to_string(),
580            ));
581        }
582
583        if let Some(pid) = self.pid().await {
584            // Safety: sending SIGSTOP to pause the process
585            let ret = unsafe { libc::kill(pid as i32, libc::SIGSTOP) };
586            if ret != 0 {
587                let err = std::io::Error::last_os_error();
588                return Err(BoxError::ExecError(format!(
589                    "Failed to send SIGSTOP to pid {}: {}",
590                    pid, err
591                )));
592            }
593            tracing::info!(box_id = %self.box_id, pid, "VM paused");
594            Ok(())
595        } else {
596            Err(BoxError::StateError(
597                "VM has no running process".to_string(),
598            ))
599        }
600    }
601
602    /// Resume the VM by sending SIGCONT to the shim process.
603    ///
604    /// Can be called on a paused VM to resume execution.
605    #[cfg(unix)]
606    pub async fn resume(&self) -> Result<()> {
607        if self
608            .resolved_execution_plan
609            .as_ref()
610            .is_some_and(|plan| plan.backend.is_sandbox())
611            || self.config.isolation.is_sandbox()
612        {
613            return Err(BoxError::StateError(
614                "Resume is not supported by the Sandbox backend yet".to_string(),
615            ));
616        }
617        if let Some(pid) = self.pid().await {
618            // Safety: sending SIGCONT to resume the process
619            let ret = unsafe { libc::kill(pid as i32, libc::SIGCONT) };
620            if ret != 0 {
621                let err = std::io::Error::last_os_error();
622                return Err(BoxError::ExecError(format!(
623                    "Failed to send SIGCONT to pid {}: {}",
624                    pid, err
625                )));
626            }
627            tracing::info!(box_id = %self.box_id, pid, "VM resumed");
628            Ok(())
629        } else {
630            Err(BoxError::StateError(
631                "VM has no running process".to_string(),
632            ))
633        }
634    }
635
636    /// Pause the VM (Windows stub - not yet implemented).
637    #[cfg(windows)]
638    pub async fn pause(&self) -> Result<()> {
639        Err(BoxError::StateError(
640            "VM pause is not yet supported on Windows".to_string(),
641        ))
642    }
643
644    /// Resume the VM (Windows stub - not yet implemented).
645    #[cfg(windows)]
646    pub async fn resume(&self) -> Result<()> {
647        Err(BoxError::StateError(
648            "VM resume is not yet supported on Windows".to_string(),
649        ))
650    }
651
652    /// Check if VM is healthy.
653    pub async fn health_check(&self) -> Result<bool> {
654        let state = self.state.read().await;
655
656        match *state {
657            BoxState::Ready | BoxState::Busy | BoxState::Compacting => {
658                // Check if handler reports VM is running
659                if let Some(ref handler) = *self.handler.read().await {
660                    Ok(handler.is_running())
661                } else {
662                    Ok(false)
663                }
664            }
665            _ => Ok(false),
666        }
667    }
668
669    /// Get VM metrics.
670    pub async fn metrics(&self) -> Option<crate::vmm::VmMetrics> {
671        let vm_metrics = self
672            .handler
673            .read()
674            .await
675            .as_ref()
676            .map(|handler| handler.metrics())?;
677
678        // Update per-VM Prometheus gauges if metrics are attached
679        if let Some(ref prom) = self.prom {
680            prom.vm_cpu_percent
681                .with_label_values(&[&self.box_id])
682                .set(vm_metrics.cpu_percent.unwrap_or(0.0) as f64);
683            prom.vm_memory_bytes
684                .with_label_values(&[&self.box_id])
685                .set(vm_metrics.memory_bytes.unwrap_or(0) as f64);
686        }
687
688        Some(vm_metrics)
689    }
690
691    /// Get the PID of the VM shim process.
692    pub async fn pid(&self) -> Option<u32> {
693        self.handler
694            .read()
695            .await
696            .as_ref()
697            .map(|handler| handler.pid())
698    }
699
700    /// Get the TEE extension, if TEE is configured and VM is booted.
701    #[cfg(unix)]
702    pub fn tee(&self) -> Option<&dyn TeeExtension> {
703        self.tee.as_deref()
704    }
705
706    /// Get the TEE extension or return an error.
707    #[cfg(unix)]
708    pub fn require_tee(&self) -> Result<&dyn TeeExtension> {
709        self.tee.as_deref().ok_or_else(|| {
710            BoxError::AttestationError("TEE is not configured for this box".to_string())
711        })
712    }
713
714    /// Apply a live resource update to the running backend.
715    ///
716    /// Tier 1 changes (provisioned vCPU count and memory size) retain the public
717    /// stop/recreate contract across backends.
718    ///
719    /// Tier 2 changes use one backend-owned path: the exact-generation A3S OCI
720    /// update for a host Sandbox, or guest cgroup writes for a MicroVM.
721    #[cfg(unix)]
722    pub async fn update_resources(
723        &mut self,
724        update: &crate::resize::ResourceUpdate,
725    ) -> Result<crate::resize::ResizeResult> {
726        crate::resize::validate_update(update)?;
727
728        let mut result = crate::resize::ResizeResult {
729            applied: Vec::new(),
730            rejected: Vec::new(),
731        };
732
733        if !update.has_tier2_changes() {
734            return Ok(result);
735        }
736
737        let sandbox = self
738            .resolved_execution_plan
739            .as_ref()
740            .is_some_and(|plan| plan.backend.is_sandbox())
741            || self.config.isolation.is_sandbox();
742        if sandbox {
743            let mut next_config = self.config.clone();
744            update.apply_to_config(&mut next_config);
745            let runtime_config = next_config.clone();
746            let box_dir = self.home_dir.join("boxes").join(&self.box_id);
747            let box_id = self.box_id.clone();
748            tokio::task::spawn_blocking(move || {
749                crate::sandbox::update_recorded_resources(&box_dir, &box_id, &runtime_config)
750            })
751            .await
752            .map_err(|error| {
753                BoxError::StateError(format!(
754                    "A3S OCI resource update worker failed for {}: {error}",
755                    self.box_id
756                ))
757            })??;
758            self.config = next_config;
759            result.applied = update
760                .tier2_change_names()
761                .into_iter()
762                .map(str::to_string)
763                .collect();
764            return Ok(result);
765        }
766
767        // Build cgroup commands and execute them inside the guest
768        let commands = update.build_microvm_cgroup_commands();
769        for cmd_str in &commands {
770            let shell_cmd = vec!["sh".to_string(), "-c".to_string(), cmd_str.clone()];
771
772            match self.exec_command(shell_cmd, 5_000_000_000).await {
773                Ok(output) if output.exit_code == 0 => {
774                    result.applied.push(cmd_str.clone());
775                }
776                Ok(output) => {
777                    let stderr = String::from_utf8_lossy(&output.stderr);
778                    let reason = if stderr.trim().is_empty() {
779                        format!("exit code {}", output.exit_code)
780                    } else {
781                        stderr.trim().to_string()
782                    };
783                    tracing::warn!(
784                        box_id = %self.box_id,
785                        cmd = %cmd_str,
786                        exit_code = output.exit_code,
787                        stderr = %stderr,
788                        "Cgroup update failed inside guest"
789                    );
790                    result.rejected.push((cmd_str.clone(), reason));
791                }
792                Err(e) => {
793                    tracing::warn!(
794                        box_id = %self.box_id,
795                        cmd = %cmd_str,
796                        error = %e,
797                        "Failed to exec cgroup update in guest"
798                    );
799                    result.rejected.push((cmd_str.clone(), e.to_string()));
800                }
801            }
802        }
803
804        Ok(result)
805    }
806}