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    /// Set the logging driver config. Threaded into the InstanceSpec so the shim
174    /// runs the log processor for the box's lifetime.
175    pub fn set_log_config(&mut self, log_config: a3s_box_core::log::LogConfig) {
176        self.log_config = log_config;
177    }
178
179    /// Set whether an image-defined health check is explicitly disabled.
180    pub fn set_healthcheck_disabled(&mut self, disabled: bool) {
181        self.healthcheck_disabled = disabled;
182    }
183
184    /// Get the attached Prometheus metrics (if any).
185    pub fn metrics_prom(&self) -> Option<&crate::prom::RuntimeMetrics> {
186        self.prom.as_ref()
187    }
188
189    /// Get the names of anonymous volumes created during boot.
190    ///
191    /// These are auto-created from OCI VOLUME directives and should be tracked
192    /// for cleanup when the box is removed.
193    pub fn anonymous_volumes(&self) -> &[String] {
194        &self.anonymous_volumes
195    }
196
197    /// Get the OCI image config resolved during boot.
198    pub fn image_config(&self) -> Option<&crate::oci::OciImageConfig> {
199        self.image_config.as_ref()
200    }
201
202    /// Return the immutable execution resolution captured for this boot.
203    pub fn resolved_execution_plan(&self) -> Option<&ResolvedExecutionPlan> {
204        self.resolved_execution_plan.as_ref()
205    }
206
207    /// Get the exit code of the container, if it has exited.
208    ///
209    /// Returns `Some(code)` after `destroy()` has been called and the shim
210    /// process exited naturally (not killed). Returns `None` if the VM has not
211    /// yet stopped or the exit code could not be determined.
212    pub fn exit_code(&self) -> Option<i32> {
213        self.shim_exit_code
214    }
215
216    #[cfg(not(target_os = "windows"))]
217    fn persisted_exit_code(&self) -> Option<i32> {
218        crate::rootfs::read_persisted_exit_code(&self.home_dir.join("boxes").join(&self.box_id))
219    }
220
221    /// Poll the owned VM process for natural exit without sending a signal.
222    ///
223    /// This is used by foreground CLI flows where the container command may
224    /// finish on its own and the CLI should clean up instead of waiting for
225    /// a Ctrl-C.
226    pub async fn try_wait_exit(&mut self) -> Result<Option<i32>> {
227        if let Some(code) = self.shim_exit_code {
228            return Ok(Some(code));
229        }
230
231        #[cfg(not(target_os = "windows"))]
232        let box_dir = self.home_dir.join("boxes").join(&self.box_id);
233
234        let mut handler = self.handler.write().await;
235        let Some(handler) = handler.as_mut() else {
236            // A recovered terminal manager can have no live provider handle.
237            // In that state the durable guest result is the remaining source
238            // of truth and no runtime writer can still append console bytes.
239            #[cfg(not(target_os = "windows"))]
240            if let Some(code) = crate::rootfs::read_persisted_exit_code(&box_dir) {
241                self.shim_exit_code = Some(code);
242            }
243            return Ok(self.shim_exit_code);
244        };
245
246        if let Some(code) = handler.try_wait_exit()? {
247            #[cfg(target_os = "windows")]
248            let code = collect_windows_guest_result(
249                &self.home_dir.join("boxes").join(&self.box_id),
250                &self.log_config,
251                code,
252            )?;
253            #[cfg(not(target_os = "windows"))]
254            let Some(code) = crate::rootfs::resolve_workload_exit_code(&box_dir, Some(code)) else {
255                return Ok(None);
256            };
257            self.shim_exit_code = Some(code);
258            return Ok(Some(code));
259        }
260
261        #[cfg(not(target_os = "windows"))]
262        if handler.has_exited() {
263            // Attached handlers cannot reap another process owner's child, but
264            // zombie-aware provider completion still proves that the shim has
265            // closed the raw streams and joined its log processor. Prefer the
266            // durable workload status over a provider-specific status.
267            if let Some(code) =
268                crate::rootfs::resolve_workload_exit_code(&box_dir, handler.exit_code())
269            {
270                self.shim_exit_code = Some(code);
271                return Ok(Some(code));
272            }
273        }
274
275        Ok(None)
276    }
277
278    /// Return true once the runtime provider has finished its terminal work.
279    ///
280    /// The guest can persist its workload status before the shim has relayed the
281    /// final console bytes. That durable status alone must not publish provider
282    /// completion or foreground cleanup can terminate the shim mid-drain.
283    pub async fn has_exited(&self) -> bool {
284        if self.shim_exit_code.is_some() {
285            return true;
286        }
287
288        let handler = self.handler.read().await;
289        if let Some(handler) = handler.as_ref() {
290            return handler.has_exited();
291        }
292        drop(handler);
293
294        #[cfg(not(target_os = "windows"))]
295        {
296            self.persisted_exit_code().is_some()
297        }
298
299        #[cfg(target_os = "windows")]
300        {
301            false
302        }
303    }
304
305    /// Run a command as the container MAIN in an IDLE-booted (deferred-main) VM.
306    ///
307    /// Sends the `spawn-main` control frame carrying `spec_json` (the command),
308    /// waits for the main to exit (which halts the VM), and returns its real exit
309    /// code + the box's json-file console logs split by stream. This is the full-
310    /// box-semantics counterpart to [`Self::exec_command`] (whose output is piped
311    /// over the exec stream, not the json-file logs).
312    #[cfg(unix)]
313    pub async fn run_deferred_main(
314        &mut self,
315        spec_json: &[u8],
316        timeout: std::time::Duration,
317    ) -> Result<a3s_box_core::exec::ExecOutput> {
318        let log_dir = self.home_dir.join("boxes").join(&self.box_id).join("logs");
319        let console_out_path = log_dir.join("console.log");
320        let console_err_path = a3s_box_core::log::stderr_console_path(&console_out_path);
321        let console_out_start = std::fs::metadata(&console_out_path)
322            .map(|metadata| metadata.len())
323            .unwrap_or(0);
324        let console_err_start = std::fs::metadata(&console_err_path)
325            .map(|metadata| metadata.len())
326            .unwrap_or(0);
327
328        let acked = {
329            let owned_client;
330            let client = if let Some(client) = self.exec_client.as_ref() {
331                client
332            } else {
333                let socket_path = self
334                    .exec_socket_path
335                    .as_deref()
336                    .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?;
337                owned_client = Self::connect_exec_client_for_request(socket_path).await?;
338                &owned_client
339            };
340            client.spawn_main(Some(spec_json)).await?
341        };
342        let exit_wait_timeout = if acked {
343            timeout
344        } else {
345            // Very short deferred mains can exit and halt the VM before the
346            // guest's ACK frame makes it back to the host. Treat a missing ACK as
347            // provisional: if the VM exits promptly, the spawn succeeded and the
348            // real exit code/logs are authoritative; otherwise fail quickly
349            // instead of waiting the full command timeout for an IDLE VM.
350            tracing::debug!(
351                box_id = %self.box_id,
352                "spawn-main was not acknowledged; waiting briefly for main exit"
353            );
354            timeout.min(std::time::Duration::from_secs(2))
355        };
356
357        // Wait for the main to exit — guest-init persists the code and halts the VM.
358        let start = std::time::Instant::now();
359        let exit_code = loop {
360            if let Some(code) = self.try_wait_exit().await? {
361                break code;
362            }
363            if start.elapsed() >= exit_wait_timeout {
364                let message = if acked {
365                    "deferred main did not exit within the timeout"
366                } else {
367                    "spawn-main was not acknowledged by the guest"
368                };
369                return Err(BoxError::ExecError(message.to_string()));
370            }
371            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
372        };
373
374        // Let the shim's log processor finish draining console.log into the json
375        // file (it flushes as the VM halts). A single short "stable length"
376        // sample is not enough here: deferred-main can persist its exit code
377        // before the final stdout/stderr bytes have reached the host tailer,
378        // especially with pre-warmed pools. Require a small quiet window before
379        // reading logs, bounded so no-output commands still return promptly.
380        let json_path = log_dir.join("container.json");
381        let drain_start = std::time::Instant::now();
382        let max_wait = std::time::Duration::from_secs(2);
383        let min_wait = std::time::Duration::from_millis(500);
384        let quiet_window = std::time::Duration::from_millis(200);
385        let mut last_len: Option<u64> = None;
386        let mut last_change = drain_start;
387        loop {
388            let len = std::fs::metadata(&json_path).map(|m| m.len()).unwrap_or(0);
389            if last_len != Some(len) {
390                last_len = Some(len);
391                last_change = std::time::Instant::now();
392            }
393            let elapsed = drain_start.elapsed();
394            if elapsed >= max_wait || (elapsed >= min_wait && last_change.elapsed() >= quiet_window)
395            {
396                break;
397            }
398            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
399        }
400        let (mut stdout, mut stderr) = self.read_container_logs();
401        if stdout.is_empty() {
402            stdout = Self::read_file_from_offset(&console_out_path, console_out_start);
403        }
404        if stderr.is_empty() {
405            stderr = Self::read_file_from_offset(&console_err_path, console_err_start);
406        }
407        let truncated = stdout.len() > a3s_box_core::exec::MAX_OUTPUT_BYTES
408            || stderr.len() > a3s_box_core::exec::MAX_OUTPUT_BYTES;
409        stdout.truncate(a3s_box_core::exec::MAX_OUTPUT_BYTES);
410        stderr.truncate(a3s_box_core::exec::MAX_OUTPUT_BYTES);
411        Ok(a3s_box_core::exec::ExecOutput {
412            stdout,
413            stderr,
414            exit_code,
415            truncated,
416        })
417    }
418
419    #[cfg(unix)]
420    fn read_file_from_offset(path: &Path, offset: u64) -> Vec<u8> {
421        use std::io::{Read, Seek, SeekFrom};
422
423        let mut file = match std::fs::File::open(path) {
424            Ok(file) => file,
425            Err(_) => return vec![],
426        };
427        if file.seek(SeekFrom::Start(offset)).is_err() {
428            return vec![];
429        }
430
431        let mut bytes = Vec::new();
432        if file.read_to_end(&mut bytes).is_err() {
433            return vec![];
434        }
435        bytes
436    }
437
438    /// Read the box's json-file console logs, split into stdout/stderr by stream.
439    #[cfg(unix)]
440    fn read_container_logs(&self) -> (Vec<u8>, Vec<u8>) {
441        let path = self
442            .home_dir
443            .join("boxes")
444            .join(&self.box_id)
445            .join("logs")
446            .join("container.json");
447        let (mut out, mut err) = (Vec::new(), Vec::new());
448        if let Ok(content) = std::fs::read_to_string(&path) {
449            for line in content.lines() {
450                if let Ok(entry) = serde_json::from_str::<a3s_box_core::log::LogEntry>(line) {
451                    if entry.stream == "stderr" {
452                        err.extend_from_slice(entry.log.as_bytes());
453                    } else {
454                        out.extend_from_slice(entry.log.as_bytes());
455                    }
456                }
457            }
458        }
459        (out, err)
460    }
461
462    /// Execute a command in the guest VM.
463    ///
464    /// Requires the VM to be in Ready, Busy, or Compacting state.
465    #[cfg(unix)]
466    #[tracing::instrument(skip(self, request), fields(box_id = %self.box_id))]
467    pub async fn exec_request(
468        &self,
469        request: &a3s_box_core::exec::ExecRequest,
470    ) -> Result<a3s_box_core::exec::ExecOutput> {
471        if request.cmd.is_empty() {
472            return Err(BoxError::ExecError(
473                "Exec request requires a non-empty command".to_string(),
474            ));
475        }
476
477        let state = self.state.read().await;
478        match *state {
479            BoxState::Ready | BoxState::Busy | BoxState::Compacting => {}
480            BoxState::Created => {
481                return Err(BoxError::ExecError("VM not yet booted".to_string()));
482            }
483            BoxState::Stopped => {
484                return Err(BoxError::ExecError("VM is stopped".to_string()));
485            }
486        }
487        drop(state);
488
489        let owned_client;
490        let client = if let Some(client) = self.exec_client.as_ref() {
491            client
492        } else {
493            let socket_path = self
494                .exec_socket_path
495                .as_deref()
496                .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?;
497            owned_client = Self::connect_exec_client_for_request(socket_path).await?;
498            &owned_client
499        };
500
501        let exec_start = std::time::Instant::now();
502        let result = client.exec_command(request).await;
503
504        // Record Prometheus metrics
505        if let Some(ref prom) = self.prom {
506            prom.exec_total.inc();
507            prom.exec_duration
508                .observe(exec_start.elapsed().as_secs_f64());
509            if result.is_err() || result.as_ref().is_ok_and(|o| o.exit_code != 0) {
510                prom.exec_errors_total.inc();
511            }
512        }
513
514        result
515    }
516
517    /// Execute a command in the guest VM.
518    ///
519    /// Requires the VM to be in Ready, Busy, or Compacting state.
520    #[cfg(unix)]
521    #[tracing::instrument(skip(self, cmd), fields(box_id = %self.box_id))]
522    pub async fn exec_command(
523        &self,
524        cmd: Vec<String>,
525        timeout_ns: u64,
526    ) -> Result<a3s_box_core::exec::ExecOutput> {
527        let request = a3s_box_core::exec::ExecRequest {
528            request_id: None,
529            cmd,
530            timeout_ns,
531            env: vec![],
532            working_dir: None,
533            rootfs: None,
534            stdin: None,
535            stdin_streaming: false,
536            user: None,
537            streaming: false,
538        };
539
540        self.exec_request(&request).await
541    }
542}