Skip to main content

a3s_box_runtime/vm/
execution.rs

1//! Guest execution channel, provider completion, and attach operations.
2
3use super::*;
4
5impl VmManager {
6    /// Get the exec client, if connected.
7    #[cfg(unix)]
8    pub fn exec_client(&self) -> Option<&ExecClient> {
9        self.exec_client.as_ref()
10    }
11
12    #[cfg(unix)]
13    async fn connect_exec_client_for_request(socket_path: &Path) -> Result<ExecClient> {
14        const ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
15
16        let client = ExecClient::connect(socket_path).await?;
17        match tokio::time::timeout(ATTEMPT_TIMEOUT, client.heartbeat()).await {
18            Ok(Ok(true)) => Ok(client),
19            Ok(Ok(false)) => Err(BoxError::ExecError(format!(
20                "Exec client not connected: heartbeat failed at {}",
21                socket_path.display()
22            ))),
23            Ok(Err(error)) => Err(error),
24            Err(_) => Err(BoxError::ExecError(format!(
25                "Exec client not connected: heartbeat timed out at {}",
26                socket_path.display()
27            ))),
28        }
29    }
30
31    /// Wait until the guest exec server can complete a heartbeat.
32    ///
33    /// Cold foreground boots may proceed after the short diagnostic readiness
34    /// cap so logs remain visible. A warm pool has a stronger contract: an idle
35    /// VM must actually be executable before it is published to callers.
36    #[cfg(unix)]
37    pub async fn wait_for_exec_available(&mut self, timeout: std::time::Duration) -> Result<()> {
38        let socket_path = self
39            .exec_socket_path
40            .clone()
41            .ok_or_else(|| BoxError::ExecError("Exec socket path is unavailable".to_string()))?;
42        let deadline = tokio::time::Instant::now() + timeout;
43        loop {
44            match Self::connect_exec_client_for_request(&socket_path).await {
45                Ok(client) => {
46                    self.exec_client = Some(client);
47                    return Ok(());
48                }
49                Err(error) if tokio::time::Instant::now() < deadline => {
50                    tracing::debug!(%error, "Waiting for pooled VM exec readiness");
51                    tokio::time::sleep(std::time::Duration::from_millis(200)).await;
52                }
53                Err(error) => return Err(error),
54            }
55        }
56    }
57
58    #[cfg(not(unix))]
59    pub async fn wait_for_exec_available(&mut self, _timeout: std::time::Duration) -> Result<()> {
60        Ok(())
61    }
62
63    /// Attach this manager to an already-running shim process.
64    ///
65    /// This is useful for crash recovery or control-plane restart flows where
66    /// the workload VM is still alive and only the host-side manager state
67    /// needs to be reconstructed.
68    #[cfg(unix)]
69    pub async fn attach_running_process(
70        &mut self,
71        pid: u32,
72        exec_socket_path: PathBuf,
73        pty_socket_path: Option<PathBuf>,
74    ) -> Result<()> {
75        let port_forward_socket_path = exec_socket_path.with_file_name("portfwd.sock");
76        let handler = crate::vmm::ShimHandler::from_pid(pid, self.box_id.clone());
77        if !handler.is_running() {
78            return Err(BoxError::StateError(format!(
79                "Cannot attach to non-running VM process {pid}"
80            )));
81        }
82
83        self.exec_client = match ExecClient::connect(&exec_socket_path).await {
84            Ok(client) => Some(client),
85            Err(error) => {
86                tracing::debug!(
87                    box_id = %self.box_id,
88                    socket_path = %exec_socket_path.display(),
89                    error = %error,
90                    "Failed to reconnect exec client while attaching to running VM"
91                );
92                None
93            }
94        };
95        self.exec_socket_path = Some(exec_socket_path);
96        self.pty_socket_path = pty_socket_path;
97        self.port_forward_socket_path = Some(port_forward_socket_path);
98        *self.handler.write().await = Some(Box::new(handler));
99        *self.state.write().await = BoxState::Ready;
100        Ok(())
101    }
102
103    /// Attach this manager to an already-running Windows shim process.
104    #[cfg(windows)]
105    pub async fn attach_running_process(
106        &mut self,
107        pid: u32,
108        exec_socket_path: PathBuf,
109        pty_socket_path: Option<PathBuf>,
110    ) -> Result<()> {
111        let handler = crate::vmm::ShimHandler::from_pid(pid, self.box_id.clone());
112        if !handler.is_running() {
113            return Err(BoxError::StateError(format!(
114                "Cannot attach to non-running VM process {pid}"
115            )));
116        }
117
118        self.exec_socket_path = Some(exec_socket_path);
119        self.pty_socket_path = pty_socket_path;
120        self.port_forward_socket_path = None;
121        *self.handler.write().await = Some(Box::new(handler));
122        *self.state.write().await = BoxState::Ready;
123        Ok(())
124    }
125
126    /// Get the exec socket path, if the VM has been booted.
127    pub fn exec_socket_path(&self) -> Option<&Path> {
128        self.exec_socket_path.as_deref()
129    }
130
131    /// Get the PTY socket path, if the VM has been booted.
132    pub fn pty_socket_path(&self) -> Option<&Path> {
133        self.pty_socket_path.as_deref()
134    }
135
136    /// Get the CRI port-forward socket path, if the VM has been booted.
137    pub fn port_forward_socket_path(&self) -> Option<&Path> {
138        self.port_forward_socket_path.as_deref()
139    }
140
141    /// Inject a custom VMM provider (e.g., a VmController with a known shim path).
142    ///
143    /// If set before `boot()`, the injected provider is used instead of the
144    /// default `VmController::find_shim()` fallback.
145    pub fn set_provider(&mut self, provider: Box<dyn VmmProvider>) {
146        self.provider = Some(provider);
147    }
148
149    /// Override the rootfs preparation and transport provider.
150    ///
151    /// By default, `default_provider()` auto-detects the best available provider.
152    /// Call this before `boot()` to force a specific provider.
153    pub fn set_rootfs_provider(&mut self, provider: Box<dyn crate::rootfs::RootfsProvider>) {
154        self.rootfs_provider = provider;
155    }
156
157    /// Get the name of the active rootfs provider.
158    pub fn rootfs_provider_name(&self) -> &str {
159        self.rootfs_provider.name()
160    }
161
162    /// Set a progress callback for image pulls: `(current, total, digest, size_bytes)`.
163    /// Called once per layer when `run` pulls an image that is not yet cached.
164    pub fn set_pull_progress_fn(&mut self, f: PullProgressFn) {
165        self.pull_progress_fn = Some(f);
166    }
167
168    /// Attach Prometheus metrics to this VM manager.
169    pub fn set_metrics(&mut self, metrics: crate::prom::RuntimeMetrics) {
170        self.prom = Some(metrics);
171    }
172
173    /// Start a drop-based timer for one stable VM boot phase.
174    ///
175    /// Cloning the optional metrics handle keeps the timer independent from the
176    /// manager borrow, so callers can hold it across asynchronous preparation
177    /// and launch operations. A missing metrics sink is deliberately cheap and
178    /// preserves the runtime's opt-in instrumentation behavior.
179    pub(crate) fn boot_phase_timer(&self, phase: &'static str) -> crate::prom::BootPhaseTimer {
180        crate::prom::BootPhaseTimer::new(self.prom.clone(), phase)
181    }
182
183    /// Set the logging driver config. Threaded into the InstanceSpec so the shim
184    /// runs the log processor for the box's lifetime.
185    pub fn set_log_config(&mut self, log_config: a3s_box_core::log::LogConfig) {
186        self.log_config = log_config;
187    }
188
189    /// Set whether an image-defined health check is explicitly disabled.
190    pub fn set_healthcheck_disabled(&mut self, disabled: bool) {
191        self.healthcheck_disabled = disabled;
192    }
193
194    /// Get the attached Prometheus metrics (if any).
195    pub fn metrics_prom(&self) -> Option<&crate::prom::RuntimeMetrics> {
196        self.prom.as_ref()
197    }
198
199    /// Get the names of anonymous volumes created during boot.
200    ///
201    /// These are auto-created from OCI VOLUME directives and should be tracked
202    /// for cleanup when the box is removed.
203    pub fn anonymous_volumes(&self) -> &[String] {
204        &self.anonymous_volumes
205    }
206
207    /// Get the OCI image config resolved during boot.
208    pub fn image_config(&self) -> Option<&crate::oci::OciImageConfig> {
209        self.image_config.as_ref()
210    }
211
212    /// Return the immutable execution resolution captured for this boot.
213    pub fn resolved_execution_plan(&self) -> Option<&ResolvedExecutionPlan> {
214        self.resolved_execution_plan.as_ref()
215    }
216
217    /// Get the exit code of the container, if it has exited.
218    ///
219    /// Returns `Some(code)` after `destroy()` has been called and the shim
220    /// process exited naturally (not killed). Returns `None` if the VM has not
221    /// yet stopped or the exit code could not be determined.
222    pub fn exit_code(&self) -> Option<i32> {
223        self.shim_exit_code
224    }
225
226    #[cfg(not(target_os = "windows"))]
227    fn persisted_exit_code(&self) -> Option<i32> {
228        crate::rootfs::read_persisted_exit_code(&self.home_dir.join("boxes").join(&self.box_id))
229    }
230
231    /// Poll the owned VM process for natural exit without sending a signal.
232    ///
233    /// This is used by foreground CLI flows where the container command may
234    /// finish on its own and the CLI should clean up instead of waiting for
235    /// a Ctrl-C.
236    pub async fn try_wait_exit(&mut self) -> Result<Option<i32>> {
237        if let Some(code) = self.shim_exit_code {
238            return Ok(Some(code));
239        }
240
241        #[cfg(not(target_os = "windows"))]
242        let box_dir = self.home_dir.join("boxes").join(&self.box_id);
243
244        let mut handler = self.handler.write().await;
245        let Some(handler) = handler.as_mut() else {
246            // A recovered terminal manager can have no live provider handle.
247            // In that state the durable guest result is the remaining source
248            // of truth and no runtime writer can still append console bytes.
249            #[cfg(not(target_os = "windows"))]
250            if let Some(code) = crate::rootfs::read_persisted_exit_code(&box_dir) {
251                self.shim_exit_code = Some(code);
252            }
253            return Ok(self.shim_exit_code);
254        };
255
256        if let Some(code) = handler.try_wait_exit()? {
257            #[cfg(target_os = "windows")]
258            let code = collect_windows_guest_result(
259                &self.home_dir.join("boxes").join(&self.box_id),
260                &self.log_config,
261                code,
262            )?;
263            #[cfg(not(target_os = "windows"))]
264            let Some(code) = crate::rootfs::resolve_workload_exit_code(&box_dir, Some(code)) else {
265                return Ok(None);
266            };
267            self.shim_exit_code = Some(code);
268            return Ok(Some(code));
269        }
270
271        #[cfg(not(target_os = "windows"))]
272        if handler.has_exited() {
273            // Attached handlers cannot reap another process owner's child, but
274            // zombie-aware provider completion still proves that the shim has
275            // closed the raw streams and joined its log processor. Prefer the
276            // durable workload status over a provider-specific status.
277            if let Some(code) =
278                crate::rootfs::resolve_workload_exit_code(&box_dir, handler.exit_code())
279            {
280                self.shim_exit_code = Some(code);
281                return Ok(Some(code));
282            }
283        }
284
285        Ok(None)
286    }
287
288    /// Return true once the runtime provider has finished its terminal work.
289    ///
290    /// The guest can persist its workload status before the shim has relayed the
291    /// final console bytes. That durable status alone must not publish provider
292    /// completion or foreground cleanup can terminate the shim mid-drain.
293    pub async fn has_exited(&self) -> bool {
294        if self.shim_exit_code.is_some() {
295            return true;
296        }
297
298        let handler = self.handler.read().await;
299        if let Some(handler) = handler.as_ref() {
300            return handler.has_exited();
301        }
302        drop(handler);
303
304        #[cfg(not(target_os = "windows"))]
305        {
306            self.persisted_exit_code().is_some()
307        }
308
309        #[cfg(target_os = "windows")]
310        {
311            false
312        }
313    }
314
315    /// Run a command as the container MAIN in an IDLE-booted (deferred-main) VM.
316    ///
317    /// Sends the `spawn-main` control frame carrying `spec_json` (the command),
318    /// waits for the main to exit (which halts the VM), and returns its real exit
319    /// code + the box's json-file console logs split by stream. This is the full-
320    /// box-semantics counterpart to [`Self::exec_command`] (whose output is piped
321    /// over the exec stream, not the json-file logs).
322    #[cfg(unix)]
323    pub async fn run_deferred_main(
324        &mut self,
325        spec_json: &[u8],
326        timeout: std::time::Duration,
327    ) -> Result<a3s_box_core::exec::ExecOutput> {
328        let log_dir = self.home_dir.join("boxes").join(&self.box_id).join("logs");
329        let console_out_path = log_dir.join("console.log");
330        let console_err_path = a3s_box_core::log::stderr_console_path(&console_out_path);
331        let console_out_start = std::fs::metadata(&console_out_path)
332            .map(|metadata| metadata.len())
333            .unwrap_or(0);
334        let console_err_start = std::fs::metadata(&console_err_path)
335            .map(|metadata| metadata.len())
336            .unwrap_or(0);
337
338        let acked = {
339            let owned_client;
340            let client = if let Some(client) = self.exec_client.as_ref() {
341                client
342            } else {
343                let socket_path = self
344                    .exec_socket_path
345                    .as_deref()
346                    .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?;
347                owned_client = Self::connect_exec_client_for_request(socket_path).await?;
348                &owned_client
349            };
350            client.spawn_main(Some(spec_json)).await?
351        };
352        let exit_wait_timeout = if acked {
353            timeout
354        } else {
355            // Very short deferred mains can exit and halt the VM before the
356            // guest's ACK frame makes it back to the host. Treat a missing ACK as
357            // provisional: if the VM exits promptly, the spawn succeeded and the
358            // real exit code/logs are authoritative; otherwise fail quickly
359            // instead of waiting the full command timeout for an IDLE VM.
360            tracing::debug!(
361                box_id = %self.box_id,
362                "spawn-main was not acknowledged; waiting briefly for main exit"
363            );
364            timeout.min(std::time::Duration::from_secs(2))
365        };
366
367        // Wait for the main to exit — guest-init persists the code and halts the VM.
368        let start = std::time::Instant::now();
369        let exit_code = loop {
370            if let Some(code) = self.try_wait_exit().await? {
371                break code;
372            }
373            if start.elapsed() >= exit_wait_timeout {
374                let message = if acked {
375                    "deferred main did not exit within the timeout"
376                } else {
377                    "spawn-main was not acknowledged by the guest"
378                };
379                return Err(BoxError::ExecError(message.to_string()));
380            }
381            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
382        };
383
384        // Let the shim's log processor finish draining console.log into the json
385        // file (it flushes as the VM halts). A single short "stable length"
386        // sample is not enough here: deferred-main can persist its exit code
387        // before the final stdout/stderr bytes have reached the host tailer,
388        // especially with pre-warmed pools. Require a small quiet window before
389        // reading logs, bounded so no-output commands still return promptly.
390        let json_path = log_dir.join("container.json");
391        let drain_start = std::time::Instant::now();
392        let max_wait = std::time::Duration::from_secs(2);
393        let min_wait = std::time::Duration::from_millis(500);
394        let quiet_window = std::time::Duration::from_millis(200);
395        let mut last_len: Option<u64> = None;
396        let mut last_change = drain_start;
397        loop {
398            let len = std::fs::metadata(&json_path).map(|m| m.len()).unwrap_or(0);
399            if last_len != Some(len) {
400                last_len = Some(len);
401                last_change = std::time::Instant::now();
402            }
403            let elapsed = drain_start.elapsed();
404            if elapsed >= max_wait || (elapsed >= min_wait && last_change.elapsed() >= quiet_window)
405            {
406                break;
407            }
408            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
409        }
410        let (mut stdout, mut stderr) = self.read_container_logs();
411        if stdout.is_empty() {
412            stdout = Self::read_file_from_offset(&console_out_path, console_out_start);
413        }
414        if stderr.is_empty() {
415            stderr = Self::read_file_from_offset(&console_err_path, console_err_start);
416        }
417        let truncated = stdout.len() > a3s_box_core::exec::MAX_OUTPUT_BYTES
418            || stderr.len() > a3s_box_core::exec::MAX_OUTPUT_BYTES;
419        stdout.truncate(a3s_box_core::exec::MAX_OUTPUT_BYTES);
420        stderr.truncate(a3s_box_core::exec::MAX_OUTPUT_BYTES);
421        Ok(a3s_box_core::exec::ExecOutput {
422            stdout,
423            stderr,
424            exit_code,
425            truncated,
426        })
427    }
428
429    #[cfg(unix)]
430    fn read_file_from_offset(path: &Path, offset: u64) -> Vec<u8> {
431        use std::io::{Read, Seek, SeekFrom};
432
433        let mut file = match std::fs::File::open(path) {
434            Ok(file) => file,
435            Err(_) => return vec![],
436        };
437        if file.seek(SeekFrom::Start(offset)).is_err() {
438            return vec![];
439        }
440
441        let mut bytes = Vec::new();
442        if file.read_to_end(&mut bytes).is_err() {
443            return vec![];
444        }
445        bytes
446    }
447
448    /// Read the box's json-file console logs, split into stdout/stderr by stream.
449    #[cfg(unix)]
450    fn read_container_logs(&self) -> (Vec<u8>, Vec<u8>) {
451        let path = self
452            .home_dir
453            .join("boxes")
454            .join(&self.box_id)
455            .join("logs")
456            .join("container.json");
457        let (mut out, mut err) = (Vec::new(), Vec::new());
458        if let Ok(content) = std::fs::read_to_string(&path) {
459            for line in content.lines() {
460                if let Ok(entry) = serde_json::from_str::<a3s_box_core::log::LogEntry>(line) {
461                    if entry.stream == "stderr" {
462                        err.extend_from_slice(entry.log.as_bytes());
463                    } else {
464                        out.extend_from_slice(entry.log.as_bytes());
465                    }
466                }
467            }
468        }
469        (out, err)
470    }
471
472    /// Execute a command in the guest VM.
473    ///
474    /// Requires the VM to be in Ready, Busy, or Compacting state.
475    #[cfg(unix)]
476    #[tracing::instrument(skip(self, request), fields(box_id = %self.box_id))]
477    pub async fn exec_request(
478        &self,
479        request: &a3s_box_core::exec::ExecRequest,
480    ) -> Result<a3s_box_core::exec::ExecOutput> {
481        if request.cmd.is_empty() {
482            return Err(BoxError::ExecError(
483                "Exec request requires a non-empty command".to_string(),
484            ));
485        }
486
487        let state = self.state.read().await;
488        match *state {
489            BoxState::Ready | BoxState::Busy | BoxState::Compacting => {}
490            BoxState::Created => {
491                return Err(BoxError::ExecError("VM not yet booted".to_string()));
492            }
493            BoxState::Stopped => {
494                return Err(BoxError::ExecError("VM is stopped".to_string()));
495            }
496        }
497        drop(state);
498
499        let owned_client;
500        let client = if let Some(client) = self.exec_client.as_ref() {
501            client
502        } else {
503            let socket_path = self
504                .exec_socket_path
505                .as_deref()
506                .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?;
507            owned_client = Self::connect_exec_client_for_request(socket_path).await?;
508            &owned_client
509        };
510
511        let exec_start = std::time::Instant::now();
512        let result = client.exec_command(request).await;
513
514        // Record Prometheus metrics
515        if let Some(ref prom) = self.prom {
516            prom.exec_total.inc();
517            prom.exec_duration
518                .observe(exec_start.elapsed().as_secs_f64());
519            if result.is_err() || result.as_ref().is_ok_and(|o| o.exit_code != 0) {
520                prom.exec_errors_total.inc();
521            }
522        }
523
524        result
525    }
526
527    /// Execute a command in the guest VM.
528    ///
529    /// Requires the VM to be in Ready, Busy, or Compacting state.
530    #[cfg(unix)]
531    #[tracing::instrument(skip(self, cmd), fields(box_id = %self.box_id))]
532    pub async fn exec_command(
533        &self,
534        cmd: Vec<String>,
535        timeout_ns: u64,
536    ) -> Result<a3s_box_core::exec::ExecOutput> {
537        let request = a3s_box_core::exec::ExecRequest {
538            request_id: None,
539            cmd,
540            timeout_ns,
541            env: vec![],
542            working_dir: None,
543            rootfs: None,
544            stdin: None,
545            stdin_streaming: false,
546            user: None,
547            streaming: false,
548        };
549
550        self.exec_request(&request).await
551    }
552}