Skip to main content

a3s_box_runtime/vmm/
controller.rs

1//! VmController - Default VMM backend using shim subprocesses.
2
3use std::path::PathBuf;
4use std::process::{Command, Stdio};
5
6use a3s_box_core::error::{BoxError, Result};
7use async_trait::async_trait;
8
9use super::handler::ShimHandler;
10use super::provider::VmmProvider;
11use super::spec::InstanceSpec;
12use super::VmHandler;
13
14/// Controller for spawning VM subprocesses.
15///
16/// Spawns the `a3s-box-shim` binary in a subprocess and returns a ShimHandler
17/// for runtime operations. The subprocess isolation ensures that VM process
18/// takeover doesn't affect the host application.
19pub struct VmController {
20    /// Path to the a3s-box-shim binary
21    shim_path: PathBuf,
22}
23
24impl VmController {
25    fn configure_shim_stdio(&self, cmd: &mut Command, spec: &InstanceSpec) {
26        use std::fs::OpenOptions;
27
28        let Some(console_output) = spec.console_output.as_ref() else {
29            cmd.stdout(Stdio::null()).stderr(Stdio::null());
30            return;
31        };
32        let Some(log_dir) = console_output.parent() else {
33            cmd.stdout(Stdio::null()).stderr(Stdio::null());
34            return;
35        };
36        if let Err(error) = std::fs::create_dir_all(log_dir) {
37            tracing::warn!(
38                box_id = %spec.box_id,
39                path = %log_dir.display(),
40                error = %error,
41                "Failed to create shim log directory"
42            );
43            cmd.stdout(Stdio::null()).stderr(Stdio::null());
44            return;
45        }
46
47        let stdout_path = log_dir.join("shim.stdout.log");
48        let stderr_path = log_dir.join("shim.stderr.log");
49
50        let stdout_file = OpenOptions::new()
51            .create(true)
52            .truncate(true)
53            .write(true)
54            .open(&stdout_path);
55        let stderr_file = OpenOptions::new()
56            .create(true)
57            .truncate(true)
58            .write(true)
59            .open(&stderr_path);
60
61        match (stdout_file, stderr_file) {
62            (Ok(stdout_file), Ok(stderr_file)) => {
63                tracing::debug!(
64                    box_id = %spec.box_id,
65                    stdout = %stdout_path.display(),
66                    stderr = %stderr_path.display(),
67                    "Redirecting shim stdio to per-box files"
68                );
69                cmd.stdout(Stdio::from(stdout_file))
70                    .stderr(Stdio::from(stderr_file));
71            }
72            (stdout_result, stderr_result) => {
73                if let Err(error) = stdout_result {
74                    tracing::warn!(
75                        box_id = %spec.box_id,
76                        path = %stdout_path.display(),
77                        error = %error,
78                        "Failed to open shim stdout log file"
79                    );
80                }
81                if let Err(error) = stderr_result {
82                    tracing::warn!(
83                        box_id = %spec.box_id,
84                        path = %stderr_path.display(),
85                        error = %error,
86                        "Failed to open shim stderr log file"
87                    );
88                }
89                cmd.stdout(Stdio::null()).stderr(Stdio::null());
90            }
91        }
92    }
93
94    /// Create a new VmController.
95    ///
96    /// # Arguments
97    /// * `shim_path` - Path to the a3s-box-shim binary
98    ///
99    /// # Returns
100    /// * `Ok(VmController)` - Successfully created controller
101    /// * `Err(...)` - Failed to create controller (e.g., binary not found)
102    pub fn new(shim_path: PathBuf) -> Result<Self> {
103        // Verify that the shim binary exists
104        if !shim_path.exists() {
105            return Err(BoxError::BoxBootError {
106                message: format!("Shim binary not found: {}", shim_path.display()),
107                hint: Some("Build the shim with: cargo build -p a3s-box-shim".to_string()),
108            });
109        }
110
111        // On macOS, ensure the shim has the Hypervisor.framework entitlement
112        #[cfg(target_os = "macos")]
113        Self::ensure_entitlement(&shim_path)?;
114
115        Ok(Self { shim_path })
116    }
117
118    /// Ensure the shim binary has the com.apple.security.hypervisor entitlement.
119    ///
120    /// On macOS, Hypervisor.framework requires this entitlement. If the binary
121    /// was built with `cargo build` directly (without `just build`), it won't
122    /// have the entitlement. This method checks and signs it if needed.
123    ///
124    /// Uses a file lock to prevent race conditions when multiple processes
125    /// (e.g., concurrent tests) try to sign the same binary simultaneously.
126    #[cfg(target_os = "macos")]
127    fn ensure_entitlement(shim_path: &std::path::Path) -> Result<()> {
128        use std::fs::File;
129
130        // Fast path: check without lock first
131        if Self::has_hypervisor_entitlement(shim_path)? {
132            return Ok(());
133        }
134
135        // Acquire exclusive file lock to prevent concurrent codesign
136        let lock_path = std::env::temp_dir().join("a3s-box-shim-codesign.lock");
137        let lock_file = File::create(&lock_path).map_err(|e| BoxError::BoxBootError {
138            message: format!("Failed to create codesign lock file: {}", e),
139            hint: None,
140        })?;
141
142        // flock(LOCK_EX) — blocks until exclusive lock is acquired
143        let fd = std::os::unix::io::AsRawFd::as_raw_fd(&lock_file);
144        let ret = unsafe { libc::flock(fd, libc::LOCK_EX) };
145        if ret != 0 {
146            return Err(BoxError::BoxBootError {
147                message: format!(
148                    "Failed to acquire codesign lock: {}",
149                    std::io::Error::last_os_error()
150                ),
151                hint: None,
152            });
153        }
154
155        // Re-check after acquiring lock — another process may have signed it
156        if Self::has_hypervisor_entitlement(shim_path)? {
157            // Lock is released when lock_file is dropped
158            return Ok(());
159        }
160
161        tracing::info!("Signing shim with Hypervisor.framework entitlement");
162
163        let entitlements_path = Self::find_entitlements_plist(shim_path)?;
164
165        let status = Command::new("codesign")
166            .args(["--entitlements"])
167            .arg(&entitlements_path)
168            .args(["--force", "-s", "-"])
169            .arg(shim_path)
170            .status()
171            .map_err(|e| BoxError::BoxBootError {
172                message: format!("Failed to codesign shim: {}", e),
173                hint: None,
174            })?;
175
176        if !status.success() {
177            return Err(BoxError::BoxBootError {
178                message: "Failed to sign shim with Hypervisor entitlement".to_string(),
179                hint: Some(format!(
180                    "Try manually: codesign --entitlements {} --force -s - {}",
181                    entitlements_path.display(),
182                    shim_path.display()
183                )),
184            });
185        }
186
187        // Lock is released when lock_file is dropped
188        Ok(())
189    }
190
191    /// Check if the shim binary already has the Hypervisor entitlement.
192    #[cfg(target_os = "macos")]
193    fn has_hypervisor_entitlement(shim_path: &std::path::Path) -> Result<bool> {
194        let output = Command::new("codesign")
195            .args(["-d", "--entitlements", "-", "--xml"])
196            .arg(shim_path)
197            .output()
198            .map_err(|e| BoxError::BoxBootError {
199                message: format!("Failed to check entitlements: {}", e),
200                hint: None,
201            })?;
202
203        let stdout = String::from_utf8_lossy(&output.stdout);
204        Ok(stdout.contains("com.apple.security.hypervisor"))
205    }
206
207    /// Find the entitlements.plist file.
208    #[cfg(target_os = "macos")]
209    fn find_entitlements_plist(shim_path: &std::path::Path) -> Result<PathBuf> {
210        // Try next to the shim binary
211        if let Some(dir) = shim_path.parent() {
212            let plist = dir.join("entitlements.plist");
213            if plist.exists() {
214                return Ok(plist);
215            }
216        }
217
218        // Try the source tree relative to the shim binary
219        // target/debug/a3s-box-shim -> ../../shim/entitlements.plist
220        if let Some(dir) = shim_path.parent() {
221            for ancestor in dir.ancestors().take(5) {
222                let plist = ancestor.join("shim").join("entitlements.plist");
223                if plist.exists() {
224                    return Ok(plist);
225                }
226            }
227        }
228
229        // Generate a temporary entitlements plist as fallback
230        let tmp_plist = std::env::temp_dir().join("a3s-box-entitlements.plist");
231        std::fs::write(
232            &tmp_plist,
233            r#"<?xml version="1.0" encoding="UTF-8"?>
234<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
235<plist version="1.0">
236<dict>
237    <key>com.apple.security.hypervisor</key>
238    <true/>
239</dict>
240</plist>
241"#,
242        )
243        .map_err(|e| BoxError::BoxBootError {
244            message: format!("Failed to write temporary entitlements plist: {}", e),
245            hint: None,
246        })?;
247
248        Ok(tmp_plist)
249    }
250
251    /// Find the shim binary in common locations.
252    ///
253    /// Searches in order:
254    /// 1. Same directory as current executable
255    /// 2. `~/.a3s/bin/` (SDK-extracted shim)
256    /// 3. target/debug or target/release (for development)
257    /// 4. PATH
258    pub fn find_shim() -> Result<PathBuf> {
259        // On Windows the binary has a .exe suffix; on other platforms it's empty.
260        #[cfg(target_os = "windows")]
261        let shim_name = "a3s-box-shim.exe";
262        #[cfg(not(target_os = "windows"))]
263        let shim_name = "a3s-box-shim";
264
265        // Try same directory as current executable
266        if let Ok(exe_path) = std::env::current_exe() {
267            if let Some(exe_dir) = exe_path.parent() {
268                let shim_path = exe_dir.join(shim_name);
269                if shim_path.exists() {
270                    return Ok(shim_path);
271                }
272            }
273        }
274
275        // Try ~/.a3s/bin/ (SDK-extracted shim)
276        {
277            let shim_path = a3s_box_core::dirs_home().join("bin").join(shim_name);
278            if shim_path.exists() {
279                return Ok(shim_path);
280            }
281        }
282
283        // Try target directories (for development)
284        let target_dirs = ["target/debug", "target/release"];
285        for dir in target_dirs {
286            let shim_path = PathBuf::from(dir).join(shim_name);
287            if shim_path.exists() {
288                return Ok(shim_path);
289            }
290        }
291
292        // Try PATH — use `where` on Windows, `which` elsewhere
293        #[cfg(target_os = "windows")]
294        let which_cmd = "where";
295        #[cfg(not(target_os = "windows"))]
296        let which_cmd = "which";
297
298        if let Ok(output) = Command::new(which_cmd).arg(shim_name).output() {
299            if output.status.success() {
300                let path = String::from_utf8_lossy(&output.stdout)
301                    .lines()
302                    .next()
303                    .unwrap_or("")
304                    .trim()
305                    .to_string();
306                if !path.is_empty() {
307                    return Ok(PathBuf::from(path));
308                }
309            }
310        }
311
312        Err(BoxError::BoxBootError {
313            message: "Could not find a3s-box-shim binary".to_string(),
314            hint: Some("Build the shim with: cargo build -p a3s-box-shim".to_string()),
315        })
316    }
317
318    #[cfg(target_os = "windows")]
319    fn windows_shim_path_env(shim_path: &std::path::Path) -> Option<std::ffi::OsString> {
320        use std::collections::HashSet;
321
322        let mut dirs = Vec::<PathBuf>::new();
323        if let Ok(dir) = std::env::var("LIBKRUN_DIR") {
324            dirs.push(PathBuf::from(dir));
325        }
326        if let Some(dir) = option_env!("LIBKRUN_DIR") {
327            dirs.push(PathBuf::from(dir));
328        }
329        if let Some(dir) = shim_path.parent() {
330            dirs.push(dir.to_path_buf());
331            dirs.push(dir.join("lib"));
332        }
333
334        let mut seen = HashSet::new();
335        let mut path_entries = Vec::new();
336        for dir in dirs {
337            if !seen.insert(dir.clone()) {
338                continue;
339            }
340            if dir.join("krun.dll").exists() {
341                path_entries.push(dir);
342            }
343        }
344
345        if path_entries.is_empty() {
346            return None;
347        }
348
349        let mut merged = std::ffi::OsString::new();
350        for entry in path_entries {
351            if !merged.is_empty() {
352                merged.push(";");
353            }
354            merged.push(entry);
355        }
356        if let Some(existing) = std::env::var_os("PATH") {
357            if !merged.is_empty() {
358                merged.push(";");
359            }
360            merged.push(existing);
361        }
362        Some(merged)
363    }
364}
365
366#[async_trait]
367impl VmmProvider for VmController {
368    async fn start(&self, spec: &InstanceSpec) -> Result<Box<dyn VmHandler>> {
369        tracing::debug!(
370            box_id = %spec.box_id,
371            vcpus = spec.vcpus,
372            memory_mib = spec.memory_mib,
373            "Starting VM subprocess"
374        );
375
376        // Serialize the config for passing to subprocess
377        let config_json = serde_json::to_string(spec).map_err(|e| BoxError::BoxBootError {
378            message: format!("Failed to serialize config: {}", e),
379            hint: None,
380        })?;
381
382        tracing::trace!(config = %config_json, "VM configuration");
383
384        // Ensure socket directory exists
385        if let Some(socket_dir) = spec.exec_socket_path.parent() {
386            std::fs::create_dir_all(socket_dir).map_err(|e| BoxError::BoxBootError {
387                message: format!(
388                    "Failed to create socket directory {}: {}",
389                    socket_dir.display(),
390                    e
391                ),
392                hint: None,
393            })?;
394        }
395
396        // Spawn shim subprocess
397        #[cfg(target_os = "macos")]
398        tracing::info!(
399            shim = %self.shim_path.display(),
400            box_id = %spec.box_id,
401            net_socket_fd = spec.network.as_ref().and_then(|net| net.net_socket_fd),
402            net_proxy_fd = spec.network.as_ref().and_then(|net| net.net_proxy_fd),
403            "Spawning shim subprocess"
404        );
405        #[cfg(not(target_os = "macos"))]
406        tracing::info!(
407            shim = %self.shim_path.display(),
408            box_id = %spec.box_id,
409            "Spawning shim subprocess"
410        );
411
412        let mut cmd = Command::new(&self.shim_path);
413        cmd.arg("--config").arg(&config_json).stdin(Stdio::null());
414        self.configure_shim_stdio(&mut cmd, spec);
415
416        // KSM page-merging: the shim opts its (guest) memory in via prctl when this
417        // env is set; driven by InstanceSpec.ksm (BoxConfig.ksm or A3S_BOX_KSM).
418        if spec.ksm {
419            cmd.env("A3S_BOX_KSM", "1");
420        }
421
422        // Snapshot-fork: set the file-backed-RAM / snapshot-trigger / restore paths
423        // for the shim/libkrun. PER-VM values from the InstanceSpec take precedence —
424        // this is what lets ONE process (the pool / fork daemon) drive a different
425        // template/restore per VM, which a process-global env cannot. Fall back to the
426        // process env only when the spec doesn't set a given var (single-VM `run`).
427        let snap_env: [(&str, Option<&str>); 3] = [
428            ("KRUN_SNAPSHOT_MEM_FILE", spec.snapshot_mem_file.as_deref()),
429            ("KRUN_SNAPSHOT_SOCK", spec.snapshot_sock.as_deref()),
430            ("KRUN_RESTORE_FROM", spec.restore_from.as_deref()),
431        ];
432        for (var, spec_val) in snap_env {
433            match spec_val {
434                Some(val) if !val.is_empty() => {
435                    cmd.env(var, val);
436                }
437                _ => {
438                    if let Ok(val) = std::env::var(var) {
439                        if !val.is_empty() {
440                            cmd.env(var, val);
441                        }
442                    }
443                }
444            }
445        }
446
447        // On macOS, set DYLD_LIBRARY_PATH to help find libkrunfw
448        #[cfg(target_os = "macos")]
449        {
450            let mut dylib_paths = Vec::new();
451            let bundled_lib_dir = self
452                .shim_path
453                .parent()
454                .and_then(|dir| dir.parent())
455                .map(|dir| dir.join("lib"));
456            if let Some(path) = bundled_lib_dir.filter(|path| path.exists()) {
457                dylib_paths.push(path);
458            }
459            let home_lib_dir = a3s_box_core::dirs_home().join("lib");
460            if home_lib_dir.exists() {
461                dylib_paths.push(home_lib_dir);
462            }
463            if let Some(existing) = std::env::var_os("DYLD_LIBRARY_PATH") {
464                dylib_paths.extend(std::env::split_paths(&existing));
465            } else {
466                dylib_paths.push(std::path::PathBuf::from("/opt/homebrew/lib"));
467            }
468            if let Ok(joined) = std::env::join_paths(dylib_paths) {
469                cmd.env("DYLD_LIBRARY_PATH", joined);
470            }
471        }
472
473        #[cfg(target_os = "windows")]
474        if let Some(path) = Self::windows_shim_path_env(&self.shim_path) {
475            cmd.env("PATH", path);
476        }
477
478        let child = cmd.spawn().map_err(|e| BoxError::BoxBootError {
479            message: format!("Failed to spawn shim: {}", e),
480            hint: Some(format!("Shim path: {}", self.shim_path.display())),
481        })?;
482
483        let pid = child.id();
484        tracing::info!(
485            box_id = %spec.box_id,
486            pid = pid,
487            "Shim subprocess spawned"
488        );
489
490        // Create handler for the running VM
491        let handler = ShimHandler::from_child(child, spec.box_id.clone());
492
493        Ok(Box::new(handler))
494    }
495}