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            prom.remove_vm_resource_metrics(&self.box_id);
421        }
422
423        // Emit stopped event
424        self.event_emitter.emit(BoxEvent::empty("box.stopped"));
425
426        // Host teardown above is complete; surface a handler-stop failure now so
427        // the caller still learns the stop was imperfect.
428        match stop_error {
429            Some(e) => Err(e),
430            None => Ok(()),
431        }
432    }
433
434    #[cfg(unix)]
435    async fn deliver_guest_stop_signal(&self, signal: i32) -> bool {
436        let socket_path = self
437            .exec_socket_path
438            .as_deref()
439            .or_else(|| self.exec_client.as_ref().map(ExecClient::socket_path));
440        let Some(socket_path) = socket_path else {
441            return false;
442        };
443        let client = ExecClient::for_socket(socket_path);
444        match tokio::time::timeout(GUEST_STOP_DELIVERY_TIMEOUT, client.signal_main(signal)).await {
445            Ok(Ok(true)) => {
446                tracing::debug!(
447                    box_id = %self.box_id,
448                    signal,
449                    socket_path = %socket_path.display(),
450                    "Delivered stop signal to the workload through guest control"
451                );
452                true
453            }
454            Ok(Ok(false)) => {
455                tracing::warn!(
456                    box_id = %self.box_id,
457                    signal,
458                    socket_path = %socket_path.display(),
459                    "Guest did not acknowledge the workload stop signal"
460                );
461                false
462            }
463            Ok(Err(error)) => {
464                tracing::warn!(
465                    box_id = %self.box_id,
466                    signal,
467                    socket_path = %socket_path.display(),
468                    %error,
469                    "Failed to deliver the workload stop signal through guest control"
470                );
471                false
472            }
473            Err(_) => {
474                tracing::warn!(
475                    box_id = %self.box_id,
476                    signal,
477                    socket_path = %socket_path.display(),
478                    "Timed out delivering the workload stop signal through guest control"
479                );
480                false
481            }
482        }
483    }
484
485    #[cfg(unix)]
486    async fn deliver_rootfs_maintenance_shutdown(&self) -> bool {
487        let socket_path = self
488            .exec_socket_path
489            .as_deref()
490            .or_else(|| self.exec_client.as_ref().map(ExecClient::socket_path));
491        let Some(socket_path) = socket_path else {
492            return false;
493        };
494        let client = ExecClient::for_socket(socket_path);
495        match tokio::time::timeout(
496            GUEST_STOP_DELIVERY_TIMEOUT,
497            client.shutdown_rootfs_maintenance(),
498        )
499        .await
500        {
501            Ok(Ok(true)) => {
502                tracing::debug!(
503                    box_id = %self.box_id,
504                    socket_path = %socket_path.display(),
505                    "Requested clean rootfs maintenance guest shutdown"
506                );
507                true
508            }
509            Ok(Ok(false)) | Ok(Err(_)) | Err(_) => {
510                tracing::warn!(
511                    box_id = %self.box_id,
512                    socket_path = %socket_path.display(),
513                    "Rootfs maintenance guest did not acknowledge shutdown"
514                );
515                false
516            }
517        }
518    }
519
520    /// Transition to busy state.
521    pub async fn set_busy(&self) -> Result<()> {
522        let mut state = self.state.write().await;
523
524        if *state != BoxState::Ready {
525            return Err(BoxError::StateError("VM not ready".to_string()));
526        }
527
528        *state = BoxState::Busy;
529        Ok(())
530    }
531
532    /// Transition back to ready state.
533    pub async fn set_ready(&self) -> Result<()> {
534        let mut state = self.state.write().await;
535
536        if *state != BoxState::Busy && *state != BoxState::Compacting {
537            return Err(BoxError::StateError("Invalid state transition".to_string()));
538        }
539
540        *state = BoxState::Ready;
541        Ok(())
542    }
543
544    /// Transition to compacting state.
545    pub async fn set_compacting(&self) -> Result<()> {
546        let mut state = self.state.write().await;
547
548        if *state != BoxState::Busy {
549            return Err(BoxError::StateError("VM not busy".to_string()));
550        }
551
552        *state = BoxState::Compacting;
553        Ok(())
554    }
555
556    /// Pause the VM by sending SIGSTOP to the shim process.
557    ///
558    /// The VM must be in Ready, Busy, or Compacting state.
559    #[cfg(unix)]
560    pub async fn pause(&self) -> Result<()> {
561        let state = self.state.read().await;
562        match *state {
563            BoxState::Ready | BoxState::Busy | BoxState::Compacting => {}
564            BoxState::Created => {
565                return Err(BoxError::StateError("VM not yet booted".to_string()));
566            }
567            BoxState::Stopped => {
568                return Err(BoxError::StateError("VM is stopped".to_string()));
569            }
570        }
571        drop(state);
572
573        if self
574            .resolved_execution_plan
575            .as_ref()
576            .is_some_and(|plan| plan.backend.is_sandbox())
577            || self.config.isolation.is_sandbox()
578        {
579            return Err(BoxError::StateError(
580                "Pause is not supported by the Sandbox backend yet".to_string(),
581            ));
582        }
583
584        if let Some(pid) = self.pid().await {
585            // Safety: sending SIGSTOP to pause the process
586            let ret = unsafe { libc::kill(pid as i32, libc::SIGSTOP) };
587            if ret != 0 {
588                let err = std::io::Error::last_os_error();
589                return Err(BoxError::ExecError(format!(
590                    "Failed to send SIGSTOP to pid {}: {}",
591                    pid, err
592                )));
593            }
594            tracing::info!(box_id = %self.box_id, pid, "VM paused");
595            Ok(())
596        } else {
597            Err(BoxError::StateError(
598                "VM has no running process".to_string(),
599            ))
600        }
601    }
602
603    /// Resume the VM by sending SIGCONT to the shim process.
604    ///
605    /// Can be called on a paused VM to resume execution.
606    #[cfg(unix)]
607    pub async fn resume(&self) -> Result<()> {
608        if self
609            .resolved_execution_plan
610            .as_ref()
611            .is_some_and(|plan| plan.backend.is_sandbox())
612            || self.config.isolation.is_sandbox()
613        {
614            return Err(BoxError::StateError(
615                "Resume is not supported by the Sandbox backend yet".to_string(),
616            ));
617        }
618        if let Some(pid) = self.pid().await {
619            // Safety: sending SIGCONT to resume the process
620            let ret = unsafe { libc::kill(pid as i32, libc::SIGCONT) };
621            if ret != 0 {
622                let err = std::io::Error::last_os_error();
623                return Err(BoxError::ExecError(format!(
624                    "Failed to send SIGCONT to pid {}: {}",
625                    pid, err
626                )));
627            }
628            tracing::info!(box_id = %self.box_id, pid, "VM resumed");
629            Ok(())
630        } else {
631            Err(BoxError::StateError(
632                "VM has no running process".to_string(),
633            ))
634        }
635    }
636
637    /// Pause the VM (Windows stub - not yet implemented).
638    #[cfg(windows)]
639    pub async fn pause(&self) -> Result<()> {
640        Err(BoxError::StateError(
641            "VM pause is not yet supported on Windows".to_string(),
642        ))
643    }
644
645    /// Resume the VM (Windows stub - not yet implemented).
646    #[cfg(windows)]
647    pub async fn resume(&self) -> Result<()> {
648        Err(BoxError::StateError(
649            "VM resume is not yet supported on Windows".to_string(),
650        ))
651    }
652
653    /// Check if VM is healthy.
654    pub async fn health_check(&self) -> Result<bool> {
655        let state = self.state.read().await;
656
657        match *state {
658            BoxState::Ready | BoxState::Busy | BoxState::Compacting => {
659                // Check if handler reports VM is running
660                if let Some(ref handler) = *self.handler.read().await {
661                    Ok(handler.is_running())
662                } else {
663                    Ok(false)
664                }
665            }
666            _ => Ok(false),
667        }
668    }
669
670    /// Get VM metrics.
671    pub async fn metrics(&self) -> Option<crate::vmm::VmMetrics> {
672        let vm_metrics = self
673            .handler
674            .read()
675            .await
676            .as_ref()
677            .map(|handler| handler.metrics())?;
678
679        // Update per-VM Prometheus gauges if metrics are attached
680        if let Some(ref prom) = self.prom {
681            prom.vm_cpu_percent
682                .with_label_values(&[&self.box_id])
683                .set(vm_metrics.cpu_percent.unwrap_or(0.0) as f64);
684            prom.vm_memory_bytes
685                .with_label_values(&[&self.box_id])
686                .set(vm_metrics.memory_bytes.unwrap_or(0) as f64);
687        }
688
689        Some(vm_metrics)
690    }
691
692    /// Get the PID of the VM shim process.
693    pub async fn pid(&self) -> Option<u32> {
694        self.handler
695            .read()
696            .await
697            .as_ref()
698            .map(|handler| handler.pid())
699    }
700
701    /// Get the TEE extension, if TEE is configured and VM is booted.
702    #[cfg(unix)]
703    pub fn tee(&self) -> Option<&dyn TeeExtension> {
704        self.tee.as_deref()
705    }
706
707    /// Get the TEE extension or return an error.
708    #[cfg(unix)]
709    pub fn require_tee(&self) -> Result<&dyn TeeExtension> {
710        self.tee.as_deref().ok_or_else(|| {
711            BoxError::AttestationError("TEE is not configured for this box".to_string())
712        })
713    }
714
715    /// Apply a live resource update to the running backend.
716    ///
717    /// Tier 1 changes (provisioned vCPU count and memory size) retain the public
718    /// stop/recreate contract across backends.
719    ///
720    /// Tier 2 changes use one backend-owned path: the exact-generation A3S OCI
721    /// update for a host Sandbox, or guest cgroup writes for a MicroVM.
722    #[cfg(unix)]
723    pub async fn update_resources(
724        &mut self,
725        update: &crate::resize::ResourceUpdate,
726    ) -> Result<crate::resize::ResizeResult> {
727        crate::resize::validate_update(update)?;
728
729        let mut result = crate::resize::ResizeResult {
730            applied: Vec::new(),
731            rejected: Vec::new(),
732        };
733
734        if !update.has_tier2_changes() {
735            return Ok(result);
736        }
737
738        let sandbox = self
739            .resolved_execution_plan
740            .as_ref()
741            .is_some_and(|plan| plan.backend.is_sandbox())
742            || self.config.isolation.is_sandbox();
743        if sandbox {
744            let mut next_config = self.config.clone();
745            update.apply_to_config(&mut next_config);
746            let runtime_config = next_config.clone();
747            let box_dir = self.home_dir.join("boxes").join(&self.box_id);
748            let box_id = self.box_id.clone();
749            tokio::task::spawn_blocking(move || {
750                crate::sandbox::update_recorded_resources(&box_dir, &box_id, &runtime_config)
751            })
752            .await
753            .map_err(|error| {
754                BoxError::StateError(format!(
755                    "A3S OCI resource update worker failed for {}: {error}",
756                    self.box_id
757                ))
758            })??;
759            self.config = next_config;
760            result.applied = update
761                .tier2_change_names()
762                .into_iter()
763                .map(str::to_string)
764                .collect();
765            return Ok(result);
766        }
767
768        // Build cgroup commands and execute them inside the guest
769        let commands = update.build_microvm_cgroup_commands();
770        for cmd_str in &commands {
771            let shell_cmd = vec!["sh".to_string(), "-c".to_string(), cmd_str.clone()];
772
773            match self.exec_command(shell_cmd, 5_000_000_000).await {
774                Ok(output) if output.exit_code == 0 => {
775                    result.applied.push(cmd_str.clone());
776                }
777                Ok(output) => {
778                    let stderr = String::from_utf8_lossy(&output.stderr);
779                    let reason = if stderr.trim().is_empty() {
780                        format!("exit code {}", output.exit_code)
781                    } else {
782                        stderr.trim().to_string()
783                    };
784                    tracing::warn!(
785                        box_id = %self.box_id,
786                        cmd = %cmd_str,
787                        exit_code = output.exit_code,
788                        stderr = %stderr,
789                        "Cgroup update failed inside guest"
790                    );
791                    result.rejected.push((cmd_str.clone(), reason));
792                }
793                Err(e) => {
794                    tracing::warn!(
795                        box_id = %self.box_id,
796                        cmd = %cmd_str,
797                        error = %e,
798                        "Failed to exec cgroup update in guest"
799                    );
800                    result.rejected.push((cmd_str.clone(), e.to_string()));
801                }
802            }
803        }
804
805        Ok(result)
806    }
807}