1mod layout;
4mod network;
5mod ready;
6pub mod reap;
7mod sandbox;
8mod spec;
9#[cfg(windows)]
10mod windows_stop;
11
12pub(crate) use layout::{persistent_rootfs_generation_exists, runtime_socket_dir};
13
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17pub type PullProgressFn = Arc<dyn Fn(usize, usize, &str, i64) + Send + Sync>;
19
20use a3s_box_core::config::BoxConfig;
21#[cfg(unix)]
22use a3s_box_core::config::TeeConfig;
23use a3s_box_core::error::{BoxError, Result};
24use a3s_box_core::event::{BoxEvent, EventEmitter};
25use a3s_box_core::execution::{ExecutionBackend, ResolvedExecutionPlan};
26use serde::{Deserialize, Serialize};
27use tokio::sync::RwLock;
28use tracing::Instrument;
29
30#[cfg(unix)]
31use libc;
32
33#[cfg(unix)]
34use crate::grpc::ExecClient;
35#[cfg(unix)]
36use crate::tee::TeeExtension;
37use crate::vmm::{VmController, VmHandler, VmmProvider, DEFAULT_SHUTDOWN_TIMEOUT_MS};
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41pub enum BoxState {
42 Created,
44
45 Ready,
47
48 Busy,
50
51 Compacting,
53
54 Stopped,
56}
57
58pub(crate) struct BoxLayout {
60 pub(crate) rootfs_path: PathBuf,
62 pub(crate) exec_socket_path: PathBuf,
64 pub(crate) pty_socket_path: PathBuf,
66 pub(crate) attest_socket_path: PathBuf,
68 pub(crate) port_forward_socket_path: PathBuf,
70 pub(crate) workspace_path: PathBuf,
72 pub(crate) console_output: Option<PathBuf>,
74 pub(crate) oci_config: Option<crate::oci::OciImageConfig>,
76 pub(crate) prefer_image_rootfs_metadata: bool,
80 pub(crate) tee_instance_config: Option<crate::vmm::TeeInstanceConfig>,
82}
83
84#[cfg(target_os = "windows")]
85const WINDOWS_GUEST_EXIT_CODE: &str = ".a3s_exit_code";
86#[cfg(target_os = "windows")]
87const WINDOWS_GUEST_STDOUT: &str = "guest-init.stdout.log";
88#[cfg(target_os = "windows")]
89const WINDOWS_GUEST_STDERR: &str = "guest-init.stderr.log";
90#[cfg(target_os = "windows")]
91const WINDOWS_STOP_DELIVERY_TIMEOUT_MS: u64 = 1_000;
92#[cfg(target_os = "windows")]
93const WINDOWS_GUEST_FINALIZATION_TIMEOUT_MS: u64 = 30_000;
94#[cfg(target_os = "windows")]
95const WINDOWS_GUEST_RESULT_MARKER: &str = ".a3s_host_result_collected";
96#[cfg(target_os = "windows")]
97const WINDOWS_LIVE_LOGS_DRAINED_MARKER: &str = ".a3s_host_live_logs_drained";
98
99#[cfg(target_os = "windows")]
102fn append_windows_guest_stream(
103 source: &Path,
104 destination: &Path,
105 runtime_filter: &a3s_box_core::log::RuntimeConsoleFilter,
106) -> std::io::Result<()> {
107 use std::io::{BufRead, Write};
108
109 let input = match a3s_box_core::windows_file::open_regular_file(source, None) {
110 Ok((input, _)) => input,
111 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
112 Err(error) => return Err(error),
113 };
114 let mut reader = std::io::BufReader::new(input);
115 let mut output = std::fs::OpenOptions::new()
116 .create(true)
117 .append(true)
118 .open(destination)?;
119 let mut line = Vec::new();
120
121 loop {
122 line.clear();
123 if reader.read_until(b'\n', &mut line)? == 0 {
124 break;
125 }
126 let keep = !line.ends_with(b"\n")
127 || std::str::from_utf8(&line).map_or(true, |line| runtime_filter.keep_line(line));
128 if keep {
129 output.write_all(&line)?;
130 }
131 }
132
133 output.flush()
134}
135
136#[cfg(target_os = "windows")]
137fn windows_marker_matches(path: &Path, expected: &[u8]) -> bool {
138 use std::io::Read;
139
140 let Ok((file, _)) = a3s_box_core::windows_file::open_regular_file(path, None) else {
141 return false;
142 };
143 let mut contents = Vec::with_capacity(expected.len().saturating_add(1));
144 if file
145 .take(expected.len().saturating_add(1) as u64)
146 .read_to_end(&mut contents)
147 .is_err()
148 {
149 return false;
150 }
151 contents == expected
152}
153
154#[cfg(target_os = "windows")]
160pub fn collect_windows_guest_result(
161 box_dir: &Path,
162 log_config: &a3s_box_core::log::LogConfig,
163 shim_exit_code: i32,
164) -> Result<i32> {
165 let rootfs = box_dir.join("rootfs");
166 let logs = box_dir.join("logs");
167 let marker = rootfs.join(WINDOWS_GUEST_RESULT_MARKER);
168 let live_logs_drained = rootfs.join(WINDOWS_LIVE_LOGS_DRAINED_MARKER);
169 let stdout_source = rootfs.join(WINDOWS_GUEST_STDOUT);
170 let stderr_source = rootfs.join(WINDOWS_GUEST_STDERR);
171
172 if !windows_marker_matches(&marker, b"collected\n") {
173 std::fs::create_dir_all(&logs)?;
174 let runtime_filter = a3s_box_core::log::RuntimeConsoleFilter::new();
175
176 for (source, destination) in [
177 (&stdout_source, logs.join("console.log")),
178 (&stderr_source, logs.join("console.err.log")),
179 ] {
180 append_windows_guest_stream(source, &destination, &runtime_filter).map_err(
181 |error| BoxError::BoxBootError {
182 message: format!(
183 "Failed to collect Windows guest output {} into {}: {error}",
184 source.display(),
185 destination.display()
186 ),
187 hint: None,
188 },
189 )?;
190 }
191
192 if !windows_marker_matches(&live_logs_drained, b"drained\n") {
198 let stopped = std::sync::atomic::AtomicBool::new(true);
199 a3s_box_core::log::run_log_processor_streams(
200 &stdout_source,
201 &stderr_source,
202 &logs,
203 log_config,
204 &stopped,
205 );
206 }
207
208 a3s_box_core::windows_file::replace_regular_file(&marker, b"collected\n").map_err(
209 |error| BoxError::BoxBootError {
210 message: format!(
211 "Failed to mark the Windows guest result collected at {}: {error}",
212 marker.display()
213 ),
214 hint: None,
215 },
216 )?;
217 }
218
219 let exit_path = rootfs.join(WINDOWS_GUEST_EXIT_CODE);
220 let contents = match a3s_box_core::windows_file::open_regular_file(&exit_path, None) {
221 Ok((file, _)) => {
222 use std::io::Read;
223 let mut contents = String::new();
224 file.take(64)
225 .read_to_string(&mut contents)
226 .map_err(|error| BoxError::BoxBootError {
227 message: format!(
228 "Failed to read the Windows guest exit code {}: {error}",
229 exit_path.display()
230 ),
231 hint: None,
232 })?;
233 contents
234 }
235 Err(error) if error.kind() == std::io::ErrorKind::NotFound && shim_exit_code != 0 => {
236 return Ok(shim_exit_code);
237 }
238 Err(error) => {
239 return Err(BoxError::BoxBootError {
240 message: if error.kind() == std::io::ErrorKind::NotFound {
241 format!(
242 "WHPX stopped before the guest persisted its exit code ({})",
243 exit_path.display()
244 )
245 } else {
246 format!(
247 "Failed to read the Windows guest exit code {}: {error}",
248 exit_path.display()
249 )
250 },
251 hint: Some(
252 "Inspect logs/init-rust.log and the shim log for the guest boot failure"
253 .to_string(),
254 ),
255 });
256 }
257 };
258
259 contents
260 .trim()
261 .parse::<i32>()
262 .map_err(|error| BoxError::BoxBootError {
263 message: format!(
264 "Invalid Windows guest exit code in {}: {error}",
265 exit_path.display()
266 ),
267 hint: None,
268 })
269}
270
271pub struct VmManager {
273 pub(crate) config: BoxConfig,
275
276 pub(crate) box_id: String,
278
279 pub(crate) state: Arc<RwLock<BoxState>>,
281
282 pub(crate) event_emitter: EventEmitter,
284
285 pub(crate) provider: Option<Box<dyn VmmProvider>>,
287
288 pub(crate) handler: Arc<RwLock<Option<Box<dyn VmHandler>>>>,
290
291 #[cfg(unix)]
293 pub(crate) exec_client: Option<ExecClient>,
294
295 pub(crate) net_manager: Option<Box<dyn crate::network::NetworkBackend>>,
298
299 pub(crate) home_dir: PathBuf,
301
302 pub(crate) anonymous_volumes: Vec<String>,
304
305 pub(crate) created_anonymous_volumes: Vec<String>,
310
311 pub(crate) image_config: Option<crate::oci::OciImageConfig>,
313
314 pub(crate) healthcheck_disabled: bool,
317
318 pub(crate) preserve_rootfs_on_boot_failure: bool,
322
323 #[cfg(unix)]
325 pub(crate) tee: Option<Box<dyn TeeExtension>>,
326
327 pub(crate) rootfs_provider: Box<dyn crate::rootfs::RootfsProvider>,
329
330 pub(crate) exec_socket_path: Option<PathBuf>,
332
333 pub(crate) pty_socket_path: Option<PathBuf>,
335
336 pub(crate) port_forward_socket_path: Option<PathBuf>,
338
339 pub(crate) prom: Option<crate::prom::RuntimeMetrics>,
341
342 pub(crate) shim_exit_code: Option<i32>,
344
345 pub(crate) pull_progress_fn: Option<PullProgressFn>,
347
348 pub(crate) log_config: a3s_box_core::log::LogConfig,
352
353 pub(crate) resolved_execution_plan: Option<ResolvedExecutionPlan>,
355}
356
357impl VmManager {
358 pub fn new(config: BoxConfig, event_emitter: EventEmitter) -> Self {
360 let box_id = uuid::Uuid::new_v4().to_string();
361 let home_dir = a3s_box_core::dirs_home();
362
363 Self {
364 config,
365 box_id,
366 state: Arc::new(RwLock::new(BoxState::Created)),
367 event_emitter,
368 provider: None,
369 handler: Arc::new(RwLock::new(None)),
370 #[cfg(unix)]
371 exec_client: None,
372 net_manager: None,
373 home_dir,
374 anonymous_volumes: Vec::new(),
375 created_anonymous_volumes: Vec::new(),
376 image_config: None,
377 healthcheck_disabled: false,
378 preserve_rootfs_on_boot_failure: false,
379 #[cfg(unix)]
380 tee: None,
381 rootfs_provider: crate::rootfs::default_provider(),
382 exec_socket_path: None,
383 pty_socket_path: None,
384 port_forward_socket_path: None,
385 prom: None,
386 shim_exit_code: None,
387 pull_progress_fn: None,
388 log_config: a3s_box_core::log::LogConfig::default(),
389 resolved_execution_plan: None,
390 }
391 }
392
393 pub fn with_box_id(config: BoxConfig, event_emitter: EventEmitter, box_id: String) -> Self {
395 let home_dir = a3s_box_core::dirs_home();
396
397 Self {
398 config,
399 box_id,
400 state: Arc::new(RwLock::new(BoxState::Created)),
401 event_emitter,
402 provider: None,
403 handler: Arc::new(RwLock::new(None)),
404 #[cfg(unix)]
405 exec_client: None,
406 net_manager: None,
407 home_dir,
408 anonymous_volumes: Vec::new(),
409 created_anonymous_volumes: Vec::new(),
410 image_config: None,
411 healthcheck_disabled: false,
412 preserve_rootfs_on_boot_failure: false,
413 #[cfg(unix)]
414 tee: None,
415 rootfs_provider: crate::rootfs::default_provider(),
416 exec_socket_path: None,
417 pty_socket_path: None,
418 port_forward_socket_path: None,
419 prom: None,
420 shim_exit_code: None,
421 pull_progress_fn: None,
422 log_config: a3s_box_core::log::LogConfig::default(),
423 resolved_execution_plan: None,
424 }
425 }
426
427 async fn cleanup_boot_failure(&mut self) {
429 if let Some(mut handler) = self.handler.write().await.take() {
430 if let Err(error) = handler.stop(default_stop_signal(), DEFAULT_SHUTDOWN_TIMEOUT_MS) {
431 tracing::warn!(
432 box_id = %self.box_id,
433 error = %error,
434 "Failed to stop VM handler after boot failure"
435 );
436 }
437 self.shim_exit_code = handler.exit_code();
438 }
439
440 if let Some(mut net_manager) = self.net_manager.take() {
441 net_manager.stop();
442 }
443
444 self.cleanup_created_anonymous_volumes();
445 self.cleanup_box_dir();
446 }
447
448 fn cleanup_created_anonymous_volumes(&mut self) {
449 if self.created_anonymous_volumes.is_empty() {
450 return;
451 }
452
453 let created = std::mem::take(&mut self.created_anonymous_volumes);
454 let created_set: std::collections::HashSet<_> = created.iter().cloned().collect();
455 let store = crate::volume::VolumeStore::new(
456 self.home_dir.join("volumes.json"),
457 self.home_dir.join("volumes"),
458 );
459
460 for volume_name in &created {
461 if let Err(error) = store.remove(volume_name, true) {
462 tracing::debug!(
463 box_id = %self.box_id,
464 volume = volume_name,
465 error = %error,
466 "Failed to remove anonymous volume after boot failure"
467 );
468 }
469 }
470
471 self.anonymous_volumes
472 .retain(|name| !created_set.contains(name));
473 }
474
475 fn cleanup_box_dir(&self) {
477 let box_dir = self.home_dir.join("boxes").join(&self.box_id);
478 let socket_dir = self.socket_dir();
479
480 #[cfg(target_os = "linux")]
487 crate::network::terminate_passt(&self.socket_dir());
488
489 if let Err(error) = self
490 .rootfs_provider
491 .cleanup(&box_dir, self.preserve_rootfs_on_boot_failure)
492 {
493 tracing::warn!(
494 box_id = %self.box_id,
495 path = %box_dir.display(),
496 error = %error,
497 "Failed to cleanup rootfs provider after boot failure"
498 );
499 }
500
501 match std::fs::remove_dir_all(&socket_dir) {
502 Ok(()) => {}
503 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
504 Err(error) => {
505 tracing::debug!(
506 box_id = %self.box_id,
507 path = %socket_dir.display(),
508 error = %error,
509 "Failed to cleanup socket directory after boot failure"
510 );
511 }
512 }
513
514 if !self.config.persistent {
518 match std::fs::remove_dir_all(&box_dir) {
519 Ok(()) => {}
520 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
521 Err(error) => {
522 tracing::warn!(
523 box_id = %self.box_id,
524 path = %box_dir.display(),
525 error = %error,
526 "Failed to cleanup box directory after boot failure"
527 );
528 }
529 }
530 }
531 }
532
533 pub fn with_provider(
535 config: BoxConfig,
536 event_emitter: EventEmitter,
537 provider: Box<dyn VmmProvider>,
538 ) -> Self {
539 let box_id = uuid::Uuid::new_v4().to_string();
540 let home_dir = a3s_box_core::dirs_home();
541 Self {
542 config,
543 box_id,
544 state: Arc::new(RwLock::new(BoxState::Created)),
545 event_emitter,
546 provider: Some(provider),
547 handler: Arc::new(RwLock::new(None)),
548 #[cfg(unix)]
549 exec_client: None,
550 net_manager: None,
551 home_dir,
552 anonymous_volumes: Vec::new(),
553 created_anonymous_volumes: Vec::new(),
554 image_config: None,
555 healthcheck_disabled: false,
556 preserve_rootfs_on_boot_failure: false,
557 #[cfg(unix)]
558 tee: None,
559 rootfs_provider: crate::rootfs::default_provider(),
560 exec_socket_path: None,
561 pty_socket_path: None,
562 port_forward_socket_path: None,
563 prom: None,
564 shim_exit_code: None,
565 pull_progress_fn: None,
566 log_config: a3s_box_core::log::LogConfig::default(),
567 resolved_execution_plan: None,
568 }
569 }
570
571 pub fn box_id(&self) -> &str {
573 &self.box_id
574 }
575
576 pub async fn state(&self) -> BoxState {
578 *self.state.read().await
579 }
580
581 #[cfg(unix)]
583 pub fn exec_client(&self) -> Option<&ExecClient> {
584 self.exec_client.as_ref()
585 }
586
587 #[cfg(unix)]
588 async fn connect_exec_client_for_request(socket_path: &Path) -> Result<ExecClient> {
589 const ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
590
591 let client = ExecClient::connect(socket_path).await?;
592 match tokio::time::timeout(ATTEMPT_TIMEOUT, client.heartbeat()).await {
593 Ok(Ok(true)) => Ok(client),
594 Ok(Ok(false)) => Err(BoxError::ExecError(format!(
595 "Exec client not connected: heartbeat failed at {}",
596 socket_path.display()
597 ))),
598 Ok(Err(error)) => Err(error),
599 Err(_) => Err(BoxError::ExecError(format!(
600 "Exec client not connected: heartbeat timed out at {}",
601 socket_path.display()
602 ))),
603 }
604 }
605
606 #[cfg(unix)]
612 pub async fn wait_for_exec_available(&mut self, timeout: std::time::Duration) -> Result<()> {
613 let socket_path = self
614 .exec_socket_path
615 .clone()
616 .ok_or_else(|| BoxError::ExecError("Exec socket path is unavailable".to_string()))?;
617 let deadline = tokio::time::Instant::now() + timeout;
618 loop {
619 match Self::connect_exec_client_for_request(&socket_path).await {
620 Ok(client) => {
621 self.exec_client = Some(client);
622 return Ok(());
623 }
624 Err(error) if tokio::time::Instant::now() < deadline => {
625 tracing::debug!(%error, "Waiting for pooled VM exec readiness");
626 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
627 }
628 Err(error) => return Err(error),
629 }
630 }
631 }
632
633 #[cfg(not(unix))]
634 pub async fn wait_for_exec_available(&mut self, _timeout: std::time::Duration) -> Result<()> {
635 Ok(())
636 }
637
638 #[cfg(unix)]
644 pub async fn attach_running_process(
645 &mut self,
646 pid: u32,
647 exec_socket_path: PathBuf,
648 pty_socket_path: Option<PathBuf>,
649 ) -> Result<()> {
650 let port_forward_socket_path = exec_socket_path.with_file_name("portfwd.sock");
651 let handler = crate::vmm::ShimHandler::from_pid(pid, self.box_id.clone());
652 if !handler.is_running() {
653 return Err(BoxError::StateError(format!(
654 "Cannot attach to non-running VM process {pid}"
655 )));
656 }
657
658 self.exec_client = match ExecClient::connect(&exec_socket_path).await {
659 Ok(client) => Some(client),
660 Err(error) => {
661 tracing::debug!(
662 box_id = %self.box_id,
663 socket_path = %exec_socket_path.display(),
664 error = %error,
665 "Failed to reconnect exec client while attaching to running VM"
666 );
667 None
668 }
669 };
670 self.exec_socket_path = Some(exec_socket_path);
671 self.pty_socket_path = pty_socket_path;
672 self.port_forward_socket_path = Some(port_forward_socket_path);
673 *self.handler.write().await = Some(Box::new(handler));
674 *self.state.write().await = BoxState::Ready;
675 Ok(())
676 }
677
678 #[cfg(windows)]
680 pub async fn attach_running_process(
681 &mut self,
682 pid: u32,
683 exec_socket_path: PathBuf,
684 pty_socket_path: Option<PathBuf>,
685 ) -> Result<()> {
686 let handler = crate::vmm::ShimHandler::from_pid(pid, self.box_id.clone());
687 if !handler.is_running() {
688 return Err(BoxError::StateError(format!(
689 "Cannot attach to non-running VM process {pid}"
690 )));
691 }
692
693 self.exec_socket_path = Some(exec_socket_path);
694 self.pty_socket_path = pty_socket_path;
695 self.port_forward_socket_path = None;
696 *self.handler.write().await = Some(Box::new(handler));
697 *self.state.write().await = BoxState::Ready;
698 Ok(())
699 }
700
701 pub fn exec_socket_path(&self) -> Option<&Path> {
703 self.exec_socket_path.as_deref()
704 }
705
706 pub fn pty_socket_path(&self) -> Option<&Path> {
708 self.pty_socket_path.as_deref()
709 }
710
711 pub fn port_forward_socket_path(&self) -> Option<&Path> {
713 self.port_forward_socket_path.as_deref()
714 }
715
716 pub fn set_provider(&mut self, provider: Box<dyn VmmProvider>) {
721 self.provider = Some(provider);
722 }
723
724 pub fn set_rootfs_provider(&mut self, provider: Box<dyn crate::rootfs::RootfsProvider>) {
729 self.rootfs_provider = provider;
730 }
731
732 pub fn rootfs_provider_name(&self) -> &str {
734 self.rootfs_provider.name()
735 }
736
737 pub fn set_pull_progress_fn(&mut self, f: PullProgressFn) {
740 self.pull_progress_fn = Some(f);
741 }
742
743 pub fn set_metrics(&mut self, metrics: crate::prom::RuntimeMetrics) {
745 self.prom = Some(metrics);
746 }
747
748 pub fn set_log_config(&mut self, log_config: a3s_box_core::log::LogConfig) {
751 self.log_config = log_config;
752 }
753
754 pub fn set_healthcheck_disabled(&mut self, disabled: bool) {
756 self.healthcheck_disabled = disabled;
757 }
758
759 pub fn metrics_prom(&self) -> Option<&crate::prom::RuntimeMetrics> {
761 self.prom.as_ref()
762 }
763
764 pub fn anonymous_volumes(&self) -> &[String] {
769 &self.anonymous_volumes
770 }
771
772 pub fn image_config(&self) -> Option<&crate::oci::OciImageConfig> {
774 self.image_config.as_ref()
775 }
776
777 pub fn resolved_execution_plan(&self) -> Option<&ResolvedExecutionPlan> {
779 self.resolved_execution_plan.as_ref()
780 }
781
782 pub fn exit_code(&self) -> Option<i32> {
788 self.shim_exit_code
789 }
790
791 #[cfg(not(target_os = "windows"))]
792 fn persisted_exit_code(&self) -> Option<i32> {
793 crate::rootfs::read_persisted_exit_code(&self.home_dir.join("boxes").join(&self.box_id))
794 }
795
796 pub async fn try_wait_exit(&mut self) -> Result<Option<i32>> {
802 if let Some(code) = self.shim_exit_code {
803 return Ok(Some(code));
804 }
805
806 #[cfg(not(target_os = "windows"))]
811 if let Some(code) = self.persisted_exit_code() {
812 self.shim_exit_code = Some(code);
813 return Ok(Some(code));
814 }
815
816 let mut handler = self.handler.write().await;
817 let Some(handler) = handler.as_mut() else {
818 return Ok(self.shim_exit_code);
819 };
820
821 if let Some(code) = handler.try_wait_exit()? {
822 #[cfg(target_os = "windows")]
823 let code = collect_windows_guest_result(
824 &self.home_dir.join("boxes").join(&self.box_id),
825 &self.log_config,
826 code,
827 )?;
828 self.shim_exit_code = Some(code);
829 return Ok(Some(code));
830 }
831
832 Ok(None)
833 }
834
835 pub async fn has_exited(&self) -> bool {
838 if self.shim_exit_code.is_some() {
839 return true;
840 }
841
842 #[cfg(not(target_os = "windows"))]
843 if self.persisted_exit_code().is_some() {
844 return true;
845 }
846
847 self.handler
848 .read()
849 .await
850 .as_ref()
851 .map(|handler| handler.has_exited())
852 .unwrap_or(false)
853 }
854
855 #[cfg(unix)]
863 pub async fn run_deferred_main(
864 &mut self,
865 spec_json: &[u8],
866 timeout: std::time::Duration,
867 ) -> Result<a3s_box_core::exec::ExecOutput> {
868 let log_dir = self.home_dir.join("boxes").join(&self.box_id).join("logs");
869 let console_out_path = log_dir.join("console.log");
870 let console_err_path = a3s_box_core::log::stderr_console_path(&console_out_path);
871 let console_out_start = std::fs::metadata(&console_out_path)
872 .map(|metadata| metadata.len())
873 .unwrap_or(0);
874 let console_err_start = std::fs::metadata(&console_err_path)
875 .map(|metadata| metadata.len())
876 .unwrap_or(0);
877
878 let acked = {
879 let owned_client;
880 let client = if let Some(client) = self.exec_client.as_ref() {
881 client
882 } else {
883 let socket_path = self
884 .exec_socket_path
885 .as_deref()
886 .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?;
887 owned_client = Self::connect_exec_client_for_request(socket_path).await?;
888 &owned_client
889 };
890 client.spawn_main(Some(spec_json)).await?
891 };
892 let exit_wait_timeout = if acked {
893 timeout
894 } else {
895 tracing::debug!(
901 box_id = %self.box_id,
902 "spawn-main was not acknowledged; waiting briefly for main exit"
903 );
904 timeout.min(std::time::Duration::from_secs(2))
905 };
906
907 let start = std::time::Instant::now();
909 let exit_code = loop {
910 if let Some(code) = self.try_wait_exit().await? {
911 break code;
912 }
913 if start.elapsed() >= exit_wait_timeout {
914 let message = if acked {
915 "deferred main did not exit within the timeout"
916 } else {
917 "spawn-main was not acknowledged by the guest"
918 };
919 return Err(BoxError::ExecError(message.to_string()));
920 }
921 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
922 };
923
924 let json_path = log_dir.join("container.json");
931 let drain_start = std::time::Instant::now();
932 let max_wait = std::time::Duration::from_secs(2);
933 let min_wait = std::time::Duration::from_millis(500);
934 let quiet_window = std::time::Duration::from_millis(200);
935 let mut last_len: Option<u64> = None;
936 let mut last_change = drain_start;
937 loop {
938 let len = std::fs::metadata(&json_path).map(|m| m.len()).unwrap_or(0);
939 if last_len != Some(len) {
940 last_len = Some(len);
941 last_change = std::time::Instant::now();
942 }
943 let elapsed = drain_start.elapsed();
944 if elapsed >= max_wait || (elapsed >= min_wait && last_change.elapsed() >= quiet_window)
945 {
946 break;
947 }
948 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
949 }
950 let (mut stdout, mut stderr) = self.read_container_logs();
951 if stdout.is_empty() {
952 stdout = Self::read_file_from_offset(&console_out_path, console_out_start);
953 }
954 if stderr.is_empty() {
955 stderr = Self::read_file_from_offset(&console_err_path, console_err_start);
956 }
957 let truncated = stdout.len() > a3s_box_core::exec::MAX_OUTPUT_BYTES
958 || stderr.len() > a3s_box_core::exec::MAX_OUTPUT_BYTES;
959 stdout.truncate(a3s_box_core::exec::MAX_OUTPUT_BYTES);
960 stderr.truncate(a3s_box_core::exec::MAX_OUTPUT_BYTES);
961 Ok(a3s_box_core::exec::ExecOutput {
962 stdout,
963 stderr,
964 exit_code,
965 truncated,
966 })
967 }
968
969 fn read_file_from_offset(path: &Path, offset: u64) -> Vec<u8> {
970 use std::io::{Read, Seek, SeekFrom};
971
972 let mut file = match std::fs::File::open(path) {
973 Ok(file) => file,
974 Err(_) => return vec![],
975 };
976 if file.seek(SeekFrom::Start(offset)).is_err() {
977 return vec![];
978 }
979
980 let mut bytes = Vec::new();
981 if file.read_to_end(&mut bytes).is_err() {
982 return vec![];
983 }
984 bytes
985 }
986
987 #[cfg(unix)]
989 fn read_container_logs(&self) -> (Vec<u8>, Vec<u8>) {
990 let path = self
991 .home_dir
992 .join("boxes")
993 .join(&self.box_id)
994 .join("logs")
995 .join("container.json");
996 let (mut out, mut err) = (Vec::new(), Vec::new());
997 if let Ok(content) = std::fs::read_to_string(&path) {
998 for line in content.lines() {
999 if let Ok(entry) = serde_json::from_str::<a3s_box_core::log::LogEntry>(line) {
1000 if entry.stream == "stderr" {
1001 err.extend_from_slice(entry.log.as_bytes());
1002 } else {
1003 out.extend_from_slice(entry.log.as_bytes());
1004 }
1005 }
1006 }
1007 }
1008 (out, err)
1009 }
1010
1011 #[cfg(unix)]
1015 #[tracing::instrument(skip(self, request), fields(box_id = %self.box_id))]
1016 pub async fn exec_request(
1017 &self,
1018 request: &a3s_box_core::exec::ExecRequest,
1019 ) -> Result<a3s_box_core::exec::ExecOutput> {
1020 if request.cmd.is_empty() {
1021 return Err(BoxError::ExecError(
1022 "Exec request requires a non-empty command".to_string(),
1023 ));
1024 }
1025
1026 let state = self.state.read().await;
1027 match *state {
1028 BoxState::Ready | BoxState::Busy | BoxState::Compacting => {}
1029 BoxState::Created => {
1030 return Err(BoxError::ExecError("VM not yet booted".to_string()));
1031 }
1032 BoxState::Stopped => {
1033 return Err(BoxError::ExecError("VM is stopped".to_string()));
1034 }
1035 }
1036 drop(state);
1037
1038 let owned_client;
1039 let client = if let Some(client) = self.exec_client.as_ref() {
1040 client
1041 } else {
1042 let socket_path = self
1043 .exec_socket_path
1044 .as_deref()
1045 .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?;
1046 owned_client = Self::connect_exec_client_for_request(socket_path).await?;
1047 &owned_client
1048 };
1049
1050 let exec_start = std::time::Instant::now();
1051 let result = client.exec_command(request).await;
1052
1053 if let Some(ref prom) = self.prom {
1055 prom.exec_total.inc();
1056 prom.exec_duration
1057 .observe(exec_start.elapsed().as_secs_f64());
1058 if result.is_err() || result.as_ref().is_ok_and(|o| o.exit_code != 0) {
1059 prom.exec_errors_total.inc();
1060 }
1061 }
1062
1063 result
1064 }
1065
1066 #[cfg(unix)]
1070 #[tracing::instrument(skip(self, cmd), fields(box_id = %self.box_id))]
1071 pub async fn exec_command(
1072 &self,
1073 cmd: Vec<String>,
1074 timeout_ns: u64,
1075 ) -> Result<a3s_box_core::exec::ExecOutput> {
1076 let request = a3s_box_core::exec::ExecRequest {
1077 request_id: None,
1078 cmd,
1079 timeout_ns,
1080 env: vec![],
1081 working_dir: None,
1082 rootfs: None,
1083 stdin: None,
1084 stdin_streaming: false,
1085 user: None,
1086 streaming: false,
1087 };
1088
1089 self.exec_request(&request).await
1090 }
1091
1092 pub async fn boot(&mut self) -> Result<()> {
1094 let boot_span = tracing::info_span!("vm_boot", box_id = %self.box_id);
1095 {
1097 let state = self.state.read().await;
1098 if *state != BoxState::Created {
1099 return Err(BoxError::StateError("VM already booted".to_string()));
1100 }
1101 }
1102
1103 let box_dir = self.home_dir.join("boxes").join(&self.box_id);
1104 self.preserve_rootfs_on_boot_failure =
1105 self.config.persistent && layout::persistent_rootfs_generation_exists(&box_dir)?;
1106
1107 let execution_plan = a3s_box_core::resolve_execution(&self.config)?;
1108 self.resolved_execution_plan = Some(execution_plan.clone());
1109 if execution_plan.backend == ExecutionBackend::Crun {
1110 let boot_start = std::time::Instant::now();
1111 return self
1112 .boot_sandbox(execution_plan, &boot_span, boot_start)
1113 .await;
1114 }
1115
1116 let boot_start = std::time::Instant::now();
1117
1118 tracing::info!(parent: &boot_span, box_id = %self.box_id, "Booting VM");
1119
1120 let layout = match self
1122 .prepare_layout()
1123 .instrument(tracing::info_span!(parent: &boot_span, "prepare_layout"))
1124 .await
1125 {
1126 Ok(layout) => layout,
1127 Err(error) => {
1128 self.cleanup_boot_failure().await;
1129 return Err(error);
1130 }
1131 };
1132 self.image_config = layout.oci_config.clone();
1133
1134 if let Err(error) = a3s_box_core::rootfs_metadata::stage_terminal_rootfs_metadata_for_boot(
1138 &layout.rootfs_path,
1139 ) {
1140 self.cleanup_boot_failure().await;
1141 return Err(BoxError::IoError(error));
1142 }
1143
1144 let resolv_content = a3s_box_core::dns::generate_resolv_conf(&self.config.dns);
1146 if let Err(e) = crate::oci::rootfs::write_guest_file(
1147 &layout.rootfs_path,
1148 "etc/resolv.conf",
1149 &resolv_content,
1150 ) {
1151 self.cleanup_boot_failure().await;
1152 return Err(e);
1153 }
1154 tracing::debug!(parent: &boot_span, dns = %resolv_content.trim(), "Configured guest DNS");
1155
1156 if let Err(e) = self.write_hostname_file(&layout) {
1158 self.cleanup_boot_failure().await;
1159 return Err(e);
1160 }
1161 if let Err(e) = self.write_standalone_hosts_file(&layout) {
1162 self.cleanup_boot_failure().await;
1163 return Err(e);
1164 }
1165
1166 let mut spec = match self.build_instance_spec(&layout) {
1168 Ok(s) => s,
1169 Err(e) => {
1170 self.cleanup_boot_failure().await;
1171 return Err(e);
1172 }
1173 };
1174
1175 let bridge_network = match &self.config.network {
1177 a3s_box_core::NetworkMode::Bridge { network } => Some(network.clone()),
1178 _ => None,
1179 };
1180 if let Some(network_name) = bridge_network {
1181 let net_config = match self.setup_bridge_network(&network_name) {
1182 Ok(n) => n,
1183 Err(e) => {
1184 self.cleanup_boot_failure().await;
1185 return Err(e);
1186 }
1187 };
1188
1189 match self.write_hosts_file(&layout, &network_name) {
1191 Ok(()) => (),
1192 Err(e) => {
1193 self.cleanup_boot_failure().await;
1194 return Err(e);
1195 }
1196 };
1197
1198 let ip_cidr = format!("{}/{}", net_config.ip_address, net_config.prefix_len);
1201 spec.entrypoint
1202 .env
1203 .push(("A3S_NET_IP".to_string(), ip_cidr));
1204 spec.entrypoint.env.push((
1205 "A3S_NET_GATEWAY".to_string(),
1206 net_config.gateway.to_string(),
1207 ));
1208 spec.entrypoint.env.push((
1209 "A3S_NET_DNS".to_string(),
1210 net_config
1211 .dns_servers
1212 .iter()
1213 .map(|s| s.to_string())
1214 .collect::<Vec<_>>()
1215 .join(","),
1216 ));
1217
1218 spec.network = Some(net_config);
1219 }
1220
1221 #[cfg(target_os = "macos")]
1222 if spec.network.is_none()
1223 && matches!(self.config.network, a3s_box_core::NetworkMode::Tsi)
1224 && !self.config.port_map.is_empty()
1225 {
1226 let net_config = match self.setup_published_default_network() {
1227 Ok(network) => network,
1228 Err(error) => {
1229 self.cleanup_boot_failure().await;
1230 return Err(error);
1231 }
1232 };
1233 let ip_cidr = format!("{}/{}", net_config.ip_address, net_config.prefix_len);
1234 spec.entrypoint
1235 .env
1236 .push(("A3S_NET_IP".to_string(), ip_cidr));
1237 spec.entrypoint.env.push((
1238 "A3S_NET_GATEWAY".to_string(),
1239 net_config.gateway.to_string(),
1240 ));
1241 spec.entrypoint.env.push((
1242 "A3S_NET_DNS".to_string(),
1243 net_config
1244 .dns_servers
1245 .iter()
1246 .map(ToString::to_string)
1247 .collect::<Vec<_>>()
1248 .join(","),
1249 ));
1250 spec.network = Some(net_config);
1251 }
1252
1253 if self.provider.is_none() {
1255 let shim_path = match VmController::find_shim() {
1256 Ok(p) => p,
1257 Err(e) => {
1258 self.cleanup_boot_failure().await;
1259 return Err(e);
1260 }
1261 };
1262 let controller = match VmController::new(shim_path) {
1263 Ok(c) => c,
1264 Err(e) => {
1265 self.cleanup_boot_failure().await;
1266 return Err(e);
1267 }
1268 };
1269 self.provider = Some(Box::new(controller));
1270 }
1271
1272 let handler = {
1274 let provider = self
1275 .provider
1276 .as_ref()
1277 .ok_or_else(|| BoxError::BoxBootError {
1278 message: "VMM provider not initialized".to_string(),
1279 hint: Some("Ensure VmManager has a provider set before boot".to_string()),
1280 })?;
1281 let vm_start_span = tracing::info_span!(parent: &boot_span, "vm_start");
1282 match async { provider.start(&spec).await }
1283 .instrument(vm_start_span)
1284 .await
1285 {
1286 Ok(h) => h,
1287 Err(e) => {
1288 self.cleanup_boot_failure().await;
1289 return Err(e);
1290 }
1291 }
1292 };
1293
1294 *self.handler.write().await = Some(handler);
1296
1297 {
1299 let wait_span = tracing::info_span!(parent: &boot_span, "wait_for_ready");
1300 if let Err(e) = async {
1301 self.wait_for_vm_running().await?;
1302
1303 #[cfg(unix)]
1308 if is_restore_mode(&self.config) {
1309 self.probe_exec_ready_once(&layout.exec_socket_path).await;
1310 } else {
1311 self.wait_for_exec_ready(&layout.exec_socket_path).await?;
1312 }
1313 Ok::<(), BoxError>(())
1314 }
1315 .instrument(wait_span)
1316 .await
1317 {
1318 self.cleanup_boot_failure().await;
1319 return Err(e);
1320 }
1321 }
1322
1323 #[cfg(unix)]
1334 if !is_restore_mode(&self.config)
1335 && std::env::var("BOX_DEFERRED_MAIN")
1336 .map(|v| v == "1")
1337 .unwrap_or(false)
1338 {
1339 if let Some(client) = self.exec_client.as_ref() {
1340 match client.spawn_main(None).await {
1341 Ok(true) => tracing::info!("deferred container main spawned"),
1342 Ok(false) => tracing::warn!("deferred spawn-main not acknowledged"),
1343 Err(e) => tracing::warn!(error = %e, "deferred spawn-main failed"),
1344 }
1345 }
1346 }
1347
1348 self.exec_socket_path = Some(layout.exec_socket_path.clone());
1350 self.pty_socket_path = Some(layout.pty_socket_path.clone());
1351 self.port_forward_socket_path = Some(layout.port_forward_socket_path.clone());
1352
1353 #[cfg(unix)]
1355 if !matches!(self.config.tee, TeeConfig::None) {
1356 self.tee = Some(Box::new(crate::tee::SnpTeeExtension::new(
1357 self.box_id.clone(),
1358 layout.attest_socket_path.clone(),
1359 )));
1360 }
1361
1362 *self.state.write().await = BoxState::Ready;
1364
1365 if let Some(ref prom) = self.prom {
1367 let boot_duration = boot_start.elapsed().as_secs_f64();
1368 prom.vm_boot_duration.observe(boot_duration);
1369 prom.vm_created_total.inc();
1370 prom.vm_count.with_label_values(&["ready"]).inc();
1371 }
1372
1373 self.event_emitter.emit(BoxEvent::empty("box.ready"));
1375
1376 tracing::info!(parent: &boot_span, box_id = %self.box_id, "VM ready");
1377
1378 Ok(())
1379 }
1380
1381 pub async fn destroy(&mut self) -> Result<()> {
1383 self.destroy_with_options(default_stop_signal(), DEFAULT_SHUTDOWN_TIMEOUT_MS)
1384 .await
1385 }
1386
1387 pub async fn destroy_with_timeout(&mut self, timeout_ms: u64) -> Result<()> {
1389 self.destroy_with_options(default_stop_signal(), timeout_ms)
1390 .await
1391 }
1392
1393 #[tracing::instrument(skip(self), fields(box_id = %self.box_id))]
1398 pub async fn destroy_with_options(&mut self, signal: i32, timeout_ms: u64) -> Result<()> {
1399 let preserve_rootfs = self.config.persistent;
1400 self.destroy_with_rootfs_policy(signal, timeout_ms, preserve_rootfs)
1401 .await
1402 }
1403
1404 pub(crate) async fn destroy_preserving_rootfs_with_options(
1407 &mut self,
1408 signal: i32,
1409 timeout_ms: u64,
1410 ) -> Result<()> {
1411 self.destroy_with_rootfs_policy(signal, timeout_ms, true)
1412 .await
1413 }
1414
1415 pub(crate) async fn destroy_preserving_rootfs(&mut self) -> Result<()> {
1416 self.destroy_with_rootfs_policy(default_stop_signal(), DEFAULT_SHUTDOWN_TIMEOUT_MS, true)
1417 .await
1418 }
1419
1420 async fn destroy_with_rootfs_policy(
1421 &mut self,
1422 signal: i32,
1423 timeout_ms: u64,
1424 preserve_rootfs: bool,
1425 ) -> Result<()> {
1426 let mut state = self.state.write().await;
1427
1428 if *state == BoxState::Stopped {
1429 return Ok(());
1430 }
1431
1432 tracing::info!(box_id = %self.box_id, signal, timeout_ms, "Destroying VM");
1433
1434 *state = BoxState::Stopped;
1436
1437 let mut stop_error = None;
1443 if let Some(mut handler) = self.handler.write().await.take() {
1444 #[cfg(windows)]
1445 let stop_request = match windows_stop::stage(&self.socket_dir(), signal) {
1446 Ok(path) => {
1447 tracing::debug!(
1448 box_id = %self.box_id,
1449 signal,
1450 path = %path.display(),
1451 "Staged Windows guest stop request"
1452 );
1453 Some(path)
1454 }
1455 Err(error) => {
1456 tracing::warn!(
1457 box_id = %self.box_id,
1458 signal,
1459 error = %error,
1460 "Failed to stage Windows guest stop request; force-stop fallback remains active"
1461 );
1462 None
1463 }
1464 };
1465
1466 #[cfg(windows)]
1467 let handler_timeout_ms = if timeout_ms == 0 {
1468 0
1469 } else if let Some(request) = stop_request.as_deref() {
1470 let delivery_started = std::time::Instant::now();
1471 let delivery_timeout = std::time::Duration::from_millis(
1472 timeout_ms.min(WINDOWS_STOP_DELIVERY_TIMEOUT_MS),
1473 );
1474 let delivered =
1475 match windows_stop::wait_until_delivered(request, delivery_timeout).await {
1476 Ok(delivered) => delivered,
1477 Err(error) => {
1478 tracing::warn!(
1479 box_id = %self.box_id,
1480 error = %error,
1481 "Failed while waiting for Windows guest stop request delivery"
1482 );
1483 false
1484 }
1485 };
1486 let delivery_elapsed_ms =
1487 u64::try_from(delivery_started.elapsed().as_millis()).unwrap_or(u64::MAX);
1488 let remaining_timeout_ms = timeout_ms.saturating_sub(delivery_elapsed_ms);
1489 if delivered {
1490 let finalization_timeout_ms = if self.config.persistent {
1491 WINDOWS_GUEST_FINALIZATION_TIMEOUT_MS
1492 } else {
1493 0
1494 };
1495 let handler_timeout_ms =
1496 remaining_timeout_ms.saturating_add(finalization_timeout_ms);
1497 tracing::debug!(
1498 box_id = %self.box_id,
1499 delivery_elapsed_ms,
1500 handler_timeout_ms,
1501 "Delivered Windows stop request to the guest"
1502 );
1503 handler_timeout_ms
1504 } else {
1505 tracing::warn!(
1506 box_id = %self.box_id,
1507 delivery_elapsed_ms,
1508 "Windows guest stop request was not delivered before the forwarding deadline"
1509 );
1510 remaining_timeout_ms
1511 }
1512 } else {
1513 timeout_ms
1514 };
1515 #[cfg(not(windows))]
1516 let handler_timeout_ms = timeout_ms;
1517
1518 let _handler_stopped = match handler.stop(signal, handler_timeout_ms) {
1519 Ok(()) => true,
1520 Err(e) => {
1521 tracing::error!(box_id = %self.box_id, error = %e, "Failed to stop VM handler; continuing teardown");
1522 stop_error = Some(e);
1523 false
1524 }
1525 };
1526 self.shim_exit_code = handler.exit_code();
1527
1528 #[cfg(windows)]
1529 if stop_request.is_some() {
1530 if let Err(error) = windows_stop::clear(&self.socket_dir()) {
1531 tracing::warn!(
1532 box_id = %self.box_id,
1533 error = %error,
1534 "Failed to clear Windows guest stop request"
1535 );
1536 }
1537 }
1538
1539 #[cfg(windows)]
1540 if _handler_stopped && self.config.persistent {
1541 let rootfs = self
1542 .home_dir
1543 .join("boxes")
1544 .join(&self.box_id)
1545 .join("rootfs");
1546 match a3s_box_core::rootfs_metadata::finalize_terminal_rootfs_metadata(&rootfs) {
1547 Ok(true) => tracing::info!(
1548 box_id = %self.box_id,
1549 path = %rootfs.display(),
1550 "Published terminal rootfs metadata after Windows guest exit"
1551 ),
1552 Ok(false) => tracing::debug!(
1553 box_id = %self.box_id,
1554 path = %rootfs.display(),
1555 "No Windows terminal rootfs metadata required host finalization"
1556 ),
1557 Err(error) => tracing::warn!(
1558 box_id = %self.box_id,
1559 path = %rootfs.display(),
1560 error = %error,
1561 "Refused to publish invalid Windows terminal rootfs metadata"
1562 ),
1563 }
1564 }
1565 }
1566
1567 if let Some(ref mut net) = self.net_manager {
1569 net.stop();
1570 }
1571 self.net_manager = None;
1572
1573 let box_dir = self.home_dir.join("boxes").join(&self.box_id);
1575 if let Err(e) = self.rootfs_provider.cleanup(&box_dir, preserve_rootfs) {
1576 tracing::warn!(
1577 box_id = %self.box_id,
1578 error = %e,
1579 "Failed to cleanup rootfs provider"
1580 );
1581 }
1582
1583 let socket_dir = self.socket_dir();
1584 if let Err(e) = std::fs::remove_dir_all(&socket_dir) {
1585 tracing::debug!(
1586 box_id = %self.box_id,
1587 path = %socket_dir.display(),
1588 error = %e,
1589 "Failed to cleanup VM socket directory"
1590 );
1591 }
1592
1593 if !preserve_rootfs {
1600 match std::fs::remove_dir_all(&box_dir) {
1601 Ok(()) => {}
1602 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1603 Err(e) => {
1604 tracing::warn!(
1605 box_id = %self.box_id,
1606 path = %box_dir.display(),
1607 error = %e,
1608 "Failed to remove box directory on destroy"
1609 );
1610 }
1611 }
1612 }
1613
1614 if let Some(ref prom) = self.prom {
1616 prom.vm_destroyed_total.inc();
1617 prom.vm_count.with_label_values(&["ready"]).dec();
1618 }
1619
1620 self.event_emitter.emit(BoxEvent::empty("box.stopped"));
1622
1623 match stop_error {
1626 Some(e) => Err(e),
1627 None => Ok(()),
1628 }
1629 }
1630
1631 pub async fn set_busy(&self) -> Result<()> {
1633 let mut state = self.state.write().await;
1634
1635 if *state != BoxState::Ready {
1636 return Err(BoxError::StateError("VM not ready".to_string()));
1637 }
1638
1639 *state = BoxState::Busy;
1640 Ok(())
1641 }
1642
1643 pub async fn set_ready(&self) -> Result<()> {
1645 let mut state = self.state.write().await;
1646
1647 if *state != BoxState::Busy && *state != BoxState::Compacting {
1648 return Err(BoxError::StateError("Invalid state transition".to_string()));
1649 }
1650
1651 *state = BoxState::Ready;
1652 Ok(())
1653 }
1654
1655 pub async fn set_compacting(&self) -> Result<()> {
1657 let mut state = self.state.write().await;
1658
1659 if *state != BoxState::Busy {
1660 return Err(BoxError::StateError("VM not busy".to_string()));
1661 }
1662
1663 *state = BoxState::Compacting;
1664 Ok(())
1665 }
1666
1667 #[cfg(unix)]
1671 pub async fn pause(&self) -> Result<()> {
1672 let state = self.state.read().await;
1673 match *state {
1674 BoxState::Ready | BoxState::Busy | BoxState::Compacting => {}
1675 BoxState::Created => {
1676 return Err(BoxError::StateError("VM not yet booted".to_string()));
1677 }
1678 BoxState::Stopped => {
1679 return Err(BoxError::StateError("VM is stopped".to_string()));
1680 }
1681 }
1682 drop(state);
1683
1684 if self
1685 .resolved_execution_plan
1686 .as_ref()
1687 .is_some_and(|plan| plan.backend == ExecutionBackend::Crun)
1688 || self.config.isolation.is_sandbox()
1689 {
1690 return Err(BoxError::StateError(
1691 "Pause is not supported by the Sandbox backend yet".to_string(),
1692 ));
1693 }
1694
1695 if let Some(pid) = self.pid().await {
1696 let ret = unsafe { libc::kill(pid as i32, libc::SIGSTOP) };
1698 if ret != 0 {
1699 let err = std::io::Error::last_os_error();
1700 return Err(BoxError::ExecError(format!(
1701 "Failed to send SIGSTOP to pid {}: {}",
1702 pid, err
1703 )));
1704 }
1705 tracing::info!(box_id = %self.box_id, pid, "VM paused");
1706 Ok(())
1707 } else {
1708 Err(BoxError::StateError(
1709 "VM has no running process".to_string(),
1710 ))
1711 }
1712 }
1713
1714 #[cfg(unix)]
1718 pub async fn resume(&self) -> Result<()> {
1719 if self
1720 .resolved_execution_plan
1721 .as_ref()
1722 .is_some_and(|plan| plan.backend == ExecutionBackend::Crun)
1723 || self.config.isolation.is_sandbox()
1724 {
1725 return Err(BoxError::StateError(
1726 "Resume is not supported by the Sandbox backend yet".to_string(),
1727 ));
1728 }
1729 if let Some(pid) = self.pid().await {
1730 let ret = unsafe { libc::kill(pid as i32, libc::SIGCONT) };
1732 if ret != 0 {
1733 let err = std::io::Error::last_os_error();
1734 return Err(BoxError::ExecError(format!(
1735 "Failed to send SIGCONT to pid {}: {}",
1736 pid, err
1737 )));
1738 }
1739 tracing::info!(box_id = %self.box_id, pid, "VM resumed");
1740 Ok(())
1741 } else {
1742 Err(BoxError::StateError(
1743 "VM has no running process".to_string(),
1744 ))
1745 }
1746 }
1747
1748 #[cfg(windows)]
1750 pub async fn pause(&self) -> Result<()> {
1751 Err(BoxError::StateError(
1752 "VM pause is not yet supported on Windows".to_string(),
1753 ))
1754 }
1755
1756 #[cfg(windows)]
1758 pub async fn resume(&self) -> Result<()> {
1759 Err(BoxError::StateError(
1760 "VM resume is not yet supported on Windows".to_string(),
1761 ))
1762 }
1763
1764 pub async fn health_check(&self) -> Result<bool> {
1766 let state = self.state.read().await;
1767
1768 match *state {
1769 BoxState::Ready | BoxState::Busy | BoxState::Compacting => {
1770 if let Some(ref handler) = *self.handler.read().await {
1772 Ok(handler.is_running())
1773 } else {
1774 Ok(false)
1775 }
1776 }
1777 _ => Ok(false),
1778 }
1779 }
1780
1781 pub async fn metrics(&self) -> Option<crate::vmm::VmMetrics> {
1783 let vm_metrics = self
1784 .handler
1785 .read()
1786 .await
1787 .as_ref()
1788 .map(|handler| handler.metrics())?;
1789
1790 if let Some(ref prom) = self.prom {
1792 prom.vm_cpu_percent
1793 .with_label_values(&[&self.box_id])
1794 .set(vm_metrics.cpu_percent.unwrap_or(0.0) as f64);
1795 prom.vm_memory_bytes
1796 .with_label_values(&[&self.box_id])
1797 .set(vm_metrics.memory_bytes.unwrap_or(0) as f64);
1798 }
1799
1800 Some(vm_metrics)
1801 }
1802
1803 pub async fn pid(&self) -> Option<u32> {
1805 self.handler
1806 .read()
1807 .await
1808 .as_ref()
1809 .map(|handler| handler.pid())
1810 }
1811
1812 #[cfg(unix)]
1814 pub fn tee(&self) -> Option<&dyn TeeExtension> {
1815 self.tee.as_deref()
1816 }
1817
1818 #[cfg(unix)]
1820 pub fn require_tee(&self) -> Result<&dyn TeeExtension> {
1821 self.tee.as_deref().ok_or_else(|| {
1822 BoxError::AttestationError("TEE is not configured for this box".to_string())
1823 })
1824 }
1825
1826 #[cfg(unix)]
1834 pub async fn update_resources(
1835 &self,
1836 update: &crate::resize::ResourceUpdate,
1837 ) -> Result<crate::resize::ResizeResult> {
1838 if self
1839 .resolved_execution_plan
1840 .as_ref()
1841 .is_some_and(|plan| plan.backend == ExecutionBackend::Crun)
1842 || self.config.isolation.is_sandbox()
1843 {
1844 return Err(BoxError::StateError(
1845 "Live resource updates are not supported by the Sandbox backend yet".to_string(),
1846 ));
1847 }
1848 crate::resize::validate_update(update)?;
1850
1851 let mut result = crate::resize::ResizeResult {
1852 applied: Vec::new(),
1853 rejected: Vec::new(),
1854 };
1855
1856 if !update.has_tier2_changes() {
1857 return Ok(result);
1858 }
1859
1860 let commands = update.build_cgroup_commands();
1862 for cmd_str in &commands {
1863 let shell_cmd = vec!["sh".to_string(), "-c".to_string(), cmd_str.clone()];
1864
1865 match self.exec_command(shell_cmd, 5_000_000_000).await {
1866 Ok(output) if output.exit_code == 0 => {
1867 result.applied.push(cmd_str.clone());
1868 }
1869 Ok(output) => {
1870 let stderr = String::from_utf8_lossy(&output.stderr);
1871 let reason = if stderr.trim().is_empty() {
1872 format!("exit code {}", output.exit_code)
1873 } else {
1874 stderr.trim().to_string()
1875 };
1876 tracing::warn!(
1877 box_id = %self.box_id,
1878 cmd = %cmd_str,
1879 exit_code = output.exit_code,
1880 stderr = %stderr,
1881 "Cgroup update failed inside guest"
1882 );
1883 result.rejected.push((cmd_str.clone(), reason));
1884 }
1885 Err(e) => {
1886 tracing::warn!(
1887 box_id = %self.box_id,
1888 cmd = %cmd_str,
1889 error = %e,
1890 "Failed to exec cgroup update in guest"
1891 );
1892 result.rejected.push((cmd_str.clone(), e.to_string()));
1893 }
1894 }
1895 }
1896
1897 Ok(result)
1898 }
1899}
1900
1901#[cfg(unix)]
1906fn is_restore_mode(config: &BoxConfig) -> bool {
1907 config
1908 .restore_from
1909 .as_deref()
1910 .is_some_and(|s| !s.is_empty())
1911 || std::env::var("KRUN_RESTORE_FROM")
1912 .map(|v| !v.is_empty())
1913 .unwrap_or(false)
1914}
1915
1916pub(crate) fn fnv1a_hash(input: &str) -> u64 {
1918 let mut hash: u64 = 0xcbf29ce484222325;
1919 for byte in input.bytes() {
1920 hash ^= byte as u64;
1921 hash = hash.wrapping_mul(0x100000001b3);
1922 }
1923 hash
1924}
1925
1926#[cfg(unix)]
1927fn default_stop_signal() -> i32 {
1928 libc::SIGTERM
1929}
1930
1931#[cfg(windows)]
1932fn default_stop_signal() -> i32 {
1933 15
1934}
1935
1936#[cfg(test)]
1937mod tests {
1938 use super::*;
1939 use a3s_box_core::event::EventEmitter;
1940 use std::sync::{
1941 atomic::{AtomicBool, Ordering},
1942 Arc,
1943 };
1944
1945 struct RecordingHandler {
1946 stopped: Arc<AtomicBool>,
1947 }
1948
1949 impl VmHandler for RecordingHandler {
1950 fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
1951 self.stopped.store(true, Ordering::SeqCst);
1952 Ok(())
1953 }
1954
1955 fn metrics(&self) -> crate::vmm::VmMetrics {
1956 crate::vmm::VmMetrics::default()
1957 }
1958
1959 fn is_running(&self) -> bool {
1960 true
1961 }
1962
1963 fn pid(&self) -> u32 {
1964 42
1965 }
1966 }
1967
1968 struct ExitStateHandler {
1969 exited: bool,
1970 }
1971
1972 impl VmHandler for ExitStateHandler {
1973 fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
1974 Ok(())
1975 }
1976
1977 fn metrics(&self) -> crate::vmm::VmMetrics {
1978 crate::vmm::VmMetrics::default()
1979 }
1980
1981 fn is_running(&self) -> bool {
1982 !self.exited
1983 }
1984
1985 fn has_exited(&self) -> bool {
1986 self.exited
1987 }
1988
1989 fn pid(&self) -> u32 {
1990 42
1991 }
1992 }
1993
1994 struct CompletedHandler {
1995 code: i32,
1996 }
1997
1998 impl VmHandler for CompletedHandler {
1999 fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
2000 Ok(())
2001 }
2002
2003 fn metrics(&self) -> crate::vmm::VmMetrics {
2004 crate::vmm::VmMetrics::default()
2005 }
2006
2007 fn is_running(&self) -> bool {
2008 false
2009 }
2010
2011 fn has_exited(&self) -> bool {
2012 true
2013 }
2014
2015 fn pid(&self) -> u32 {
2016 42
2017 }
2018
2019 fn try_wait_exit(&mut self) -> Result<Option<i32>> {
2020 Ok(Some(self.code))
2021 }
2022 }
2023
2024 struct FailingHandler;
2026
2027 impl VmHandler for FailingHandler {
2028 fn stop(&mut self, _signal: i32, _timeout_ms: u64) -> Result<()> {
2029 Err(BoxError::StateError("simulated stop failure".to_string()))
2030 }
2031
2032 fn metrics(&self) -> crate::vmm::VmMetrics {
2033 crate::vmm::VmMetrics::default()
2034 }
2035
2036 fn is_running(&self) -> bool {
2037 true
2038 }
2039
2040 fn pid(&self) -> u32 {
2041 42
2042 }
2043 }
2044
2045 #[tokio::test]
2050 async fn test_destroy_runs_host_teardown_even_when_handler_stop_fails() {
2051 let tmp = tempfile::tempdir().unwrap();
2052 let box_id = "box-stopfail".to_string();
2053 let mut vm =
2055 VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
2056 vm.home_dir = tmp.path().to_path_buf();
2057 *vm.handler.write().await = Some(Box::new(FailingHandler));
2058
2059 let box_dir = tmp.path().join("boxes").join(&box_id);
2060 std::fs::create_dir_all(box_dir.join("logs")).unwrap();
2061
2062 let result = vm.destroy_with_options(default_stop_signal(), 100).await;
2063
2064 assert!(
2066 result.is_err(),
2067 "a handler-stop failure must still be reported"
2068 );
2069 assert!(vm.handler.read().await.is_none());
2071 assert!(
2072 !box_dir.exists(),
2073 "non-persistent box dir must be removed even when the stop failed"
2074 );
2075 }
2076
2077 #[tokio::test]
2078 async fn test_cleanup_boot_failure_stops_handler_and_removes_created_volumes() {
2079 let tmp = tempfile::tempdir().unwrap();
2080 let box_id = "box-test".to_string();
2081 let mut vm =
2082 VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
2083 vm.home_dir = tmp.path().to_path_buf();
2084 vm.anonymous_volumes = vec!["created-volume".to_string(), "reused-volume".to_string()];
2085 vm.created_anonymous_volumes = vec!["created-volume".to_string()];
2086
2087 let stopped = Arc::new(AtomicBool::new(false));
2088 *vm.handler.write().await = Some(Box::new(RecordingHandler {
2089 stopped: stopped.clone(),
2090 }));
2091
2092 let box_dir = tmp.path().join("boxes").join(&box_id);
2093 std::fs::create_dir_all(box_dir.join("logs")).unwrap();
2094
2095 let store = crate::volume::VolumeStore::new(
2096 tmp.path().join("volumes.json"),
2097 tmp.path().join("volumes"),
2098 );
2099 store
2100 .create(a3s_box_core::volume::VolumeConfig::new(
2101 "created-volume",
2102 "",
2103 ))
2104 .unwrap();
2105 store
2106 .create(a3s_box_core::volume::VolumeConfig::new("reused-volume", ""))
2107 .unwrap();
2108
2109 vm.cleanup_boot_failure().await;
2110
2111 assert!(stopped.load(Ordering::SeqCst));
2112 assert!(vm.handler.read().await.is_none());
2113 assert!(vm.created_anonymous_volumes.is_empty());
2114 assert_eq!(vm.anonymous_volumes, vec!["reused-volume".to_string()]);
2115 assert!(store.get("created-volume").unwrap().is_none());
2116 assert!(store.get("reused-volume").unwrap().is_some());
2117 assert!(!box_dir.exists());
2118 }
2119
2120 #[tokio::test]
2121 async fn test_cleanup_boot_failure_preserves_persistent_rootfs() {
2122 let tmp = tempfile::tempdir().unwrap();
2123 let box_id = "box-persistent-boot-failure".to_string();
2124 let config = BoxConfig {
2125 persistent: true,
2126 ..BoxConfig::default()
2127 };
2128 let mut vm = VmManager::with_box_id(config, EventEmitter::new(16), box_id.clone());
2129 vm.home_dir = tmp.path().to_path_buf();
2130 vm.set_rootfs_provider(Box::new(crate::rootfs::CopyProvider));
2131
2132 let box_dir = tmp.path().join("boxes").join(&box_id);
2133 let sentinel = box_dir.join("rootfs/var/lib/application/data.db");
2134 std::fs::create_dir_all(sentinel.parent().unwrap()).unwrap();
2135 std::fs::write(&sentinel, b"persistent guest data").unwrap();
2136 std::fs::create_dir_all(vm.socket_dir()).unwrap();
2137 std::fs::write(vm.socket_dir().join("stale.sock"), b"stale").unwrap();
2138 vm.preserve_rootfs_on_boot_failure = true;
2139
2140 vm.cleanup_boot_failure().await;
2141
2142 assert_eq!(
2143 std::fs::read(&sentinel).unwrap(),
2144 b"persistent guest data",
2145 "a failed restart must not erase the persistent writable rootfs"
2146 );
2147 assert!(box_dir.exists());
2148 assert!(
2149 !vm.socket_dir().exists(),
2150 "transient sockets should still be removed after a failed restart"
2151 );
2152 }
2153
2154 #[tokio::test]
2155 async fn test_cleanup_boot_failure_discards_partial_first_persistent_rootfs() {
2156 let tmp = tempfile::tempdir().unwrap();
2157 let box_id = "box-partial-first-boot".to_string();
2158 let config = BoxConfig {
2159 persistent: true,
2160 ..BoxConfig::default()
2161 };
2162 let mut vm = VmManager::with_box_id(config, EventEmitter::new(16), box_id.clone());
2163 vm.home_dir = tmp.path().to_path_buf();
2164 vm.set_rootfs_provider(Box::new(crate::rootfs::CopyProvider));
2165
2166 let box_dir = tmp.path().join("boxes").join(&box_id);
2167 let partial = box_dir.join("rootfs/partially-extracted");
2168 std::fs::create_dir_all(partial.parent().unwrap()).unwrap();
2169 std::fs::write(&partial, b"incomplete").unwrap();
2170
2171 vm.cleanup_boot_failure().await;
2172
2173 assert!(box_dir.exists(), "a retained box keeps its host directory");
2174 assert!(
2175 !box_dir.join("rootfs").exists(),
2176 "a failed first boot must discard its incomplete rootfs generation"
2177 );
2178 }
2179
2180 #[tokio::test]
2181 async fn test_wait_for_vm_running_returns_error_when_handler_exited() {
2182 let vm = VmManager::with_box_id(
2183 BoxConfig::default(),
2184 EventEmitter::new(16),
2185 "box-exited".to_string(),
2186 );
2187 *vm.handler.write().await = Some(Box::new(ExitStateHandler { exited: true }));
2188
2189 let err = vm.wait_for_vm_running().await.unwrap_err();
2190
2191 assert!(err
2192 .to_string()
2193 .contains("VM process exited immediately after start"));
2194 }
2195
2196 #[tokio::test]
2197 async fn test_wait_for_vm_running_succeeds_when_handler_stays_running() {
2198 let config = BoxConfig {
2199 restore_from: Some("snapshot-path".to_string()),
2200 ..BoxConfig::default()
2201 };
2202 let vm = VmManager::with_box_id(config, EventEmitter::new(16), "box-running".to_string());
2203 *vm.handler.write().await = Some(Box::new(ExitStateHandler { exited: false }));
2204
2205 vm.wait_for_vm_running().await.unwrap();
2206 }
2207
2208 #[cfg(not(target_os = "windows"))]
2209 #[tokio::test]
2210 async fn test_try_wait_exit_reads_guest_persisted_exit_code() {
2211 let tmp = tempfile::tempdir().unwrap();
2212 let box_id = "box-exit-code".to_string();
2213 let mut vm =
2214 VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
2215 vm.home_dir = tmp.path().to_path_buf();
2216
2217 let exit_path = tmp
2218 .path()
2219 .join("boxes")
2220 .join(&box_id)
2221 .join("upper")
2222 .join(".a3s_exit_code");
2223 std::fs::create_dir_all(exit_path.parent().unwrap()).unwrap();
2224 std::fs::write(&exit_path, "42\n").unwrap();
2225
2226 assert_eq!(vm.try_wait_exit().await.unwrap(), Some(42));
2227 assert_eq!(vm.exit_code(), Some(42));
2228 assert!(vm.has_exited().await);
2229 }
2230
2231 #[tokio::test]
2232 async fn test_try_wait_exit_reads_windows_rootfs_exit_code() {
2233 let tmp = tempfile::tempdir().unwrap();
2234 let box_id = "box-windows-exit-code".to_string();
2235 let mut vm =
2236 VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
2237 vm.home_dir = tmp.path().to_path_buf();
2238 *vm.handler.write().await = Some(Box::new(CompletedHandler { code: 23 }));
2239
2240 let exit_path = tmp
2241 .path()
2242 .join("boxes")
2243 .join(&box_id)
2244 .join("rootfs")
2245 .join(".a3s_exit_code");
2246 std::fs::create_dir_all(exit_path.parent().unwrap()).unwrap();
2247 std::fs::write(&exit_path, "23\n").unwrap();
2248 #[cfg(target_os = "windows")]
2249 {
2250 std::fs::write(
2251 exit_path.parent().unwrap().join(WINDOWS_GUEST_STDOUT),
2252 "guest stdout\n",
2253 )
2254 .unwrap();
2255 std::fs::write(
2256 exit_path.parent().unwrap().join(WINDOWS_GUEST_STDERR),
2257 concat!(
2258 "init.krun: mount_filesystems ok\n",
2259 "init.krun: execvp(/bin/app) starting\n",
2260 "guest stderr\n",
2261 ),
2262 )
2263 .unwrap();
2264 }
2265
2266 assert_eq!(vm.try_wait_exit().await.unwrap(), Some(23));
2267 assert_eq!(vm.exit_code(), Some(23));
2268 assert!(vm.has_exited().await);
2269 #[cfg(target_os = "windows")]
2270 {
2271 let logs = tmp.path().join("boxes").join(&box_id).join("logs");
2272 assert_eq!(
2273 std::fs::read_to_string(logs.join("console.log")).unwrap(),
2274 "guest stdout\n"
2275 );
2276 assert_eq!(
2277 std::fs::read_to_string(logs.join("console.err.log")).unwrap(),
2278 "guest stderr\n"
2279 );
2280 let json = std::fs::read_to_string(logs.join("container.json")).unwrap();
2281 assert!(json.contains("\"log\":\"guest stdout\\n\""));
2282 assert!(json.contains("\"log\":\"guest stderr\\n\""));
2283 assert!(!json.contains("init.krun"));
2284 }
2285 }
2286
2287 #[cfg(target_os = "windows")]
2288 #[test]
2289 fn test_append_windows_guest_stream_uses_shared_phase_and_keeps_partial_lines() {
2290 let tmp = tempfile::tempdir().unwrap();
2291 let stdout_source = tmp.path().join("stdout.source");
2292 let stderr_source = tmp.path().join("stderr.source");
2293 let stdout_destination = tmp.path().join("stdout.destination");
2294 let stderr_destination = tmp.path().join("stderr.destination");
2295 std::fs::write(
2296 &stdout_source,
2297 concat!(
2298 "init.krun: mount_filesystems ok\n",
2299 "init.krun: business\n",
2300 "init.krun: config parsed",
2301 ),
2302 )
2303 .unwrap();
2304 std::fs::write(
2305 &stderr_source,
2306 concat!(
2307 "init.krun: execvp(/bin/app) starting\n",
2308 "init.krun: mount_filesystems ok\n",
2309 ),
2310 )
2311 .unwrap();
2312
2313 let filter = a3s_box_core::log::RuntimeConsoleFilter::new();
2314 append_windows_guest_stream(&stdout_source, &stdout_destination, &filter).unwrap();
2315 append_windows_guest_stream(&stderr_source, &stderr_destination, &filter).unwrap();
2316
2317 assert_eq!(
2318 std::fs::read_to_string(stdout_destination).unwrap(),
2319 "init.krun: business\ninit.krun: config parsed"
2320 );
2321 assert_eq!(
2322 std::fs::read_to_string(stderr_destination).unwrap(),
2323 "init.krun: mount_filesystems ok\n"
2324 );
2325 }
2326
2327 #[cfg(target_os = "windows")]
2328 #[test]
2329 fn test_collect_windows_guest_result_is_idempotent() {
2330 let tmp = tempfile::tempdir().unwrap();
2331 let box_dir = tmp.path().join("box");
2332 let rootfs = box_dir.join("rootfs");
2333 std::fs::create_dir_all(&rootfs).unwrap();
2334 std::fs::write(rootfs.join(WINDOWS_GUEST_STDOUT), "once\n").unwrap();
2335 std::fs::write(rootfs.join(WINDOWS_GUEST_STDERR), "error once\n").unwrap();
2336 std::fs::write(rootfs.join(WINDOWS_GUEST_EXIT_CODE), "7\n").unwrap();
2337
2338 let config = a3s_box_core::log::LogConfig::default();
2339 assert_eq!(
2340 collect_windows_guest_result(&box_dir, &config, 0).unwrap(),
2341 7
2342 );
2343 assert_eq!(
2344 collect_windows_guest_result(&box_dir, &config, 0).unwrap(),
2345 7
2346 );
2347
2348 let logs = box_dir.join("logs");
2349 assert_eq!(
2350 std::fs::read_to_string(logs.join("console.log")).unwrap(),
2351 "once\n"
2352 );
2353 assert_eq!(
2354 std::fs::read_to_string(logs.join("console.err.log")).unwrap(),
2355 "error once\n"
2356 );
2357 let json = std::fs::read_to_string(logs.join("container.json")).unwrap();
2358 assert_eq!(json.matches("\"log\":\"once\\n\"").count(), 1);
2359 assert_eq!(json.matches("\"log\":\"error once\\n\"").count(), 1);
2360 }
2361
2362 #[cfg(target_os = "windows")]
2363 #[test]
2364 fn test_collect_windows_guest_result_does_not_replay_drained_live_logs() {
2365 let tmp = tempfile::tempdir().unwrap();
2366 let box_dir = tmp.path().join("box");
2367 let rootfs = box_dir.join("rootfs");
2368 let logs = box_dir.join("logs");
2369 std::fs::create_dir_all(&rootfs).unwrap();
2370 std::fs::create_dir_all(&logs).unwrap();
2371 std::fs::write(rootfs.join(WINDOWS_GUEST_STDOUT), "live once\n").unwrap();
2372 std::fs::write(rootfs.join(WINDOWS_GUEST_STDERR), "live error once\n").unwrap();
2373 std::fs::write(rootfs.join(WINDOWS_GUEST_EXIT_CODE), "4\n").unwrap();
2374 std::fs::write(rootfs.join(WINDOWS_LIVE_LOGS_DRAINED_MARKER), "drained\n").unwrap();
2375 let live_json =
2376 "{\"log\":\"live once\\n\",\"stream\":\"stdout\",\"time\":\"2026-01-01T00:00:00Z\"}\n";
2377 std::fs::write(logs.join("container.json"), live_json).unwrap();
2378
2379 let config = a3s_box_core::log::LogConfig::default();
2380 assert_eq!(
2381 collect_windows_guest_result(&box_dir, &config, 0).unwrap(),
2382 4
2383 );
2384
2385 assert_eq!(
2386 std::fs::read_to_string(logs.join("container.json")).unwrap(),
2387 live_json
2388 );
2389 assert_eq!(
2390 std::fs::read_to_string(logs.join("console.log")).unwrap(),
2391 "live once\n"
2392 );
2393 assert_eq!(
2394 std::fs::read_to_string(logs.join("console.err.log")).unwrap(),
2395 "live error once\n"
2396 );
2397 assert!(rootfs.join(WINDOWS_GUEST_RESULT_MARKER).exists());
2398 }
2399
2400 #[cfg(target_os = "windows")]
2401 #[test]
2402 fn test_collect_windows_guest_result_replaces_marker_symlink_without_touching_target() {
2403 let tmp = tempfile::tempdir().unwrap();
2404 let box_dir = tmp.path().join("box");
2405 let rootfs = box_dir.join("rootfs");
2406 std::fs::create_dir_all(&rootfs).unwrap();
2407 std::fs::write(rootfs.join(WINDOWS_GUEST_STDOUT), "safe output\n").unwrap();
2408 std::fs::write(rootfs.join(WINDOWS_GUEST_STDERR), "").unwrap();
2409 std::fs::write(rootfs.join(WINDOWS_GUEST_EXIT_CODE), "0\n").unwrap();
2410
2411 let host_target = tmp.path().join("host-target.txt");
2412 std::fs::write(&host_target, "host secret").unwrap();
2413 let marker = rootfs.join(WINDOWS_GUEST_RESULT_MARKER);
2414 match std::os::windows::fs::symlink_file(&host_target, &marker) {
2415 Ok(()) => {}
2416 Err(error) if error.raw_os_error() == Some(1314) => return,
2417 Err(error) => panic!("failed to create marker symlink: {error}"),
2418 }
2419
2420 let config = a3s_box_core::log::LogConfig::default();
2421 assert_eq!(
2422 collect_windows_guest_result(&box_dir, &config, 0).unwrap(),
2423 0
2424 );
2425 assert_eq!(
2426 std::fs::read_to_string(&host_target).unwrap(),
2427 "host secret"
2428 );
2429 assert_eq!(std::fs::read_to_string(&marker).unwrap(), "collected\n");
2430 assert!(!std::fs::symlink_metadata(marker)
2431 .unwrap()
2432 .file_type()
2433 .is_symlink());
2434 }
2435
2436 #[cfg(target_os = "windows")]
2437 #[test]
2438 fn test_collect_windows_guest_result_refuses_stream_symlink() {
2439 let tmp = tempfile::tempdir().unwrap();
2440 let box_dir = tmp.path().join("box");
2441 let rootfs = box_dir.join("rootfs");
2442 std::fs::create_dir_all(&rootfs).unwrap();
2443 let host_secret = tmp.path().join("host-secret.txt");
2444 std::fs::write(&host_secret, "must not be logged\n").unwrap();
2445 match std::os::windows::fs::symlink_file(&host_secret, rootfs.join(WINDOWS_GUEST_STDOUT)) {
2446 Ok(()) => {}
2447 Err(error) if error.raw_os_error() == Some(1314) => return,
2448 Err(error) => panic!("failed to create stream symlink: {error}"),
2449 }
2450 std::fs::write(rootfs.join(WINDOWS_GUEST_STDERR), "").unwrap();
2451 std::fs::write(rootfs.join(WINDOWS_GUEST_EXIT_CODE), "0\n").unwrap();
2452
2453 let config = a3s_box_core::log::LogConfig::default();
2454 let error = collect_windows_guest_result(&box_dir, &config, 0)
2455 .unwrap_err()
2456 .to_string();
2457
2458 assert!(error.contains("Failed to collect Windows guest output"));
2459 let console = box_dir.join("logs").join("console.log");
2460 assert!(
2461 !console.exists()
2462 || !std::fs::read_to_string(console)
2463 .unwrap()
2464 .contains("must not")
2465 );
2466 }
2467
2468 #[cfg(target_os = "windows")]
2469 #[test]
2470 fn test_collect_windows_guest_result_rejects_false_success() {
2471 let tmp = tempfile::tempdir().unwrap();
2472 let box_dir = tmp.path().join("box");
2473 std::fs::create_dir_all(box_dir.join("rootfs")).unwrap();
2474 let config = a3s_box_core::log::LogConfig::default();
2475
2476 let error = collect_windows_guest_result(&box_dir, &config, 0)
2477 .unwrap_err()
2478 .to_string();
2479 assert!(error.contains("before the guest persisted its exit code"));
2480 assert_eq!(
2481 collect_windows_guest_result(&box_dir, &config, 9).unwrap(),
2482 9
2483 );
2484 }
2485
2486 #[cfg(target_os = "windows")]
2487 #[tokio::test]
2488 async fn test_windows_exit_file_waits_for_shim_log_relay() {
2489 let tmp = tempfile::tempdir().unwrap();
2490 let box_id = "box-windows-pending-relay".to_string();
2491 let mut vm =
2492 VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
2493 vm.home_dir = tmp.path().to_path_buf();
2494 *vm.handler.write().await = Some(Box::new(RecordingHandler {
2495 stopped: Arc::new(AtomicBool::new(false)),
2496 }));
2497
2498 let exit_path = tmp
2499 .path()
2500 .join("boxes")
2501 .join(&box_id)
2502 .join("rootfs")
2503 .join(".a3s_exit_code");
2504 std::fs::create_dir_all(exit_path.parent().unwrap()).unwrap();
2505 std::fs::write(exit_path, "0\n").unwrap();
2506
2507 assert_eq!(vm.try_wait_exit().await.unwrap(), None);
2508 assert_eq!(vm.exit_code(), None);
2509 assert!(!vm.has_exited().await);
2510 }
2511
2512 #[cfg(unix)]
2513 #[tokio::test]
2514 async fn test_wait_for_exec_ready_returns_when_handler_already_exited() {
2515 let mut vm = VmManager::with_box_id(
2516 BoxConfig::default(),
2517 EventEmitter::new(16),
2518 "box-exec-exited".to_string(),
2519 );
2520 *vm.handler.write().await = Some(Box::new(ExitStateHandler { exited: true }));
2521 let tmp = tempfile::tempdir().unwrap();
2522
2523 vm.wait_for_exec_ready(&tmp.path().join("missing-exec.sock"))
2524 .await
2525 .unwrap();
2526
2527 assert!(vm.exec_client.is_none());
2528 }
2529
2530 #[cfg(unix)]
2531 #[tokio::test]
2532 async fn test_wait_for_exec_ready_returns_when_guest_exit_code_persisted() {
2533 let tmp = tempfile::tempdir().unwrap();
2534 let box_id = "box-exec-finished".to_string();
2535 let mut vm =
2536 VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone());
2537 vm.home_dir = tmp.path().to_path_buf();
2538
2539 let exit_path = tmp
2540 .path()
2541 .join("boxes")
2542 .join(&box_id)
2543 .join("upper")
2544 .join(".a3s_exit_code");
2545 std::fs::create_dir_all(exit_path.parent().unwrap()).unwrap();
2546 std::fs::write(&exit_path, "17\n").unwrap();
2547
2548 tokio::time::timeout(
2549 std::time::Duration::from_secs(1),
2550 vm.wait_for_exec_ready(&tmp.path().join("missing-exec.sock")),
2551 )
2552 .await
2553 .unwrap()
2554 .unwrap();
2555
2556 assert_eq!(vm.exit_code(), Some(17));
2557 assert!(vm.exec_client.is_none());
2558 }
2559
2560 #[cfg(unix)]
2561 #[tokio::test]
2562 async fn test_probe_exec_ready_once_ignores_missing_socket() {
2563 let mut vm = VmManager::with_box_id(
2564 BoxConfig::default(),
2565 EventEmitter::new(16),
2566 "box-probe".to_string(),
2567 );
2568 let tmp = tempfile::tempdir().unwrap();
2569
2570 vm.probe_exec_ready_once(&tmp.path().join("missing-exec.sock"))
2571 .await;
2572
2573 assert!(vm.exec_client.is_none());
2574 }
2575
2576 #[cfg(unix)]
2577 #[tokio::test]
2578 async fn test_attach_running_process_infers_port_forward_socket_path() {
2579 let mut vm = VmManager::with_box_id(
2580 BoxConfig::default(),
2581 EventEmitter::new(16),
2582 "box-test".to_string(),
2583 );
2584 let tmp = tempfile::tempdir().unwrap();
2585 let exec_socket_path = tmp.path().join("exec.sock");
2586 let pty_socket_path = Some(tmp.path().join("pty.sock"));
2587
2588 vm.attach_running_process(
2589 std::process::id(),
2590 exec_socket_path.clone(),
2591 pty_socket_path.clone(),
2592 )
2593 .await
2594 .unwrap();
2595
2596 assert_eq!(vm.exec_socket_path(), Some(exec_socket_path.as_path()));
2597 assert_eq!(vm.pty_socket_path(), pty_socket_path.as_deref());
2598 assert_eq!(
2599 vm.port_forward_socket_path(),
2600 Some(exec_socket_path.with_file_name("portfwd.sock").as_path())
2601 );
2602 assert_eq!(vm.state().await, BoxState::Ready);
2603 }
2604}