1mod layout;
4mod network;
5mod ready;
6pub mod reap;
7mod sandbox;
8mod spec;
9
10pub(crate) use layout::runtime_socket_dir;
11
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15pub type PullProgressFn = Arc<dyn Fn(usize, usize, &str, i64) + Send + Sync>;
17
18use a3s_box_core::config::BoxConfig;
19#[cfg(unix)]
20use a3s_box_core::config::TeeConfig;
21use a3s_box_core::error::{BoxError, Result};
22use a3s_box_core::event::{BoxEvent, EventEmitter};
23use a3s_box_core::execution::{ExecutionBackend, ResolvedExecutionPlan};
24use serde::{Deserialize, Serialize};
25use tokio::sync::RwLock;
26use tracing::Instrument;
27
28#[cfg(unix)]
29use libc;
30
31#[cfg(unix)]
32use crate::grpc::ExecClient;
33#[cfg(unix)]
34use crate::tee::TeeExtension;
35use crate::vmm::{VmController, VmHandler, VmmProvider, DEFAULT_SHUTDOWN_TIMEOUT_MS};
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39pub enum BoxState {
40 Created,
42
43 Ready,
45
46 Busy,
48
49 Compacting,
51
52 Stopped,
54}
55
56pub(crate) struct BoxLayout {
58 pub(crate) rootfs_path: PathBuf,
60 pub(crate) exec_socket_path: PathBuf,
62 pub(crate) pty_socket_path: PathBuf,
64 pub(crate) attest_socket_path: PathBuf,
66 pub(crate) port_forward_socket_path: PathBuf,
68 pub(crate) workspace_path: PathBuf,
70 pub(crate) console_output: Option<PathBuf>,
72 pub(crate) oci_config: Option<crate::oci::OciImageConfig>,
74 pub(crate) tee_instance_config: Option<crate::vmm::TeeInstanceConfig>,
76}
77
78pub struct VmManager {
80 pub(crate) config: BoxConfig,
82
83 pub(crate) box_id: String,
85
86 pub(crate) state: Arc<RwLock<BoxState>>,
88
89 pub(crate) event_emitter: EventEmitter,
91
92 pub(crate) provider: Option<Box<dyn VmmProvider>>,
94
95 pub(crate) handler: Arc<RwLock<Option<Box<dyn VmHandler>>>>,
97
98 #[cfg(unix)]
100 pub(crate) exec_client: Option<ExecClient>,
101
102 pub(crate) net_manager: Option<Box<dyn crate::network::NetworkBackend>>,
105
106 pub(crate) home_dir: PathBuf,
108
109 pub(crate) anonymous_volumes: Vec<String>,
111
112 pub(crate) created_anonymous_volumes: Vec<String>,
117
118 pub(crate) image_config: Option<crate::oci::OciImageConfig>,
120
121 #[cfg(unix)]
123 pub(crate) tee: Option<Box<dyn TeeExtension>>,
124
125 pub(crate) rootfs_provider: Box<dyn crate::rootfs::RootfsProvider>,
127
128 pub(crate) exec_socket_path: Option<PathBuf>,
130
131 pub(crate) pty_socket_path: Option<PathBuf>,
133
134 pub(crate) port_forward_socket_path: Option<PathBuf>,
136
137 pub(crate) prom: Option<crate::prom::RuntimeMetrics>,
139
140 pub(crate) shim_exit_code: Option<i32>,
142
143 pub(crate) pull_progress_fn: Option<PullProgressFn>,
145
146 pub(crate) log_config: a3s_box_core::log::LogConfig,
150
151 pub(crate) resolved_execution_plan: Option<ResolvedExecutionPlan>,
153}
154
155impl VmManager {
156 pub fn new(config: BoxConfig, event_emitter: EventEmitter) -> Self {
158 let box_id = uuid::Uuid::new_v4().to_string();
159 let home_dir = a3s_box_core::dirs_home();
160
161 Self {
162 config,
163 box_id,
164 state: Arc::new(RwLock::new(BoxState::Created)),
165 event_emitter,
166 provider: None,
167 handler: Arc::new(RwLock::new(None)),
168 #[cfg(unix)]
169 exec_client: None,
170 net_manager: None,
171 home_dir,
172 anonymous_volumes: Vec::new(),
173 created_anonymous_volumes: Vec::new(),
174 image_config: None,
175 #[cfg(unix)]
176 tee: None,
177 rootfs_provider: crate::rootfs::default_provider(),
178 exec_socket_path: None,
179 pty_socket_path: None,
180 port_forward_socket_path: None,
181 prom: None,
182 shim_exit_code: None,
183 pull_progress_fn: None,
184 log_config: a3s_box_core::log::LogConfig::default(),
185 resolved_execution_plan: None,
186 }
187 }
188
189 pub fn with_box_id(config: BoxConfig, event_emitter: EventEmitter, box_id: String) -> Self {
191 let home_dir = a3s_box_core::dirs_home();
192
193 Self {
194 config,
195 box_id,
196 state: Arc::new(RwLock::new(BoxState::Created)),
197 event_emitter,
198 provider: None,
199 handler: Arc::new(RwLock::new(None)),
200 #[cfg(unix)]
201 exec_client: None,
202 net_manager: None,
203 home_dir,
204 anonymous_volumes: Vec::new(),
205 created_anonymous_volumes: Vec::new(),
206 image_config: None,
207 #[cfg(unix)]
208 tee: None,
209 rootfs_provider: crate::rootfs::default_provider(),
210 exec_socket_path: None,
211 pty_socket_path: None,
212 port_forward_socket_path: None,
213 prom: None,
214 shim_exit_code: None,
215 pull_progress_fn: None,
216 log_config: a3s_box_core::log::LogConfig::default(),
217 resolved_execution_plan: None,
218 }
219 }
220
221 async fn cleanup_boot_failure(&mut self) {
223 if let Some(mut handler) = self.handler.write().await.take() {
224 if let Err(error) = handler.stop(default_stop_signal(), DEFAULT_SHUTDOWN_TIMEOUT_MS) {
225 tracing::warn!(
226 box_id = %self.box_id,
227 error = %error,
228 "Failed to stop VM handler after boot failure"
229 );
230 }
231 self.shim_exit_code = handler.exit_code();
232 }
233
234 if let Some(mut net_manager) = self.net_manager.take() {
235 net_manager.stop();
236 }
237
238 self.cleanup_created_anonymous_volumes();
239 self.cleanup_box_dir();
240 }
241
242 fn cleanup_created_anonymous_volumes(&mut self) {
243 if self.created_anonymous_volumes.is_empty() {
244 return;
245 }
246
247 let created = std::mem::take(&mut self.created_anonymous_volumes);
248 let created_set: std::collections::HashSet<_> = created.iter().cloned().collect();
249 let store = crate::volume::VolumeStore::new(
250 self.home_dir.join("volumes.json"),
251 self.home_dir.join("volumes"),
252 );
253
254 for volume_name in &created {
255 if let Err(error) = store.remove(volume_name, true) {
256 tracing::debug!(
257 box_id = %self.box_id,
258 volume = volume_name,
259 error = %error,
260 "Failed to remove anonymous volume after boot failure"
261 );
262 }
263 }
264
265 self.anonymous_volumes
266 .retain(|name| !created_set.contains(name));
267 }
268
269 fn cleanup_box_dir(&self) {
271 let box_dir = self.home_dir.join("boxes").join(&self.box_id);
272
273 #[cfg(target_os = "linux")]
280 crate::network::terminate_passt(&self.socket_dir());
281
282 if let Err(error) = self.rootfs_provider.cleanup(&box_dir, false) {
283 tracing::warn!(
284 box_id = %self.box_id,
285 path = %box_dir.display(),
286 error = %error,
287 "Failed to cleanup rootfs provider after boot failure"
288 );
289 }
290
291 match std::fs::remove_dir_all(&box_dir) {
292 Ok(()) => {}
293 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
294 Err(error) => {
295 tracing::warn!(
296 box_id = %self.box_id,
297 path = %box_dir.display(),
298 error = %error,
299 "Failed to cleanup box directory after boot failure"
300 );
301 }
302 }
303
304 let socket_dir = self.socket_dir();
305 if socket_dir != box_dir.join("sockets") {
306 match std::fs::remove_dir_all(&socket_dir) {
307 Ok(()) => {}
308 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
309 Err(error) => {
310 tracing::debug!(
311 box_id = %self.box_id,
312 path = %socket_dir.display(),
313 error = %error,
314 "Failed to cleanup socket directory after boot failure"
315 );
316 }
317 }
318 }
319 }
320
321 pub fn with_provider(
323 config: BoxConfig,
324 event_emitter: EventEmitter,
325 provider: Box<dyn VmmProvider>,
326 ) -> Self {
327 let box_id = uuid::Uuid::new_v4().to_string();
328 let home_dir = a3s_box_core::dirs_home();
329 Self {
330 config,
331 box_id,
332 state: Arc::new(RwLock::new(BoxState::Created)),
333 event_emitter,
334 provider: Some(provider),
335 handler: Arc::new(RwLock::new(None)),
336 #[cfg(unix)]
337 exec_client: None,
338 net_manager: None,
339 home_dir,
340 anonymous_volumes: Vec::new(),
341 created_anonymous_volumes: Vec::new(),
342 image_config: None,
343 #[cfg(unix)]
344 tee: None,
345 rootfs_provider: crate::rootfs::default_provider(),
346 exec_socket_path: None,
347 pty_socket_path: None,
348 port_forward_socket_path: None,
349 prom: None,
350 shim_exit_code: None,
351 pull_progress_fn: None,
352 log_config: a3s_box_core::log::LogConfig::default(),
353 resolved_execution_plan: None,
354 }
355 }
356
357 pub fn box_id(&self) -> &str {
359 &self.box_id
360 }
361
362 pub async fn state(&self) -> BoxState {
364 *self.state.read().await
365 }
366
367 #[cfg(unix)]
369 pub fn exec_client(&self) -> Option<&ExecClient> {
370 self.exec_client.as_ref()
371 }
372
373 #[cfg(unix)]
374 async fn connect_exec_client_for_request(socket_path: &Path) -> Result<ExecClient> {
375 const ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
376
377 let client = ExecClient::connect(socket_path).await?;
378 match tokio::time::timeout(ATTEMPT_TIMEOUT, client.heartbeat()).await {
379 Ok(Ok(true)) => Ok(client),
380 Ok(Ok(false)) => Err(BoxError::ExecError(format!(
381 "Exec client not connected: heartbeat failed at {}",
382 socket_path.display()
383 ))),
384 Ok(Err(error)) => Err(error),
385 Err(_) => Err(BoxError::ExecError(format!(
386 "Exec client not connected: heartbeat timed out at {}",
387 socket_path.display()
388 ))),
389 }
390 }
391
392 #[cfg(unix)]
398 pub async fn wait_for_exec_available(&mut self, timeout: std::time::Duration) -> Result<()> {
399 let socket_path = self
400 .exec_socket_path
401 .clone()
402 .ok_or_else(|| BoxError::ExecError("Exec socket path is unavailable".to_string()))?;
403 let deadline = tokio::time::Instant::now() + timeout;
404 loop {
405 match Self::connect_exec_client_for_request(&socket_path).await {
406 Ok(client) => {
407 self.exec_client = Some(client);
408 return Ok(());
409 }
410 Err(error) if tokio::time::Instant::now() < deadline => {
411 tracing::debug!(%error, "Waiting for pooled VM exec readiness");
412 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
413 }
414 Err(error) => return Err(error),
415 }
416 }
417 }
418
419 #[cfg(not(unix))]
420 pub async fn wait_for_exec_available(&mut self, _timeout: std::time::Duration) -> Result<()> {
421 Ok(())
422 }
423
424 #[cfg(unix)]
430 pub async fn attach_running_process(
431 &mut self,
432 pid: u32,
433 exec_socket_path: PathBuf,
434 pty_socket_path: Option<PathBuf>,
435 ) -> Result<()> {
436 let port_forward_socket_path = exec_socket_path.with_file_name("portfwd.sock");
437 let handler = crate::vmm::ShimHandler::from_pid(pid, self.box_id.clone());
438 if !handler.is_running() {
439 return Err(BoxError::StateError(format!(
440 "Cannot attach to non-running VM process {pid}"
441 )));
442 }
443
444 self.exec_client = match ExecClient::connect(&exec_socket_path).await {
445 Ok(client) => Some(client),
446 Err(error) => {
447 tracing::debug!(
448 box_id = %self.box_id,
449 socket_path = %exec_socket_path.display(),
450 error = %error,
451 "Failed to reconnect exec client while attaching to running VM"
452 );
453 None
454 }
455 };
456 self.exec_socket_path = Some(exec_socket_path);
457 self.pty_socket_path = pty_socket_path;
458 self.port_forward_socket_path = Some(port_forward_socket_path);
459 *self.handler.write().await = Some(Box::new(handler));
460 *self.state.write().await = BoxState::Ready;
461 Ok(())
462 }
463
464 #[cfg(windows)]
466 pub async fn attach_running_process(
467 &mut self,
468 pid: u32,
469 exec_socket_path: PathBuf,
470 pty_socket_path: Option<PathBuf>,
471 ) -> Result<()> {
472 let handler = crate::vmm::ShimHandler::from_pid(pid, self.box_id.clone());
473 if !handler.is_running() {
474 return Err(BoxError::StateError(format!(
475 "Cannot attach to non-running VM process {pid}"
476 )));
477 }
478
479 self.exec_socket_path = Some(exec_socket_path);
480 self.pty_socket_path = pty_socket_path;
481 self.port_forward_socket_path = None;
482 *self.handler.write().await = Some(Box::new(handler));
483 *self.state.write().await = BoxState::Ready;
484 Ok(())
485 }
486
487 pub fn exec_socket_path(&self) -> Option<&Path> {
489 self.exec_socket_path.as_deref()
490 }
491
492 pub fn pty_socket_path(&self) -> Option<&Path> {
494 self.pty_socket_path.as_deref()
495 }
496
497 pub fn port_forward_socket_path(&self) -> Option<&Path> {
499 self.port_forward_socket_path.as_deref()
500 }
501
502 pub fn set_provider(&mut self, provider: Box<dyn VmmProvider>) {
507 self.provider = Some(provider);
508 }
509
510 pub fn set_rootfs_provider(&mut self, provider: Box<dyn crate::rootfs::RootfsProvider>) {
515 self.rootfs_provider = provider;
516 }
517
518 pub fn rootfs_provider_name(&self) -> &str {
520 self.rootfs_provider.name()
521 }
522
523 pub fn set_pull_progress_fn(&mut self, f: PullProgressFn) {
526 self.pull_progress_fn = Some(f);
527 }
528
529 pub fn set_metrics(&mut self, metrics: crate::prom::RuntimeMetrics) {
531 self.prom = Some(metrics);
532 }
533
534 pub fn set_log_config(&mut self, log_config: a3s_box_core::log::LogConfig) {
537 self.log_config = log_config;
538 }
539
540 pub fn metrics_prom(&self) -> Option<&crate::prom::RuntimeMetrics> {
542 self.prom.as_ref()
543 }
544
545 pub fn anonymous_volumes(&self) -> &[String] {
550 &self.anonymous_volumes
551 }
552
553 pub fn image_config(&self) -> Option<&crate::oci::OciImageConfig> {
555 self.image_config.as_ref()
556 }
557
558 pub fn resolved_execution_plan(&self) -> Option<&ResolvedExecutionPlan> {
560 self.resolved_execution_plan.as_ref()
561 }
562
563 pub fn exit_code(&self) -> Option<i32> {
569 self.shim_exit_code
570 }
571
572 fn persisted_exit_code(&self) -> Option<i32> {
573 crate::rootfs::read_persisted_exit_code(&self.home_dir.join("boxes").join(&self.box_id))
574 }
575
576 pub async fn try_wait_exit(&mut self) -> Result<Option<i32>> {
582 if let Some(code) = self.persisted_exit_code() {
583 self.shim_exit_code = Some(code);
584 return Ok(Some(code));
585 }
586
587 let mut handler = self.handler.write().await;
588 let Some(handler) = handler.as_mut() else {
589 return Ok(self.shim_exit_code);
590 };
591
592 if let Some(code) = handler.try_wait_exit()? {
593 self.shim_exit_code = Some(code);
594 return Ok(Some(code));
595 }
596
597 Ok(None)
598 }
599
600 pub async fn has_exited(&self) -> bool {
603 if self.shim_exit_code.is_some() || self.persisted_exit_code().is_some() {
604 return true;
605 }
606
607 self.handler
608 .read()
609 .await
610 .as_ref()
611 .map(|handler| handler.has_exited())
612 .unwrap_or(false)
613 }
614
615 #[cfg(unix)]
623 pub async fn run_deferred_main(
624 &mut self,
625 spec_json: &[u8],
626 timeout: std::time::Duration,
627 ) -> Result<a3s_box_core::exec::ExecOutput> {
628 let log_dir = self.home_dir.join("boxes").join(&self.box_id).join("logs");
629 let console_out_path = log_dir.join("console.log");
630 let console_err_path = a3s_box_core::log::stderr_console_path(&console_out_path);
631 let console_out_start = std::fs::metadata(&console_out_path)
632 .map(|metadata| metadata.len())
633 .unwrap_or(0);
634 let console_err_start = std::fs::metadata(&console_err_path)
635 .map(|metadata| metadata.len())
636 .unwrap_or(0);
637
638 let acked = {
639 let owned_client;
640 let client = if let Some(client) = self.exec_client.as_ref() {
641 client
642 } else {
643 let socket_path = self
644 .exec_socket_path
645 .as_deref()
646 .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?;
647 owned_client = Self::connect_exec_client_for_request(socket_path).await?;
648 &owned_client
649 };
650 client.spawn_main(Some(spec_json)).await?
651 };
652 let exit_wait_timeout = if acked {
653 timeout
654 } else {
655 tracing::debug!(
661 box_id = %self.box_id,
662 "spawn-main was not acknowledged; waiting briefly for main exit"
663 );
664 timeout.min(std::time::Duration::from_secs(2))
665 };
666
667 let start = std::time::Instant::now();
669 let exit_code = loop {
670 if let Some(code) = self.try_wait_exit().await? {
671 break code;
672 }
673 if start.elapsed() >= exit_wait_timeout {
674 let message = if acked {
675 "deferred main did not exit within the timeout"
676 } else {
677 "spawn-main was not acknowledged by the guest"
678 };
679 return Err(BoxError::ExecError(message.to_string()));
680 }
681 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
682 };
683
684 let json_path = log_dir.join("container.json");
691 let drain_start = std::time::Instant::now();
692 let max_wait = std::time::Duration::from_secs(2);
693 let min_wait = std::time::Duration::from_millis(500);
694 let quiet_window = std::time::Duration::from_millis(200);
695 let mut last_len: Option<u64> = None;
696 let mut last_change = drain_start;
697 loop {
698 let len = std::fs::metadata(&json_path).map(|m| m.len()).unwrap_or(0);
699 if last_len != Some(len) {
700 last_len = Some(len);
701 last_change = std::time::Instant::now();
702 }
703 let elapsed = drain_start.elapsed();
704 if elapsed >= max_wait || (elapsed >= min_wait && last_change.elapsed() >= quiet_window)
705 {
706 break;
707 }
708 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
709 }
710 let (mut stdout, mut stderr) = self.read_container_logs();
711 if stdout.is_empty() {
712 stdout = Self::read_file_from_offset(&console_out_path, console_out_start);
713 }
714 if stderr.is_empty() {
715 stderr = Self::read_file_from_offset(&console_err_path, console_err_start);
716 }
717 Ok(a3s_box_core::exec::ExecOutput {
718 stdout,
719 stderr,
720 exit_code,
721 })
722 }
723
724 fn read_file_from_offset(path: &Path, offset: u64) -> Vec<u8> {
725 use std::io::{Read, Seek, SeekFrom};
726
727 let mut file = match std::fs::File::open(path) {
728 Ok(file) => file,
729 Err(_) => return vec![],
730 };
731 if file.seek(SeekFrom::Start(offset)).is_err() {
732 return vec![];
733 }
734
735 let mut bytes = Vec::new();
736 if file.read_to_end(&mut bytes).is_err() {
737 return vec![];
738 }
739 bytes
740 }
741
742 fn read_container_logs(&self) -> (Vec<u8>, Vec<u8>) {
744 let path = self
745 .home_dir
746 .join("boxes")
747 .join(&self.box_id)
748 .join("logs")
749 .join("container.json");
750 let (mut out, mut err) = (Vec::new(), Vec::new());
751 if let Ok(content) = std::fs::read_to_string(&path) {
752 for line in content.lines() {
753 if let Ok(entry) = serde_json::from_str::<a3s_box_core::log::LogEntry>(line) {
754 if entry.stream == "stderr" {
755 err.extend_from_slice(entry.log.as_bytes());
756 } else {
757 out.extend_from_slice(entry.log.as_bytes());
758 }
759 }
760 }
761 }
762 (out, err)
763 }
764
765 #[cfg(unix)]
769 #[tracing::instrument(skip(self, request), fields(box_id = %self.box_id))]
770 pub async fn exec_request(
771 &self,
772 request: &a3s_box_core::exec::ExecRequest,
773 ) -> Result<a3s_box_core::exec::ExecOutput> {
774 if request.cmd.is_empty() {
775 return Err(BoxError::ExecError(
776 "Exec request requires a non-empty command".to_string(),
777 ));
778 }
779
780 let state = self.state.read().await;
781 match *state {
782 BoxState::Ready | BoxState::Busy | BoxState::Compacting => {}
783 BoxState::Created => {
784 return Err(BoxError::ExecError("VM not yet booted".to_string()));
785 }
786 BoxState::Stopped => {
787 return Err(BoxError::ExecError("VM is stopped".to_string()));
788 }
789 }
790 drop(state);
791
792 let owned_client;
793 let client = if let Some(client) = self.exec_client.as_ref() {
794 client
795 } else {
796 let socket_path = self
797 .exec_socket_path
798 .as_deref()
799 .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?;
800 owned_client = Self::connect_exec_client_for_request(socket_path).await?;
801 &owned_client
802 };
803
804 let exec_start = std::time::Instant::now();
805 let result = client.exec_command(request).await;
806
807 if let Some(ref prom) = self.prom {
809 prom.exec_total.inc();
810 prom.exec_duration
811 .observe(exec_start.elapsed().as_secs_f64());
812 if result.is_err() || result.as_ref().is_ok_and(|o| o.exit_code != 0) {
813 prom.exec_errors_total.inc();
814 }
815 }
816
817 result
818 }
819
820 #[cfg(unix)]
824 #[tracing::instrument(skip(self, cmd), fields(box_id = %self.box_id))]
825 pub async fn exec_command(
826 &self,
827 cmd: Vec<String>,
828 timeout_ns: u64,
829 ) -> Result<a3s_box_core::exec::ExecOutput> {
830 let request = a3s_box_core::exec::ExecRequest {
831 cmd,
832 timeout_ns,
833 env: vec![],
834 working_dir: None,
835 rootfs: None,
836 stdin: None,
837 stdin_streaming: false,
838 user: None,
839 streaming: false,
840 };
841
842 self.exec_request(&request).await
843 }
844
845 pub async fn boot(&mut self) -> Result<()> {
847 let boot_span = tracing::info_span!("vm_boot", box_id = %self.box_id);
848 {
850 let state = self.state.read().await;
851 if *state != BoxState::Created {
852 return Err(BoxError::StateError("VM already booted".to_string()));
853 }
854 }
855
856 let execution_plan = a3s_box_core::resolve_execution(&self.config)?;
857 self.resolved_execution_plan = Some(execution_plan.clone());
858 if execution_plan.backend == ExecutionBackend::Crun {
859 let boot_start = std::time::Instant::now();
860 return self
861 .boot_sandbox(execution_plan, &boot_span, boot_start)
862 .await;
863 }
864
865 let boot_start = std::time::Instant::now();
866
867 tracing::info!(parent: &boot_span, box_id = %self.box_id, "Booting VM");
868
869 let layout = match self
871 .prepare_layout()
872 .instrument(tracing::info_span!(parent: &boot_span, "prepare_layout"))
873 .await
874 {
875 Ok(layout) => layout,
876 Err(error) => {
877 self.cleanup_boot_failure().await;
878 return Err(error);
879 }
880 };
881 self.image_config = layout.oci_config.clone();
882
883 let resolv_content = a3s_box_core::dns::generate_resolv_conf(&self.config.dns);
885 let resolv_path = layout.rootfs_path.join("etc/resolv.conf");
886 if let Err(e) = tokio::fs::write(&resolv_path, &resolv_content).await {
887 self.cleanup_boot_failure().await;
888 return Err(BoxError::IoError(e));
889 }
890 tracing::debug!(parent: &boot_span, dns = %resolv_content.trim(), "Configured guest DNS");
891
892 if let Err(e) = self.write_hostname_file(&layout) {
894 self.cleanup_boot_failure().await;
895 return Err(e);
896 }
897 if let Err(e) = self.write_standalone_hosts_file(&layout) {
898 self.cleanup_boot_failure().await;
899 return Err(e);
900 }
901
902 let mut spec = match self.build_instance_spec(&layout) {
904 Ok(s) => s,
905 Err(e) => {
906 self.cleanup_boot_failure().await;
907 return Err(e);
908 }
909 };
910
911 let bridge_network = match &self.config.network {
913 a3s_box_core::NetworkMode::Bridge { network } => Some(network.clone()),
914 _ => None,
915 };
916 if let Some(network_name) = bridge_network {
917 let net_config = match self.setup_bridge_network(&network_name) {
918 Ok(n) => n,
919 Err(e) => {
920 self.cleanup_boot_failure().await;
921 return Err(e);
922 }
923 };
924
925 match self.write_hosts_file(&layout, &network_name) {
927 Ok(()) => (),
928 Err(e) => {
929 self.cleanup_boot_failure().await;
930 return Err(e);
931 }
932 };
933
934 let ip_cidr = format!("{}/{}", net_config.ip_address, net_config.prefix_len);
937 spec.entrypoint
938 .env
939 .push(("A3S_NET_IP".to_string(), ip_cidr));
940 spec.entrypoint.env.push((
941 "A3S_NET_GATEWAY".to_string(),
942 net_config.gateway.to_string(),
943 ));
944 spec.entrypoint.env.push((
945 "A3S_NET_DNS".to_string(),
946 net_config
947 .dns_servers
948 .iter()
949 .map(|s| s.to_string())
950 .collect::<Vec<_>>()
951 .join(","),
952 ));
953
954 spec.network = Some(net_config);
955 }
956
957 #[cfg(target_os = "macos")]
958 if spec.network.is_none()
959 && matches!(self.config.network, a3s_box_core::NetworkMode::Tsi)
960 && !self.config.port_map.is_empty()
961 {
962 let net_config = match self.setup_published_default_network() {
963 Ok(network) => network,
964 Err(error) => {
965 self.cleanup_boot_failure().await;
966 return Err(error);
967 }
968 };
969 let ip_cidr = format!("{}/{}", net_config.ip_address, net_config.prefix_len);
970 spec.entrypoint
971 .env
972 .push(("A3S_NET_IP".to_string(), ip_cidr));
973 spec.entrypoint.env.push((
974 "A3S_NET_GATEWAY".to_string(),
975 net_config.gateway.to_string(),
976 ));
977 spec.entrypoint.env.push((
978 "A3S_NET_DNS".to_string(),
979 net_config
980 .dns_servers
981 .iter()
982 .map(ToString::to_string)
983 .collect::<Vec<_>>()
984 .join(","),
985 ));
986 spec.network = Some(net_config);
987 }
988
989 if self.provider.is_none() {
991 let shim_path = match VmController::find_shim() {
992 Ok(p) => p,
993 Err(e) => {
994 self.cleanup_boot_failure().await;
995 return Err(e);
996 }
997 };
998 let controller = match VmController::new(shim_path) {
999 Ok(c) => c,
1000 Err(e) => {
1001 self.cleanup_boot_failure().await;
1002 return Err(e);
1003 }
1004 };
1005 self.provider = Some(Box::new(controller));
1006 }
1007
1008 let handler = {
1010 let provider = self
1011 .provider
1012 .as_ref()
1013 .ok_or_else(|| BoxError::BoxBootError {
1014 message: "VMM provider not initialized".to_string(),
1015 hint: Some("Ensure VmManager has a provider set before boot".to_string()),
1016 })?;
1017 let vm_start_span = tracing::info_span!(parent: &boot_span, "vm_start");
1018 match async { provider.start(&spec).await }
1019 .instrument(vm_start_span)
1020 .await
1021 {
1022 Ok(h) => h,
1023 Err(e) => {
1024 self.cleanup_boot_failure().await;
1025 return Err(e);
1026 }
1027 }
1028 };
1029
1030 *self.handler.write().await = Some(handler);
1032
1033 {
1035 let wait_span = tracing::info_span!(parent: &boot_span, "wait_for_ready");
1036 if let Err(e) = async {
1037 self.wait_for_vm_running().await?;
1038
1039 #[cfg(unix)]
1044 if is_restore_mode(&self.config) {
1045 self.probe_exec_ready_once(&layout.exec_socket_path).await;
1046 } else {
1047 self.wait_for_exec_ready(&layout.exec_socket_path).await?;
1048 }
1049 Ok::<(), BoxError>(())
1050 }
1051 .instrument(wait_span)
1052 .await
1053 {
1054 self.cleanup_boot_failure().await;
1055 return Err(e);
1056 }
1057 }
1058
1059 #[cfg(unix)]
1070 if !is_restore_mode(&self.config)
1071 && std::env::var("BOX_DEFERRED_MAIN")
1072 .map(|v| v == "1")
1073 .unwrap_or(false)
1074 {
1075 if let Some(client) = self.exec_client.as_ref() {
1076 match client.spawn_main(None).await {
1077 Ok(true) => tracing::info!("deferred container main spawned"),
1078 Ok(false) => tracing::warn!("deferred spawn-main not acknowledged"),
1079 Err(e) => tracing::warn!(error = %e, "deferred spawn-main failed"),
1080 }
1081 }
1082 }
1083
1084 self.exec_socket_path = Some(layout.exec_socket_path.clone());
1086 self.pty_socket_path = Some(layout.pty_socket_path.clone());
1087 self.port_forward_socket_path = Some(layout.port_forward_socket_path.clone());
1088
1089 #[cfg(unix)]
1091 if !matches!(self.config.tee, TeeConfig::None) {
1092 self.tee = Some(Box::new(crate::tee::SnpTeeExtension::new(
1093 self.box_id.clone(),
1094 layout.attest_socket_path.clone(),
1095 )));
1096 }
1097
1098 *self.state.write().await = BoxState::Ready;
1100
1101 if let Some(ref prom) = self.prom {
1103 let boot_duration = boot_start.elapsed().as_secs_f64();
1104 prom.vm_boot_duration.observe(boot_duration);
1105 prom.vm_created_total.inc();
1106 prom.vm_count.with_label_values(&["ready"]).inc();
1107 }
1108
1109 self.event_emitter.emit(BoxEvent::empty("box.ready"));
1111
1112 tracing::info!(parent: &boot_span, box_id = %self.box_id, "VM ready");
1113
1114 Ok(())
1115 }
1116
1117 pub async fn destroy(&mut self) -> Result<()> {
1119 self.destroy_with_options(default_stop_signal(), DEFAULT_SHUTDOWN_TIMEOUT_MS)
1120 .await
1121 }
1122
1123 pub async fn destroy_with_timeout(&mut self, timeout_ms: u64) -> Result<()> {
1125 self.destroy_with_options(libc::SIGTERM, timeout_ms).await
1126 }
1127
1128 #[tracing::instrument(skip(self), fields(box_id = %self.box_id))]
1133 pub async fn destroy_with_options(&mut self, signal: i32, timeout_ms: u64) -> Result<()> {
1134 let mut state = self.state.write().await;
1135
1136 if *state == BoxState::Stopped {
1137 return Ok(());
1138 }
1139
1140 tracing::info!(box_id = %self.box_id, signal, timeout_ms, "Destroying VM");
1141
1142 *state = BoxState::Stopped;
1144
1145 let mut stop_error = None;
1151 if let Some(mut handler) = self.handler.write().await.take() {
1152 if let Err(e) = handler.stop(signal, timeout_ms) {
1153 tracing::error!(box_id = %self.box_id, error = %e, "Failed to stop VM handler; continuing teardown");
1154 stop_error = Some(e);
1155 }
1156 self.shim_exit_code = handler.exit_code();
1157 }
1158
1159 if let Some(ref mut net) = self.net_manager {
1161 net.stop();
1162 }
1163 self.net_manager = None;
1164
1165 let box_dir = self.home_dir.join("boxes").join(&self.box_id);
1167 if let Err(e) = self
1168 .rootfs_provider
1169 .cleanup(&box_dir, self.config.persistent)
1170 {
1171 tracing::warn!(
1172 box_id = %self.box_id,
1173 error = %e,
1174 "Failed to cleanup rootfs provider"
1175 );
1176 }
1177
1178 let socket_dir = self.socket_dir();
1179 if let Err(e) = std::fs::remove_dir_all(&socket_dir) {
1180 tracing::debug!(
1181 box_id = %self.box_id,
1182 path = %socket_dir.display(),
1183 error = %e,
1184 "Failed to cleanup VM socket directory"
1185 );
1186 }
1187
1188 if !self.config.persistent {
1195 match std::fs::remove_dir_all(&box_dir) {
1196 Ok(()) => {}
1197 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1198 Err(e) => {
1199 tracing::warn!(
1200 box_id = %self.box_id,
1201 path = %box_dir.display(),
1202 error = %e,
1203 "Failed to remove box directory on destroy"
1204 );
1205 }
1206 }
1207 }
1208
1209 if let Some(ref prom) = self.prom {
1211 prom.vm_destroyed_total.inc();
1212 prom.vm_count.with_label_values(&["ready"]).dec();
1213 }
1214
1215 self.event_emitter.emit(BoxEvent::empty("box.stopped"));
1217
1218 match stop_error {
1221 Some(e) => Err(e),
1222 None => Ok(()),
1223 }
1224 }
1225
1226 pub async fn set_busy(&self) -> Result<()> {
1228 let mut state = self.state.write().await;
1229
1230 if *state != BoxState::Ready {
1231 return Err(BoxError::StateError("VM not ready".to_string()));
1232 }
1233
1234 *state = BoxState::Busy;
1235 Ok(())
1236 }
1237
1238 pub async fn set_ready(&self) -> Result<()> {
1240 let mut state = self.state.write().await;
1241
1242 if *state != BoxState::Busy && *state != BoxState::Compacting {
1243 return Err(BoxError::StateError("Invalid state transition".to_string()));
1244 }
1245
1246 *state = BoxState::Ready;
1247 Ok(())
1248 }
1249
1250 pub async fn set_compacting(&self) -> Result<()> {
1252 let mut state = self.state.write().await;
1253
1254 if *state != BoxState::Busy {
1255 return Err(BoxError::StateError("VM not busy".to_string()));
1256 }
1257
1258 *state = BoxState::Compacting;
1259 Ok(())
1260 }
1261
1262 #[cfg(unix)]
1266 pub async fn pause(&self) -> Result<()> {
1267 let state = self.state.read().await;
1268 match *state {
1269 BoxState::Ready | BoxState::Busy | BoxState::Compacting => {}
1270 BoxState::Created => {
1271 return Err(BoxError::StateError("VM not yet booted".to_string()));
1272 }
1273 BoxState::Stopped => {
1274 return Err(BoxError::StateError("VM is stopped".to_string()));
1275 }
1276 }
1277 drop(state);
1278
1279 if self
1280 .resolved_execution_plan
1281 .as_ref()
1282 .is_some_and(|plan| plan.backend == ExecutionBackend::Crun)
1283 || self.config.isolation.is_sandbox()
1284 {
1285 return Err(BoxError::StateError(
1286 "Pause is not supported by the Sandbox backend yet".to_string(),
1287 ));
1288 }
1289
1290 if let Some(pid) = self.pid().await {
1291 let ret = unsafe { libc::kill(pid as i32, libc::SIGSTOP) };
1293 if ret != 0 {
1294 let err = std::io::Error::last_os_error();
1295 return Err(BoxError::ExecError(format!(
1296 "Failed to send SIGSTOP to pid {}: {}",
1297 pid, err
1298 )));
1299 }
1300 tracing::info!(box_id = %self.box_id, pid, "VM paused");
1301 Ok(())
1302 } else {
1303 Err(BoxError::StateError(
1304 "VM has no running process".to_string(),
1305 ))
1306 }
1307 }
1308
1309 #[cfg(unix)]
1313 pub async fn resume(&self) -> Result<()> {
1314 if self
1315 .resolved_execution_plan
1316 .as_ref()
1317 .is_some_and(|plan| plan.backend == ExecutionBackend::Crun)
1318 || self.config.isolation.is_sandbox()
1319 {
1320 return Err(BoxError::StateError(
1321 "Resume is not supported by the Sandbox backend yet".to_string(),
1322 ));
1323 }
1324 if let Some(pid) = self.pid().await {
1325 let ret = unsafe { libc::kill(pid as i32, libc::SIGCONT) };
1327 if ret != 0 {
1328 let err = std::io::Error::last_os_error();
1329 return Err(BoxError::ExecError(format!(
1330 "Failed to send SIGCONT to pid {}: {}",
1331 pid, err
1332 )));
1333 }
1334 tracing::info!(box_id = %self.box_id, pid, "VM resumed");
1335 Ok(())
1336 } else {
1337 Err(BoxError::StateError(
1338 "VM has no running process".to_string(),
1339 ))
1340 }
1341 }
1342
1343 #[cfg(windows)]
1345 pub async fn pause(&self) -> Result<()> {
1346 Err(BoxError::StateError(
1347 "VM pause is not yet supported on Windows".to_string(),
1348 ))
1349 }
1350
1351 #[cfg(windows)]
1353 pub async fn resume(&self) -> Result<()> {
1354 Err(BoxError::StateError(
1355 "VM resume is not yet supported on Windows".to_string(),
1356 ))
1357 }
1358
1359 pub async fn health_check(&self) -> Result<bool> {
1361 let state = self.state.read().await;
1362
1363 match *state {
1364 BoxState::Ready | BoxState::Busy | BoxState::Compacting => {
1365 if let Some(ref handler) = *self.handler.read().await {
1367 Ok(handler.is_running())
1368 } else {
1369 Ok(false)
1370 }
1371 }
1372 _ => Ok(false),
1373 }
1374 }
1375
1376 pub async fn metrics(&self) -> Option<crate::vmm::VmMetrics> {
1378 let vm_metrics = self
1379 .handler
1380 .read()
1381 .await
1382 .as_ref()
1383 .map(|handler| handler.metrics())?;
1384
1385 if let Some(ref prom) = self.prom {
1387 prom.vm_cpu_percent
1388 .with_label_values(&[&self.box_id])
1389 .set(vm_metrics.cpu_percent.unwrap_or(0.0) as f64);
1390 prom.vm_memory_bytes
1391 .with_label_values(&[&self.box_id])
1392 .set(vm_metrics.memory_bytes.unwrap_or(0) as f64);
1393 }
1394
1395 Some(vm_metrics)
1396 }
1397
1398 pub async fn pid(&self) -> Option<u32> {
1400 self.handler
1401 .read()
1402 .await
1403 .as_ref()
1404 .map(|handler| handler.pid())
1405 }
1406
1407 #[cfg(unix)]
1409 pub fn tee(&self) -> Option<&dyn TeeExtension> {
1410 self.tee.as_deref()
1411 }
1412
1413 #[cfg(unix)]
1415 pub fn require_tee(&self) -> Result<&dyn TeeExtension> {
1416 self.tee.as_deref().ok_or_else(|| {
1417 BoxError::AttestationError("TEE is not configured for this box".to_string())
1418 })
1419 }
1420
1421 #[cfg(unix)]
1429 pub async fn update_resources(
1430 &self,
1431 update: &crate::resize::ResourceUpdate,
1432 ) -> Result<crate::resize::ResizeResult> {
1433 if self
1434 .resolved_execution_plan
1435 .as_ref()
1436 .is_some_and(|plan| plan.backend == ExecutionBackend::Crun)
1437 || self.config.isolation.is_sandbox()
1438 {
1439 return Err(BoxError::StateError(
1440 "Live resource updates are not supported by the Sandbox backend yet".to_string(),
1441 ));
1442 }
1443 crate::resize::validate_update(update)?;
1445
1446 let mut result = crate::resize::ResizeResult {
1447 applied: Vec::new(),
1448 rejected: Vec::new(),
1449 };
1450
1451 if !update.has_tier2_changes() {
1452 return Ok(result);
1453 }
1454
1455 let commands = update.build_cgroup_commands();
1457 for cmd_str in &commands {
1458 let shell_cmd = vec!["sh".to_string(), "-c".to_string(), cmd_str.clone()];
1459
1460 match self.exec_command(shell_cmd, 5_000_000_000).await {
1461 Ok(output) if output.exit_code == 0 => {
1462 result.applied.push(cmd_str.clone());
1463 }
1464 Ok(output) => {
1465 let stderr = String::from_utf8_lossy(&output.stderr);
1466 let reason = if stderr.trim().is_empty() {
1467 format!("exit code {}", output.exit_code)
1468 } else {
1469 stderr.trim().to_string()
1470 };
1471 tracing::warn!(
1472 box_id = %self.box_id,
1473 cmd = %cmd_str,
1474 exit_code = output.exit_code,
1475 stderr = %stderr,
1476 "Cgroup update failed inside guest"
1477 );
1478 result.rejected.push((cmd_str.clone(), reason));
1479 }
1480 Err(e) => {
1481 tracing::warn!(
1482 box_id = %self.box_id,
1483 cmd = %cmd_str,
1484 error = %e,
1485 "Failed to exec cgroup update in guest"
1486 );
1487 result.rejected.push((cmd_str.clone(), e.to_string()));
1488 }
1489 }
1490 }
1491
1492 Ok(result)
1493 }
1494}
1495
1496#[cfg(unix)]
1501fn is_restore_mode(config: &BoxConfig) -> bool {
1502 config
1503 .restore_from
1504 .as_deref()
1505 .is_some_and(|s| !s.is_empty())
1506 || std::env::var("KRUN_RESTORE_FROM")
1507 .map(|v| !v.is_empty())
1508 .unwrap_or(false)
1509}
1510
1511pub(crate) fn fnv1a_hash(input: &str) -> u64 {
1513 let mut hash: u64 = 0xcbf29ce484222325;
1514 for byte in input.bytes() {
1515 hash ^= byte as u64;
1516 hash = hash.wrapping_mul(0x100000001b3);
1517 }
1518 hash
1519}
1520
1521#[cfg(unix)]
1522fn default_stop_signal() -> i32 {
1523 libc::SIGTERM
1524}
1525
1526#[cfg(windows)]
1527fn default_stop_signal() -> i32 {
1528 15
1529}
1530
1531#[cfg(test)]
1532mod tests {
1533 use super::*;
1534 use a3s_box_core::event::EventEmitter;
1535 use std::sync::{
1536 atomic::{AtomicBool, Ordering},
1537 Arc,
1538 };
1539
1540 struct RecordingHandler {
1541 stopped: Arc<AtomicBool>,
1542 }
1543
1544 impl VmHandler for RecordingHandler {
1545 fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
1546 self.stopped.store(true, Ordering::SeqCst);
1547 Ok(())
1548 }
1549
1550 fn metrics(&self) -> crate::vmm::VmMetrics {
1551 crate::vmm::VmMetrics::default()
1552 }
1553
1554 fn is_running(&self) -> bool {
1555 true
1556 }
1557
1558 fn pid(&self) -> u32 {
1559 42
1560 }
1561 }
1562
1563 struct ExitStateHandler {
1564 exited: bool,
1565 }
1566
1567 impl VmHandler for ExitStateHandler {
1568 fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
1569 Ok(())
1570 }
1571
1572 fn metrics(&self) -> crate::vmm::VmMetrics {
1573 crate::vmm::VmMetrics::default()
1574 }
1575
1576 fn is_running(&self) -> bool {
1577 !self.exited
1578 }
1579
1580 fn has_exited(&self) -> bool {
1581 self.exited
1582 }
1583
1584 fn pid(&self) -> u32 {
1585 42
1586 }
1587 }
1588
1589 struct FailingHandler;
1591
1592 impl VmHandler for FailingHandler {
1593 fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
1594 Err(BoxError::StateError("simulated stop failure".to_string()))
1595 }
1596
1597 fn metrics(&self) -> crate::vmm::VmMetrics {
1598 crate::vmm::VmMetrics::default()
1599 }
1600
1601 fn is_running(&self) -> bool {
1602 true
1603 }
1604
1605 fn pid(&self) -> u32 {
1606 42
1607 }
1608 }
1609
1610 #[tokio::test]
1615 async fn test_destroy_runs_host_teardown_even_when_handler_stop_fails() {
1616 let tmp = tempfile::tempdir().unwrap();
1617 let box_id = "box-stopfail".to_string();
1618 let mut vm =
1620 VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
1621 vm.home_dir = tmp.path().to_path_buf();
1622 *vm.handler.write().await = Some(Box::new(FailingHandler));
1623
1624 let box_dir = tmp.path().join("boxes").join(&box_id);
1625 std::fs::create_dir_all(box_dir.join("logs")).unwrap();
1626
1627 let result = vm.destroy_with_options(default_stop_signal(), 100).await;
1628
1629 assert!(
1631 result.is_err(),
1632 "a handler-stop failure must still be reported"
1633 );
1634 assert!(vm.handler.read().await.is_none());
1636 assert!(
1637 !box_dir.exists(),
1638 "non-persistent box dir must be removed even when the stop failed"
1639 );
1640 }
1641
1642 #[tokio::test]
1643 async fn test_cleanup_boot_failure_stops_handler_and_removes_created_volumes() {
1644 let tmp = tempfile::tempdir().unwrap();
1645 let box_id = "box-test".to_string();
1646 let mut vm =
1647 VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
1648 vm.home_dir = tmp.path().to_path_buf();
1649 vm.anonymous_volumes = vec!["created-volume".to_string(), "reused-volume".to_string()];
1650 vm.created_anonymous_volumes = vec!["created-volume".to_string()];
1651
1652 let stopped = Arc::new(AtomicBool::new(false));
1653 *vm.handler.write().await = Some(Box::new(RecordingHandler {
1654 stopped: stopped.clone(),
1655 }));
1656
1657 let box_dir = tmp.path().join("boxes").join(&box_id);
1658 std::fs::create_dir_all(box_dir.join("logs")).unwrap();
1659
1660 let store = crate::volume::VolumeStore::new(
1661 tmp.path().join("volumes.json"),
1662 tmp.path().join("volumes"),
1663 );
1664 store
1665 .create(a3s_box_core::volume::VolumeConfig::new(
1666 "created-volume",
1667 "",
1668 ))
1669 .unwrap();
1670 store
1671 .create(a3s_box_core::volume::VolumeConfig::new("reused-volume", ""))
1672 .unwrap();
1673
1674 vm.cleanup_boot_failure().await;
1675
1676 assert!(stopped.load(Ordering::SeqCst));
1677 assert!(vm.handler.read().await.is_none());
1678 assert!(vm.created_anonymous_volumes.is_empty());
1679 assert_eq!(vm.anonymous_volumes, vec!["reused-volume".to_string()]);
1680 assert!(store.get("created-volume").unwrap().is_none());
1681 assert!(store.get("reused-volume").unwrap().is_some());
1682 assert!(!box_dir.exists());
1683 }
1684
1685 #[tokio::test]
1686 async fn test_wait_for_vm_running_returns_error_when_handler_exited() {
1687 let vm = VmManager::with_box_id(
1688 BoxConfig::default(),
1689 EventEmitter::new(16),
1690 "box-exited".to_string(),
1691 );
1692 *vm.handler.write().await = Some(Box::new(ExitStateHandler { exited: true }));
1693
1694 let err = vm.wait_for_vm_running().await.unwrap_err();
1695
1696 assert!(err
1697 .to_string()
1698 .contains("VM process exited immediately after start"));
1699 }
1700
1701 #[tokio::test]
1702 async fn test_wait_for_vm_running_succeeds_when_handler_stays_running() {
1703 let config = BoxConfig {
1704 restore_from: Some("snapshot-path".to_string()),
1705 ..BoxConfig::default()
1706 };
1707 let vm = VmManager::with_box_id(config, EventEmitter::new(16), "box-running".to_string());
1708 *vm.handler.write().await = Some(Box::new(ExitStateHandler { exited: false }));
1709
1710 vm.wait_for_vm_running().await.unwrap();
1711 }
1712
1713 #[tokio::test]
1714 async fn test_try_wait_exit_reads_guest_persisted_exit_code() {
1715 let tmp = tempfile::tempdir().unwrap();
1716 let box_id = "box-exit-code".to_string();
1717 let mut vm =
1718 VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
1719 vm.home_dir = tmp.path().to_path_buf();
1720
1721 let exit_path = tmp
1722 .path()
1723 .join("boxes")
1724 .join(&box_id)
1725 .join("upper")
1726 .join(".a3s_exit_code");
1727 std::fs::create_dir_all(exit_path.parent().unwrap()).unwrap();
1728 std::fs::write(&exit_path, "42\n").unwrap();
1729
1730 assert_eq!(vm.try_wait_exit().await.unwrap(), Some(42));
1731 assert_eq!(vm.exit_code(), Some(42));
1732 assert!(vm.has_exited().await);
1733 }
1734
1735 #[cfg(unix)]
1736 #[tokio::test]
1737 async fn test_wait_for_exec_ready_returns_when_handler_already_exited() {
1738 let mut vm = VmManager::with_box_id(
1739 BoxConfig::default(),
1740 EventEmitter::new(16),
1741 "box-exec-exited".to_string(),
1742 );
1743 *vm.handler.write().await = Some(Box::new(ExitStateHandler { exited: true }));
1744 let tmp = tempfile::tempdir().unwrap();
1745
1746 vm.wait_for_exec_ready(&tmp.path().join("missing-exec.sock"))
1747 .await
1748 .unwrap();
1749
1750 assert!(vm.exec_client.is_none());
1751 }
1752
1753 #[cfg(unix)]
1754 #[tokio::test]
1755 async fn test_wait_for_exec_ready_returns_when_guest_exit_code_persisted() {
1756 let tmp = tempfile::tempdir().unwrap();
1757 let box_id = "box-exec-finished".to_string();
1758 let mut vm =
1759 VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
1760 vm.home_dir = tmp.path().to_path_buf();
1761
1762 let exit_path = tmp
1763 .path()
1764 .join("boxes")
1765 .join(&box_id)
1766 .join("upper")
1767 .join(".a3s_exit_code");
1768 std::fs::create_dir_all(exit_path.parent().unwrap()).unwrap();
1769 std::fs::write(&exit_path, "17\n").unwrap();
1770
1771 tokio::time::timeout(
1772 std::time::Duration::from_secs(1),
1773 vm.wait_for_exec_ready(&tmp.path().join("missing-exec.sock")),
1774 )
1775 .await
1776 .unwrap()
1777 .unwrap();
1778
1779 assert_eq!(vm.exit_code(), Some(17));
1780 assert!(vm.exec_client.is_none());
1781 }
1782
1783 #[cfg(unix)]
1784 #[tokio::test]
1785 async fn test_probe_exec_ready_once_ignores_missing_socket() {
1786 let mut vm = VmManager::with_box_id(
1787 BoxConfig::default(),
1788 EventEmitter::new(16),
1789 "box-probe".to_string(),
1790 );
1791 let tmp = tempfile::tempdir().unwrap();
1792
1793 vm.probe_exec_ready_once(&tmp.path().join("missing-exec.sock"))
1794 .await;
1795
1796 assert!(vm.exec_client.is_none());
1797 }
1798
1799 #[cfg(unix)]
1800 #[tokio::test]
1801 async fn test_attach_running_process_infers_port_forward_socket_path() {
1802 let mut vm = VmManager::with_box_id(
1803 BoxConfig::default(),
1804 EventEmitter::new(16),
1805 "box-test".to_string(),
1806 );
1807 let tmp = tempfile::tempdir().unwrap();
1808 let exec_socket_path = tmp.path().join("exec.sock");
1809 let pty_socket_path = Some(tmp.path().join("pty.sock"));
1810
1811 vm.attach_running_process(
1812 std::process::id(),
1813 exec_socket_path.clone(),
1814 pty_socket_path.clone(),
1815 )
1816 .await
1817 .unwrap();
1818
1819 assert_eq!(vm.exec_socket_path(), Some(exec_socket_path.as_path()));
1820 assert_eq!(vm.pty_socket_path(), pty_socket_path.as_deref());
1821 assert_eq!(
1822 vm.port_forward_socket_path(),
1823 Some(exec_socket_path.with_file_name("portfwd.sock").as_path())
1824 );
1825 assert_eq!(vm.state().await, BoxState::Ready);
1826 }
1827}