diff --git a/.github/workflows/microvm-e2e.yml b/.github/workflows/microvm-e2e.yml index 914ea458e..84d8a7838 100644 --- a/.github/workflows/microvm-e2e.yml +++ b/.github/workflows/microvm-e2e.yml @@ -47,13 +47,22 @@ jobs: shell: pwsh run: | $binDir = Join-Path $env:GITHUB_WORKSPACE "src\target\x86_64-pc-windows-msvc\debug" - $required = @("wxc-exec.exe", "nanvixd.exe", "kernel.elf", "python3.12", "nanvix_rootfs.img") + $required = @( + "wxc-exec.exe", + "nanvixd.exe", + "nanvix_rootfs.img", + "python3.initrd", + "bin\kernel.elf", + "snapshots\kernel.vmem", + "snapshots\kernel.whp.cbor" + ) $missing = $required | Where-Object { -not (Test-Path (Join-Path $binDir $_)) } if ($missing) { Write-Host "::error::Missing binaries: $($missing -join ', ')" exit 1 } - Get-ChildItem $binDir -Include $required -Recurse | Format-Table Name, Length + $leaves = $required | ForEach-Object { Split-Path $_ -Leaf } + Get-ChildItem $binDir -Include $leaves -Recurse | Format-Table FullName, Length - name: Diagnose hypervisor environment shell: pwsh diff --git a/src/Cargo.lock b/src/Cargo.lock index a62da426d..a0034f9e9 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -2764,6 +2764,7 @@ dependencies = [ "getrandom 0.2.17", "hyperlight-unikraft-host", "isolation_session_bindings", + "nanvix_common", "sandbox_spec", "semver", "serde", diff --git a/src/nanvix_binaries/build.rs b/src/nanvix_binaries/build.rs index b5140788b..bb205f521 100644 --- a/src/nanvix_binaries/build.rs +++ b/src/nanvix_binaries/build.rs @@ -72,6 +72,22 @@ fn main() { } verify_checksums(&all_binaries, &bin_dir, &checksums); + verify_bin_subdir_checksums(&bin_dir, &checksums); + + // Generate host-local WHP snapshots at build time so even the first + // runtime execution uses warm start. The runtime fallback in + // nanvix_runner.rs handles the case where snapshots are missing. + let snapshots_dir = bin_dir.join(nanvix_common::SNAPSHOTS_SUBDIR); + let snapshots_present = nanvix_common::SNAPSHOT_FILES + .iter() + .all(|name| snapshots_dir.join(name).exists()); + if !snapshots_present { + fs::create_dir_all(&snapshots_dir).expect("failed to create snapshots dir"); + eprintln!("nanvix_binaries: generating host-local snapshots (cold boot)..."); + generate_snapshots_locally(&bin_dir); + } else { + eprintln!("nanvix_binaries: host-local snapshots already present"); + } println!("cargo:rustc-env=NANVIX_BIN_DIR={}", bin_dir.display()); println!("cargo:BIN_DIR={}", bin_dir.display()); @@ -89,7 +105,8 @@ fn needs_download( bin_dir: &Path, checksums: &HashMap, ) -> bool { - config.binaries.iter().any(|name| { + // Check flat binaries. + let flat_missing = config.binaries.iter().any(|name| { let path = bin_dir.join(name); if !path.exists() { return true; @@ -99,26 +116,40 @@ fn needs_download( } else { false } - }) + }); + if flat_missing { + return true; + } + + // Check bin/ subdir files. + let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR); + for name in nanvix_common::BIN_SUBDIR_FILES { + let path = bin_subdir.join(name); + if !path.exists() { + return true; + } + if let Some(expected) = checksums.get(*name) { + if certutil_sha256(&path) != *expected { + return true; + } + } + } + + false } fn download_and_extract(config: &RepoConfig, repo: &str, bin_dir: &Path) { let url = github_download_url(repo, &config.tag, &config.asset); let zip_path = bin_dir.join(&config.asset); - let binary_paths: Vec = config.binaries.iter().map(|b| bin_dir.join(b)).collect(); - // Cleanup helper: remove zip + any partially extracted binaries. - // Called before panicking so the filesystem isn't left in a dangling state. - let cleanup = |bin_dir_paths: &[PathBuf], zip: &Path| { + // Cleanup helper: remove zip on failure. + let cleanup = |zip: &Path| { let _ = fs::remove_file(zip); - for p in bin_dir_paths { - let _ = fs::remove_file(p); - } }; eprintln!(" downloading {}...", config.asset); if let Err(msg) = try_curl_download(&url, &zip_path) { - cleanup(&binary_paths, &zip_path); + cleanup(&zip_path); panic!("nanvix_binaries: {}", msg); } @@ -126,14 +157,56 @@ fn download_and_extract(config: &RepoConfig, repo: &str, bin_dir: &Path) { eprintln!(" downloaded {} bytes, extracting...", size); let binaries: Vec<&str> = config.binaries.iter().map(|s| s.as_str()).collect(); + + // Extract flat binaries (nanvixd.exe from bin/, rootfs + initrd from root). if let Err(msg) = try_tar_extract(&zip_path, bin_dir, &binaries) { - cleanup(&binary_paths, &zip_path); + cleanup(&zip_path); + panic!("nanvix_binaries: {}", msg); + } + + // Extract bin/ subdir files (kernel.elf stays in bin/ as nanvixd expects). + let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR); + fs::create_dir_all(&bin_subdir).expect("failed to create bin subdir"); + if let Err(msg) = try_tar_extract_bin_subdir(&zip_path, &bin_subdir) { + cleanup(&zip_path); panic!("nanvix_binaries: {}", msg); } let _ = fs::remove_file(&zip_path); } +// -- Snapshot generation ----------------------------------------------------- + +fn generate_snapshots_locally(bin_dir: &Path) { + let nanvixd = bin_dir.join("nanvixd.exe"); + let ramfs = bin_dir.join("nanvix_rootfs.img"); + let initrd = bin_dir.join("python3.initrd"); + let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR); + + if !nanvixd.exists() || !ramfs.exists() || !initrd.exists() { + panic!( + "nanvix_binaries: cannot generate snapshots — required binaries missing:\n\ + \x20 nanvixd.exe: {}\n\ + \x20 nanvix_rootfs.img: {}\n\ + \x20 python3.initrd: {}", + nanvixd.exists(), + ramfs.exists(), + initrd.exists() + ); + } + + nanvix_common::generate_snapshot(bin_dir, &nanvixd, &bin_subdir, &ramfs, &initrd) + .unwrap_or_else(|e| panic!("nanvix_binaries: {}", e)); + + // Log generated file sizes. + let snapshots_dir = bin_dir.join(nanvix_common::SNAPSHOTS_SUBDIR); + for name in nanvix_common::SNAPSHOT_FILES { + let path = snapshots_dir.join(name); + let size = path.metadata().map(|m| m.len()).unwrap_or(0); + eprintln!(" snapshots/{} -- generated ({} bytes)", name, size); + } +} + // -- curl.exe ---------------------------------------------------------------- fn try_curl_download(url: &str, dest: &Path) -> Result<(), String> { @@ -184,12 +257,11 @@ fn try_curl_download(url: &str, dest: &Path) -> Result<(), String> { fn try_tar_extract(zip_path: &Path, dest_dir: &Path, files: &[&str]) -> Result<(), String> { // The nanvix-python zip has a top-level directory with two sub-layouts: - // bin/nanvixd.exe, bin/kernel.elf, bin/python3.12 → strip 2 components - // nanvix_rootfs.img → strip 1 component - // We run two passes to handle both depths. + // bin/nanvixd.exe → strip 2 components + // nanvix_rootfs.img, python3.initrd → strip 1 component const ARCHIVE_PREFIX: &str = "microvm-standalone-256mb"; - const BIN_DIR_FILES: &[&str] = &["nanvixd.exe", "kernel.elf", "python3.12"]; + const BIN_DIR_FILES: &[&str] = &["nanvixd.exe"]; let (bin_files, root_files): (Vec<&&str>, Vec<&&str>) = files.iter().partition(|f| BIN_DIR_FILES.contains(f)); @@ -255,6 +327,37 @@ fn try_tar_extract(zip_path: &Path, dest_dir: &Path, files: &[&str]) -> Result<( Ok(()) } +fn try_tar_extract_bin_subdir(zip_path: &Path, dest_dir: &Path) -> Result<(), String> { + const ARCHIVE_PREFIX: &str = "microvm-standalone-256mb"; + + for name in nanvix_common::BIN_SUBDIR_FILES { + let mut cmd = Command::new("tar"); + cmd.arg("-xf").arg(zip_path).arg("-C").arg(dest_dir); + cmd.args(["--strip-components", "2"]); + cmd.arg(format!("{}/bin/{}", ARCHIVE_PREFIX, name)); + let output = cmd + .output() + .map_err(|e| format!("tar.exe not found: {}", e))?; + if !output.status.success() { + return Err(format!( + "tar extraction failed (bin/{})\n exit code: {}\n stderr: {}", + name, + output.status, + String::from_utf8_lossy(&output.stderr) + )); + } + let path = dest_dir.join(name); + if path.exists() { + let size = path.metadata().map(|m| m.len()).unwrap_or(0); + eprintln!(" bin/{} -- extracted ({} bytes)", name, size); + } else { + return Err(format!("'bin/{}' not found in zip after extraction", name)); + } + } + + Ok(()) +} + // -- Helpers ----------------------------------------------------------------- fn github_token() -> Option { @@ -326,3 +429,36 @@ fn verify_checksums(binaries: &[&str], bin_dir: &Path, checksums: &HashMap) { + let bin_subdir = bin_dir.join(nanvix_common::BIN_SUBDIR); + for name in nanvix_common::BIN_SUBDIR_FILES { + let path = bin_subdir.join(name); + if !path.exists() { + panic!( + "nanvix_binaries: bin/{} not found after download/extract", + name + ); + } + + if let Some(expected) = checksums.get(*name) { + let actual = certutil_sha256(&path); + if actual != *expected { + panic!( + "nanvix_binaries: SHA256 mismatch for 'bin/{}'!\n\ + \x20 expected: {}\n\ + \x20 actual: {}\n\ + Update checksums.json with the new hashes.", + name, expected, actual + ); + } + eprintln!(" bin/{} -- checksum OK", name); + } else { + panic!( + "nanvix_binaries: 'bin/{}' has no entry in checksums.json — \ + every binary must be hash-verified", + name + ); + } + } +} diff --git a/src/nanvix_binaries/checksums.json b/src/nanvix_binaries/checksums.json index 3395cd839..ae9c425ab 100644 --- a/src/nanvix_binaries/checksums.json +++ b/src/nanvix_binaries/checksums.json @@ -1,6 +1,6 @@ { - "nanvixd.exe": "375f21ea2cf148c0fc761779237c88921742890979b3389c6aa45409a4dd9c0c", - "kernel.elf": "034ce96131b727d65f728b98e617257ebb514d3318b599ab139ce7901c7cd4ba", - "python3.12": "46516dd7437c4e63470c3889bfb61cc3c195c792d697d5e137697620abe2af15", - "nanvix_rootfs.img": "7015e5280d1490958d13f8eff7ebe8ff6cf9e197167d23518985284a1d143309" + "nanvixd.exe": "9172542c250c7cc3eb1bba00fb027543f2a309e5b0152f5f9dee9e6484ed21ec", + "nanvix_rootfs.img": "0d45a1f0a29e0d6fc9f297016c02a798738d5288acacb46e770eb9103e1ca0b9", + "python3.initrd": "9e20583c70b2b6feb43ccbd477657d78f5cd1a061e962bacb7b13079fbdede92", + "kernel.elf": "4e75f32192b63c034846f7f802e3e3cfe7d6df57b3a0c8d6bd5927fc4ead0b2d" } diff --git a/src/nanvix_binaries/versions.json b/src/nanvix_binaries/versions.json index aaba28c06..f99dc632d 100644 --- a/src/nanvix_binaries/versions.json +++ b/src/nanvix_binaries/versions.json @@ -1,7 +1,11 @@ { "nanvix_python": { - "tag": "3.12.3-nanvix-0.12.529-ee6cfe1", + "tag": "3.12.3-nanvix-0.15.4-ac76d40", "asset": "microvm-standalone-256mb.zip", - "binaries": ["nanvixd.exe", "kernel.elf", "python3.12", "nanvix_rootfs.img"] + "binaries": [ + "nanvixd.exe", + "nanvix_rootfs.img", + "python3.initrd" + ] } } diff --git a/src/nanvix_common/src/lib.rs b/src/nanvix_common/src/lib.rs index 320f5c8d0..c879fefd5 100644 --- a/src/nanvix_common/src/lib.rs +++ b/src/nanvix_common/src/lib.rs @@ -12,17 +12,84 @@ use std::path::Path; use serde::Deserialize; -/// All required NanVix binary filenames. -pub const REQUIRED_BINARIES: &[&str] = &[ - "nanvixd.exe", - "kernel.elf", - "python3.12", - "nanvix_rootfs.img", -]; +/// All required NanVix binary filenames (flat, next to wxc-exec). +pub const REQUIRED_BINARIES: &[&str] = &["nanvixd.exe", "nanvix_rootfs.img", "python3.initrd"]; + +/// Subdirectory holding kernel binary (nanvixd expects `./bin/kernel.elf`). +pub const BIN_SUBDIR: &str = "bin"; + +/// Subdirectory holding WHP snapshot files. +pub const SNAPSHOTS_SUBDIR: &str = "snapshots"; + +/// Files that live in a `bin/` subdirectory (nanvixd expects ./bin/kernel.elf). +pub const BIN_SUBDIR_FILES: &[&str] = &["kernel.elf"]; + +/// Snapshot files that live in a `snapshots/` subdirectory next to the exe. +pub const SNAPSHOT_FILES: &[&str] = &["kernel.vmem", "kernel.whp.cbor"]; /// Binaries sourced from the `nanvix/nanvix-python` GitHub release. pub const NANVIX_PYTHON_REPO_BINARIES: &[&str] = REQUIRED_BINARIES; +/// Number of bytes to retain from the end of nanvixd stderr when capturing +/// it for diagnostics. Bounds host memory growth in the face of an +/// untrusted/verbose child (availability / DoS hardening). +pub const STDERR_TAIL_BYTES: usize = 8 * 1024; + +/// Render a bounded tail of nanvixd stderr bytes as a UTF-8 string, +/// prefixed with `...(truncated)` when truncation occurred. +/// +/// `bytes` may be the full stderr buffer (post-hoc trim) or a buffer that +/// the caller already capped while streaming. The `truncated` flag tells +/// us that streaming-time bytes were dropped even when the resulting +/// buffer length is at or below [`STDERR_TAIL_BYTES`]. +pub fn format_stderr_tail(bytes: &[u8], truncated: bool) -> String { + if bytes.len() > STDERR_TAIL_BYTES { + // Trim at the byte level *before* UTF-8 decoding. Slicing into the + // `String` produced by `from_utf8_lossy` would panic if the byte + // offset fell inside a multi-byte codepoint; `from_utf8_lossy` on + // a raw byte slice tolerates a partial leading codepoint by + // emitting U+FFFD replacement characters instead. + let start = bytes.len() - STDERR_TAIL_BYTES; + let tail = String::from_utf8_lossy(&bytes[start..]); + format!("...(truncated){}", tail) + } else if truncated { + let text = String::from_utf8_lossy(bytes); + format!("...(truncated){}", text) + } else { + String::from_utf8_lossy(bytes).into_owned() + } +} + +/// Drain `reader` to completion, retaining only the last +/// [`STDERR_TAIL_BYTES`] bytes. Returns `(tail, truncated)` where +/// `truncated` is set when any earlier bytes were dropped. +/// +/// Used to bound host memory growth when capturing nanvixd stderr from a +/// potentially untrusted / verbose child (availability / DoS hardening). +/// Read errors terminate the drain and return whatever was captured so +/// far; this mirrors `read_to_end` failure semantics for our use case +/// where stderr is best-effort diagnostic data. +pub fn drain_stderr_tail(mut reader: R) -> (Vec, bool) { + let mut tail: Vec = Vec::new(); + let mut chunk = [0u8; 8 * 1024]; + let mut truncated = false; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + tail.extend_from_slice(&chunk[..n]); + if tail.len() > STDERR_TAIL_BYTES { + let drop = tail.len() - STDERR_TAIL_BYTES; + tail.drain(..drop); + truncated = true; + } + } + Err(_) => break, + } + } + (tail, truncated) +} + /// Release configuration loaded from `versions.json`. #[derive(Debug, Deserialize)] pub struct ReleaseConfig { @@ -63,3 +130,190 @@ pub fn github_download_url(repo: &str, tag: &str, asset: &str) -> String { repo, tag, asset ) } + +/// Generate WHP snapshots by cold-booting nanvixd. +/// +/// `snapshot_home` is used as the process working directory. nanvixd writes +/// snapshot files to `/snapshots/`, so the resulting files end up at +/// `/snapshots/kernel.vmem` and `kernel.whp.cbor`. +/// +/// `bin_dir` is the directory containing `kernel.elf` (passed as `-bin-dir`). +/// +/// Returns `Ok(())` on success. On failure, returns a human-readable error +/// message suitable for both build scripts (which panic) and runtime callers +/// (which wrap in their own error type). +pub fn generate_snapshot( + snapshot_home: &Path, + nanvixd: &Path, + bin_dir: &Path, + ramfs: &Path, + initrd: &Path, +) -> Result<(), String> { + use std::process::{Command, Stdio}; + + let output = Command::new(nanvixd) + .current_dir(snapshot_home) + .arg("-bin-dir") + .arg(bin_dir) + .arg("-ramfs") + .arg(ramfs) + .arg("-kernel-args") + .arg("snapshot") + .arg("--") + .arg(initrd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| format!("failed to run nanvixd for snapshot generation: {}", e))?; + + if !output.status.success() { + // Include a bounded tail of stderr so callers (build script panic + // or runtime preflight error) can surface actionable diagnostics + // without growing host memory unboundedly. + let tail = format_stderr_tail(&output.stderr, false); + let trimmed = tail.trim_end(); + if trimmed.is_empty() { + return Err(format!( + "snapshot generation failed (exit code: {})", + output.status + )); + } + return Err(format!( + "snapshot generation failed (exit code: {})\nnanvixd stderr:\n{}", + output.status, trimmed + )); + } + + let snap_dir = snapshot_home.join(SNAPSHOTS_SUBDIR); + for name in SNAPSHOT_FILES { + if !snap_dir.join(name).exists() { + return Err(format!( + "snapshot generation completed but '{}' not found in {:?}", + name, snap_dir + )); + } + } + + Ok(()) +} + +/// Copy NanVix artifacts from the build cache (`src_dir`) to the target +/// directory next to the output executable. +/// +/// Copies flat binaries, `snapshots/` files, and `bin/` subdir files. +/// Skips files that are already up-to-date (based on modification time). +pub fn copy_artifacts_to_target(src_dir: &Path, target_dir: &Path) { + use std::fs; + + // Flat binaries (nanvixd.exe, nanvix_rootfs.img, python3.initrd). + for name in REQUIRED_BINARIES { + let src = src_dir.join(name); + let dst = target_dir.join(name); + if src.exists() && (!dst.exists() || is_newer(&src, &dst)) { + eprintln!("nanvix: copying {} -> {}", src.display(), dst.display()); + if let Err(e) = fs::copy(&src, &dst) { + let _ = fs::remove_file(&dst); + eprintln!("nanvix: WARNING: failed to copy {}: {}", name, e); + } + } + } + + // Snapshot files (snapshots/kernel.vmem, snapshots/kernel.whp.cbor). + let snapshots_src = src_dir.join(SNAPSHOTS_SUBDIR); + let snapshots_dst = target_dir.join(SNAPSHOTS_SUBDIR); + if snapshots_src.exists() { + let _ = fs::create_dir_all(&snapshots_dst); + for name in SNAPSHOT_FILES { + let src = snapshots_src.join(name); + let dst = snapshots_dst.join(name); + if src.exists() && (!dst.exists() || is_newer(&src, &dst)) { + eprintln!("nanvix: copying snapshots/{} -> {}", name, dst.display()); + if let Err(e) = fs::copy(&src, &dst) { + let _ = fs::remove_file(&dst); + eprintln!("nanvix: WARNING: failed to copy snapshots/{}: {}", name, e); + } + } + } + } + + // bin/ subdir files (kernel.elf) — nanvixd expects ./bin/kernel.elf. + let bin_src = src_dir.join(BIN_SUBDIR); + let bin_dst = target_dir.join(BIN_SUBDIR); + if bin_src.exists() { + let _ = fs::create_dir_all(&bin_dst); + for name in BIN_SUBDIR_FILES { + let src = bin_src.join(name); + let dst = bin_dst.join(name); + if src.exists() && (!dst.exists() || is_newer(&src, &dst)) { + eprintln!("nanvix: copying bin/{} -> {}", name, dst.display()); + if let Err(e) = fs::copy(&src, &dst) { + let _ = fs::remove_file(&dst); + eprintln!("nanvix: WARNING: failed to copy bin/{}: {}", name, e); + } + } + } + } +} + +fn is_newer(src: &Path, dst: &Path) -> bool { + let src_time = src.metadata().and_then(|m| m.modified()).ok(); + let dst_time = dst.metadata().and_then(|m| m.modified()).ok(); + match (src_time, dst_time) { + (Some(s), Some(d)) => s > d, + _ => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_stderr_tail_short_buffer_no_prefix() { + let out = format_stderr_tail(b"hello", false); + assert_eq!(out, "hello"); + } + + #[test] + fn format_stderr_tail_short_buffer_with_truncated_flag() { + let out = format_stderr_tail(b"hello", true); + assert_eq!(out, "...(truncated)hello"); + } + + #[test] + fn format_stderr_tail_trims_oversized_ascii_buffer() { + let big = vec![b'A'; STDERR_TAIL_BYTES + 16]; + let out = format_stderr_tail(&big, false); + assert!(out.starts_with("...(truncated)")); + // 14 chars for the prefix + STDERR_TAIL_BYTES trailing ASCII bytes. + assert_eq!(out.len(), "...(truncated)".len() + STDERR_TAIL_BYTES); + } + + /// Regression test for PR review comment r3283559877: an oversized + /// buffer containing multi-byte UTF-8 must not panic when the + /// truncation byte offset falls inside a codepoint. + #[test] + fn format_stderr_tail_oversized_multibyte_does_not_panic() { + // 4-byte UTF-8 emoji repeated until well past the cap. + let unit = "🦀"; // 4 bytes + let repeats = (STDERR_TAIL_BYTES / unit.len()) + 64; + let big = unit.repeat(repeats).into_bytes(); + assert!(big.len() > STDERR_TAIL_BYTES); + let out = format_stderr_tail(&big, false); + assert!(out.starts_with("...(truncated)")); + // Ensure the result is still valid UTF-8 (String guarantees this); + // partial leading codepoints become U+FFFD via `from_utf8_lossy`. + assert!(out.len() >= "...(truncated)".len()); + } + + #[test] + fn format_stderr_tail_oversized_with_truncated_flag_uses_byte_trim() { + // When the buffer is oversized, the explicit `truncated` flag + // should not change behavior — byte-level trim still applies. + let big = vec![b'B'; STDERR_TAIL_BYTES + 1]; + let out = format_stderr_tail(&big, true); + assert!(out.starts_with("...(truncated)")); + assert_eq!(out.len(), "...(truncated)".len() + STDERR_TAIL_BYTES); + } +} diff --git a/src/wxc/Cargo.toml b/src/wxc/Cargo.toml index 00984c61f..13c8605c5 100644 --- a/src/wxc/Cargo.toml +++ b/src/wxc/Cargo.toml @@ -24,7 +24,10 @@ nanvix_common = { path = "../nanvix_common" } [features] default = [] -microvm = ["nanvix_binaries"] +microvm = ["nanvix_binaries", "wxc_common/microvm"] wslc = ["dep:wslc_common", "wslc_common/link-wslcsdk"] hyperlight = ["wxc_common/hyperlight"] -isolation_session = ["dep:isolation_session_bindings", "wxc_common/isolation_session"] +isolation_session = [ + "dep:isolation_session_bindings", + "wxc_common/isolation_session", +] diff --git a/src/wxc/build.rs b/src/wxc/build.rs index a338048ad..9f9d20d07 100644 --- a/src/wxc/build.rs +++ b/src/wxc/build.rs @@ -69,7 +69,6 @@ fn check_test_prerequisites() { #[cfg(all(windows, feature = "microvm"))] fn copy_nanvix_binaries() { - use std::fs; use std::path::Path; let nanvix_bin_dir = match std::env::var("DEP_NANVIX_BINARIES_BIN_DIR") { @@ -95,36 +94,8 @@ fn copy_nanvix_binaries() { } }; - let src_dir = Path::new(&nanvix_bin_dir); - - for name in nanvix_common::REQUIRED_BINARIES { - let src = src_dir.join(name); - let dst = target_dir.join(name); - - if src.exists() && (!dst.exists() || is_newer(&src, &dst)) { - eprintln!( - "wxc build.rs: copying {} -> {}", - src.display(), - dst.display() - ); - if let Err(e) = fs::copy(&src, &dst) { - // Remove partial copy to avoid leaving a dangling file - let _ = fs::remove_file(&dst); - eprintln!("wxc build.rs: WARNING: failed to copy {}: {}", name, e); - } - } - } + nanvix_common::copy_artifacts_to_target(Path::new(&nanvix_bin_dir), target_dir); // Re-run when source binaries change (detected via nanvix_binaries rebuild) println!("cargo:rerun-if-env-changed=DEP_NANVIX_BINARIES_BIN_DIR"); } - -#[cfg(all(windows, feature = "microvm"))] -fn is_newer(src: &std::path::Path, dst: &std::path::Path) -> bool { - let src_time = src.metadata().and_then(|m| m.modified()).ok(); - let dst_time = dst.metadata().and_then(|m| m.modified()).ok(); - match (src_time, dst_time) { - (Some(s), Some(d)) => s > d, - _ => true, - } -} diff --git a/src/wxc/src/main.rs b/src/wxc/src/main.rs index 19ba78855..1ebcd2bff 100644 --- a/src/wxc/src/main.rs +++ b/src/wxc/src/main.rs @@ -18,6 +18,7 @@ use wxc_common::isolation_session::IsolationSessionRunner; use wxc_common::logger::{Logger, Mode}; use wxc_common::models::{CodexRequest, ContainmentBackend, ScriptResponse}; use wxc_common::mxc_error::{MxcError, ResponseEnvelope}; +#[cfg(feature = "microvm")] use wxc_common::nanvix_runner::NanVixScriptRunner; use wxc_common::script_runner::{handle_dry_run_exit, ScriptRunner}; use wxc_common::state_aware_dispatch::{run_state_aware, DispatchOutcome}; @@ -484,7 +485,15 @@ fn main() { eprintln!("Error: MicroVM is an experimental feature. Use --experimental flag."); process::exit(1); } - Box::new(NanVixScriptRunner::new()) + #[cfg(feature = "microvm")] + { + Box::new(NanVixScriptRunner::new()) + } + #[cfg(not(feature = "microvm"))] + { + eprintln!("Error: MicroVM backend not compiled in (build with --features microvm)"); + process::exit(1); + } } ContainmentBackend::Hyperlight => { #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] diff --git a/src/wxc_common/Cargo.toml b/src/wxc_common/Cargo.toml index 41fe6236b..c51c25181 100644 --- a/src/wxc_common/Cargo.toml +++ b/src/wxc_common/Cargo.toml @@ -10,6 +10,7 @@ isolation_session = [ "dep:windows-future", "dep:windows-collections", ] +microvm = ["dep:nanvix_common"] [dependencies] serde = { workspace = true } @@ -24,6 +25,7 @@ semver = "1" hyperlight-unikraft-host = { git = "https://github.com/hyperlight-dev/hyperlight-unikraft", tag = "v0.6.0", optional = true } [target.'cfg(target_os = "windows")'.dependencies] +nanvix_common = { path = "../nanvix_common", optional = true } windows = { workspace = true } windows-core = { workspace = true } flatbuffers = { workspace = true } diff --git a/src/wxc_common/src/lib.rs b/src/wxc_common/src/lib.rs index 0a20c874a..f50c5df43 100644 --- a/src/wxc_common/src/lib.rs +++ b/src/wxc_common/src/lib.rs @@ -10,11 +10,11 @@ pub mod hyperlight_runner; pub mod id; pub mod log_symbols; pub mod logger; -#[cfg(target_os = "windows")] +#[cfg(all(feature = "microvm", target_os = "windows"))] pub mod microvm_staging; pub mod models; pub mod mxc_error; -#[cfg(target_os = "windows")] +#[cfg(all(feature = "microvm", target_os = "windows"))] pub mod nanvix_runner; pub mod script_runner; pub mod state_aware_backend; diff --git a/src/wxc_common/src/microvm_staging.rs b/src/wxc_common/src/microvm_staging.rs index 7a6366244..cfb619227 100644 --- a/src/wxc_common/src/microvm_staging.rs +++ b/src/wxc_common/src/microvm_staging.rs @@ -12,10 +12,8 @@ use uuid::Uuid; /// Maximum allowed total staging directory size (16 MB). pub const MAX_STAGING_BYTES: u64 = 16 * 1024 * 1024; -/// Bootstrap Python loader filename. -pub const BOOTSTRAP_FILENAME: &str = ".mxc-bootstrap.py"; -/// User script filename in staging. -pub const SCRIPT_FILENAME: &str = ".mxc-script.py"; +/// Entry point filename (warm-start protocol executes this automatically). +pub const BOOTSTRAP_FILENAME: &str = "bootstrap.py"; /// Subdirectory for read-write staged host paths. pub const RW_DIR: &str = "rw"; /// Subdirectory for read-only staged host paths. @@ -27,14 +25,17 @@ const GUEST_MOUNT_ROOT: &str = "/mnt"; fn build_guest_path(category: &str, name: &str) -> String { format!("{}/{}/{}", GUEST_MOUNT_ROOT, category, name) } -/// Bootstrap Python source used by the guest runtime. -/// Minimal — just runs the user script. Path translation is done at staging -/// time by rewriting host paths in the script source before writing it. -pub(crate) const BOOTSTRAP_SOURCE: &str = "import sys -sys.argv = ['/mnt/.mxc-script.py'] -with open(sys.argv[0]) as _f: - exec(compile(_f.read(), sys.argv[0], 'exec'), {'__name__': '__main__', '__file__': sys.argv[0]}) -"; + +/// Preamble prepended to the user script in bootstrap.py. +/// +/// Composed from [`GUEST_MOUNT_ROOT`] and [`BOOTSTRAP_FILENAME`] so that +/// any future change to either constant flows through automatically. +fn bootstrap_preamble() -> String { + format!( + "import sys\nsys.argv = ['{}/{}']\n", + GUEST_MOUNT_ROOT, BOOTSTRAP_FILENAME + ) +} /// Errors produced while creating or validating a staging directory. #[derive(Debug, Error)] @@ -112,8 +113,6 @@ impl StagingDir { fs::create_dir_all(&path)?; let build_result = || -> StagingBuildResult { - fs::write(path.join(BOOTSTRAP_FILENAME), BOOTSTRAP_SOURCE)?; - // Collect host→guest path mappings for script rewriting. let mut rewrite_map: Vec<(String, String)> = Vec::new(); let mut rw_mappings: Vec = Vec::new(); @@ -164,7 +163,8 @@ impl StagingDir { // know about the guest mount layout. Both backslash and forward-slash // variants of each host path are replaced. let rewritten_script = rewrite_paths_in_script(script, &rewrite_map); - fs::write(path.join(SCRIPT_FILENAME), &rewritten_script)?; + let bootstrap_content = format!("{}{}", bootstrap_preamble(), rewritten_script); + fs::write(path.join(BOOTSTRAP_FILENAME), &bootstrap_content)?; let size_bytes = dir_size(&path)?; if size_bytes > MAX_STAGING_BYTES { @@ -568,16 +568,16 @@ mod tests { } #[test] - fn staging_creates_bootstrap_and_script() { + fn staging_creates_bootstrap() { let root = tempdir().unwrap(); let script = "print('hello')"; let staging = StagingDir::new(root.path().to_path_buf(), script, &[], &[]).unwrap(); let bootstrap = staging.path().join(BOOTSTRAP_FILENAME); - let script_path = staging.path().join(SCRIPT_FILENAME); assert!(bootstrap.exists()); - assert!(script_path.exists()); - assert_eq!(fs::read_to_string(script_path).unwrap(), script); + let content = fs::read_to_string(bootstrap).unwrap(); + assert!(content.starts_with(&bootstrap_preamble())); + assert!(content.contains(script)); } #[test] @@ -603,7 +603,7 @@ mod tests { let guest_rel = host_path_to_guest_relative(&PathBuf::from(&host_path)); assert!(staging.path().join(RW_DIR).join(&guest_rel).exists()); // Verify the script was rewritten with the guest path. - let rewritten = fs::read_to_string(staging.path().join(SCRIPT_FILENAME)).unwrap(); + let rewritten = fs::read_to_string(staging.path().join(BOOTSTRAP_FILENAME)).unwrap(); let expected_guest = build_guest_path(RW_DIR, &guest_rel); assert!( rewritten.contains(&expected_guest), @@ -665,7 +665,7 @@ mod tests { let rw = vec![host_path.clone()]; let staging = StagingDir::new(root.path().to_path_buf(), &script, &rw, &[]).unwrap(); - let rewritten = fs::read_to_string(staging.path().join(SCRIPT_FILENAME)).unwrap(); + let rewritten = fs::read_to_string(staging.path().join(BOOTSTRAP_FILENAME)).unwrap(); let guest_rel = host_path_to_guest_relative(&PathBuf::from(&host_path)); let expected_guest = build_guest_path(RW_DIR, &guest_rel); assert!( @@ -687,8 +687,13 @@ mod tests { let left = fs::read_to_string(a.path().join(BOOTSTRAP_FILENAME)).unwrap(); let right = fs::read_to_string(b.path().join(BOOTSTRAP_FILENAME)).unwrap(); - assert_eq!(left, right); - assert_eq!(left, BOOTSTRAP_SOURCE); + // The preamble (loader boilerplate) must be identical regardless of script content. + let preamble = bootstrap_preamble(); + assert!(left.starts_with(&preamble)); + assert!(right.starts_with(&preamble)); + let left_preamble = &left[..preamble.len()]; + let right_preamble = &right[..preamble.len()]; + assert_eq!(left_preamble, right_preamble); } #[test] @@ -916,4 +921,175 @@ mod tests { assert!(!fresh_dir.exists(), "staging dir should be swept at age 0"); assert!(unrelated.exists(), "unrelated dir must not be swept"); } + + #[test] + fn staging_rejects_path_with_parent_dir_component() { + let root = tempdir().unwrap(); + let source = root.path().join("legit"); + fs::create_dir_all(&source).unwrap(); + // Construct a path with `..` to attempt traversal. + let traversal = source.join("..").join("legit"); + let rw = vec![traversal.display().to_string()]; + let err = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap_err(); + assert!( + matches!(err, StagingError::PathNotFound(ref msg) if msg.contains("..")), + "expected PathNotFound with '..' mention, got: {err}" + ); + } + + #[test] + fn staging_mixed_rw_and_ro_paths() { + let root = tempdir().unwrap(); + let source_root = tempdir().unwrap(); + let rw_dir = source_root.path().join("editable"); + let ro_dir = source_root.path().join("reference"); + fs::create_dir_all(&rw_dir).unwrap(); + fs::create_dir_all(&ro_dir).unwrap(); + write_file(&rw_dir.join("a.txt"), "rw-content"); + write_file(&ro_dir.join("b.txt"), "ro-content"); + + let rw = vec![rw_dir.display().to_string()]; + let ro = vec![ro_dir.display().to_string()]; + let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &ro).unwrap(); + + // Both subdirectories must exist. + assert!(staging.path().join(RW_DIR).exists()); + assert!(staging.path().join(RO_DIR).exists()); + // Verify file content was staged. + let rw_rel = host_path_to_guest_relative(&rw_dir); + let ro_rel = host_path_to_guest_relative(&ro_dir); + let staged_rw_file = staging.path().join(RW_DIR).join(&rw_rel).join("a.txt"); + let staged_ro_file = staging.path().join(RO_DIR).join(&ro_rel).join("b.txt"); + assert_eq!(fs::read_to_string(staged_rw_file).unwrap(), "rw-content"); + assert_eq!(fs::read_to_string(staged_ro_file).unwrap(), "ro-content"); + } + + #[test] + fn staging_overhead_ms_scales_with_size() { + let root = tempdir().unwrap(); + let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap(); + // A minimal staging dir should have near-zero overhead. + assert!(staging.staging_overhead_ms() < 5); + } + + #[test] + fn staging_overhead_ms_capped_at_30s() { + let root = tempdir().unwrap(); + let mut staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap(); + // Simulate a huge staging size (won't actually allocate). + staging.size_bytes = 500 * 1024 * 1024; // 500 MB + assert_eq!(staging.staging_overhead_ms(), 30_000); + } + + #[test] + fn preserved_path_none_when_not_preserved() { + let root = tempdir().unwrap(); + let staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap(); + assert!(staging.preserved_path().is_none()); + } + + #[test] + fn host_path_to_guest_relative_handles_trailing_slash() { + let p = PathBuf::from(r"C:\Users\me\work\"); + assert_eq!(host_path_to_guest_relative(&p), "c/Users/me/work"); + } + + #[test] + fn host_path_to_guest_relative_lowercase_drive() { + let p = PathBuf::from(r"E:\Projects\src"); + let result = host_path_to_guest_relative(&p); + assert!(result.starts_with('e'), "drive letter should be lowercase"); + assert_eq!(result, "e/Projects/src"); + } + + #[test] + fn rewrite_paths_handles_escaped_backslashes() { + let host = r"C:\Users\me\work".to_string(); + let guest = "/mnt/rw/c/Users/me/work".to_string(); + let script = r#"path = "C:\\Users\\me\\work""#; + let result = rewrite_paths_in_script(script, &[(host, guest.clone())]); + assert!( + result.contains(&guest), + "escaped backslashes not rewritten: {result}" + ); + } + + #[test] + fn rewrite_paths_longer_prefix_first() { + let short_host = r"C:\data".to_string(); + let short_guest = "/mnt/rw/c/data".to_string(); + let long_host = r"C:\data\subdir".to_string(); + let long_guest = "/mnt/rw/c/data/subdir".to_string(); + let script = r"C:\data\subdir\file.txt"; + let mappings = vec![ + (short_host, short_guest.clone()), + (long_host, long_guest.clone()), + ]; + let result = rewrite_paths_in_script(script, &mappings); + // The longer path must match first so we don't get a partial replacement. + assert!( + result.contains("/mnt/rw/c/data/subdir"), + "longer prefix should match first: {result}" + ); + } + + #[test] + fn build_guest_path_format() { + assert_eq!(build_guest_path("rw", "c/Users/me"), "/mnt/rw/c/Users/me"); + assert_eq!(build_guest_path("ro", "d/ref"), "/mnt/ro/d/ref"); + } + + #[test] + fn staging_empty_script() { + let root = tempdir().unwrap(); + let staging = StagingDir::new(root.path().to_path_buf(), "", &[], &[]).unwrap(); + let content = fs::read_to_string(staging.path().join(BOOTSTRAP_FILENAME)).unwrap(); + // Should only contain the preamble. + assert_eq!(content, bootstrap_preamble()); + } + + #[test] + fn staging_nested_directory_rw_copyback() { + let root = tempdir().unwrap(); + let source_root = tempdir().unwrap(); + let source = source_root.path().join("nested"); + let sub = source.join("sub").join("deep"); + fs::create_dir_all(&sub).unwrap(); + write_file(&sub.join("deep.txt"), "original"); + write_file(&source.join("top.txt"), "top-original"); + + let rw = vec![source.display().to_string()]; + let mut staging = StagingDir::new(root.path().to_path_buf(), "print(1)", &rw, &[]).unwrap(); + let staged_dir = staged_rw(&staging, &source); + + // Modify deep file and add a new file. + fs::write( + staged_dir.join("sub").join("deep").join("deep.txt"), + "modified", + ) + .unwrap(); + fs::write(staged_dir.join("new.txt"), "added").unwrap(); + + staging.copy_back_to_host().unwrap(); + assert_eq!( + fs::read_to_string(sub.join("deep.txt")).unwrap(), + "modified" + ); + assert_eq!(fs::read_to_string(source.join("new.txt")).unwrap(), "added"); + } + + #[test] + fn sweep_ignores_nonexistent_root() { + let nonexistent = PathBuf::from(r"C:\nonexistent_mxc_test_dir_12345"); + // Should not panic or error. + sweep_orphaned_staging_dirs(&nonexistent, Duration::from_secs(0)); + } + + #[test] + fn staging_dir_has_unique_names() { + let root = tempdir().unwrap(); + let a = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap(); + let b = StagingDir::new(root.path().to_path_buf(), "print(1)", &[], &[]).unwrap(); + assert_ne!(a.path(), b.path()); + } } diff --git a/src/wxc_common/src/nanvix_runner.rs b/src/wxc_common/src/nanvix_runner.rs index fddfb2bc9..0bff2609a 100644 --- a/src/wxc_common/src/nanvix_runner.rs +++ b/src/wxc_common/src/nanvix_runner.rs @@ -10,20 +10,32 @@ //! //! - **stdin**: set to `Stdio::null()` (NanVix guest does not read host stdin) //! - **stdout**: inherited from parent via `Stdio::inherit()` (not captured) -//! - **stderr**: inherited from parent via `Stdio::inherit()` (kernel traces) +//! - **stderr**: inherited from parent by default (kernel traces stream +//! straight to the parent terminal). When the `MXC_NANVIX_TRACE` env var +//! is truthy, stderr is piped and captured so it can be embedded in the +//! wxc-exec log on non-zero exit. //! //! **Note for SDK consumers:** Use `usePty: false` (non-PTY mode) for the MicroVM //! backend. PTY mode is not supported. Because stdout/stderr are inherited, //! `ScriptResponse.standard_out` and `standard_err` are always empty strings. //! Output is streamed directly to the parent's pipes. //! +//! ## Diagnostics +//! +//! By default the runner sets `RUST_LOG=off` in nanvixd's environment, which +//! suppresses the per-run `%LOCALAPPDATA%\nanvix\logs\nanvixd_*.log` trace +//! file and noticeably reduces warm-start latency. Set `MXC_NANVIX_TRACE=1` +//! (or `true`/`yes`, case-insensitive) before invoking wxc-exec to let +//! nanvixd use its own `RUST_LOG` default and to capture nanvixd's stderr +//! for inclusion in the wxc-exec log. +//! //! ## Exit codes //! //! `nanvixd` propagates the guest process exit code directly. //! //! Auto-discovery //! -//! All required binaries (`nanvixd.exe`, `python3.12`, `nanvix_rootfs.img`) +//! All required binaries (`nanvixd.exe`, `python3.initrd`, `nanvix_rootfs.img`) //! are discovered next to the running executable. No configuration is needed. use std::fmt::Write; @@ -39,16 +51,29 @@ use crate::logger::Logger; use crate::models::{CodexRequest, NetworkPolicy, ScriptResponse}; use crate::script_runner::ScriptRunner; -/// Python guest binary loaded by NanVix (embedded in the rootfs, also provided as a sidecar). -const PYTHON_BINARY: &str = "python3.12"; -/// Guest PYTHONHOME value used by CPython inside NanVix. -/// Must NOT contain ';' or spaces — these are NanVix argument delimiters -/// that would corrupt the guest cmdline string. -const PYTHON_HOME: &str = "/sysroot"; +/// Multi-binary initrd (daemons + CPython) loaded by NanVix at warm start. +const INITRD_BINARY: &str = "python3.initrd"; /// NanVix daemon binary launched by the host runner. const NANVIXD_BINARY: &str = "nanvixd.exe"; /// Combined rootfs image (NanVix kernel userspace + CPython stdlib). const RAMFS_IMAGE: &str = "nanvix_rootfs.img"; +/// Pre-built VM state snapshot (CBOR) for warm start. +const SNAPSHOT_CBOR: &str = "kernel.whp.cbor"; +/// Subdirectory holding snapshot files next to the exe. +const SNAPSHOTS_DIR: &str = nanvix_common::SNAPSHOTS_SUBDIR; +/// Subdirectory holding kernel binary. +const BIN_DIR: &str = nanvix_common::BIN_SUBDIR; +/// Env var override for the NanVix snapshot home directory. Set this to +/// force a specific location; otherwise the runner uses a standard +/// OS-local data path or falls back to `/snapshots/`. +const NANVIX_HOME_ENV: &str = "NANVIX_HOME"; +/// Env var that opts in to nanvixd's verbose tracing (and captured stderr). +/// When unset (the default), the runner forces `RUST_LOG=off` for nanvixd +/// and inherits stderr, which saves ~25–30 ms per warm execution by +/// avoiding nanvixd's per-run log file and the host-side stderr drain. +const NANVIX_TRACE_ENV: &str = "MXC_NANVIX_TRACE"; +/// Final component of the default OS-local data path. +const DEFAULT_HOME_LEAF: &str = "nanvix"; /// Boot grace period that is always enforced. const BOOT_TIMEOUT_MS: u64 = 60_000; /// Generic error exit code returned to host callers. @@ -118,6 +143,16 @@ fn exe_dir() -> Result { crate::process_util::exe_dir().map_err(|e| NanVixError::Preflight(e.to_string())) } +/// Returns `true` when [`NANVIX_TRACE_ENV`] is set to a truthy value +/// (`"1"`, `"true"`, or `"yes"`, case-insensitive). Any other value +/// (including unset, empty, or `"0"`/`"false"`/`"no"`) means trace is off. +fn nanvix_trace_enabled() -> bool { + match std::env::var(NANVIX_TRACE_ENV) { + Ok(v) => matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"), + Err(_) => false, + } +} + /// Watchdog thread: waits for timeout or cancellation, then terminates the process. fn watchdog_thread_fn( process_handle_raw: usize, @@ -198,17 +233,28 @@ impl Default for NanVixScriptRunner { } } +/// Resolved paths for NanVix invocation. +#[derive(Debug)] +struct ResolvedPaths { + nanvixd: PathBuf, + ramfs: PathBuf, + initrd: PathBuf, + /// Directory holding the `bin/` subdir next to the exe. + exe_dir: PathBuf, + /// Snapshot home directory — used as cwd for nanvixd so it can locate + /// `snapshots/kernel.vmem` relative to cwd (nanvixd constraint). + snapshot_home: PathBuf, +} + impl NanVixScriptRunner { pub fn new() -> Self { Self { _private: () } } /// Resolve and validate all required paths next to the running executable. - fn resolve_paths(&self) -> Result<(PathBuf, PathBuf, PathBuf), NanVixError> { + fn resolve_paths(&self) -> Result { let dir = exe_dir()?; - // NanVix runtime artifacts (nanvixd.exe, kernel.elf, python3.12, nanvix_rootfs.img) - // are distributed via GitHub releases from nanvix/nanvix-python. - // They are placed next to wxc-exec.exe by the build system. + // NanVix runtime artifacts are placed next to wxc-exec.exe by the build system. let nanvixd = dir.join(NANVIXD_BINARY); if !nanvixd.exists() { @@ -226,15 +272,150 @@ impl NanVixScriptRunner { ))); } - let python = dir.join(PYTHON_BINARY); - if !python.exists() { + let initrd = dir.join(INITRD_BINARY); + if !initrd.exists() { return Err(NanVixError::Preflight(format!( "{} not found in {:?}", - PYTHON_BINARY, dir + INITRD_BINARY, dir ))); } - Ok((nanvixd, ramfs, python)) + // Preflight-check bin/ subdir contents (nanvixd loads `./bin/kernel.elf` + // via `-bin-dir`; missing the file here yields a clearer error than + // letting nanvixd fail at boot time). + let bin_subdir = dir.join(BIN_DIR); + for name in nanvix_common::BIN_SUBDIR_FILES { + let path = bin_subdir.join(name); + if !path.exists() { + return Err(NanVixError::Preflight(format!( + "{}/{} not found in {:?}", + BIN_DIR, name, dir + ))); + } + } + + let snapshot_home = Self::resolve_snapshot_home(&dir)?; + // Warm start requires *all* snapshot files (kernel.vmem + kernel.whp.cbor); + // a partial/corrupt set must trigger regeneration instead of a late failure + // inside nanvixd. + let snapshots_present = nanvix_common::SNAPSHOT_FILES + .iter() + .all(|name| snapshot_home.join(SNAPSHOTS_DIR).join(name).exists()); + if !snapshots_present { + // No (complete) snapshot yet — generate one via cold boot + // (one-time cost, ~400–500 ms). Subsequent runs restore directly. + Self::generate_snapshot(&dir, &snapshot_home, &nanvixd, &ramfs, &initrd)?; + } + + Ok(ResolvedPaths { + nanvixd, + ramfs, + initrd, + exe_dir: dir, + snapshot_home, + }) + } + + /// Resolve the snapshot home directory. + /// + /// Discovery chain (first match wins): + /// 1. `$NANVIX_HOME` env var (if set and non-empty) + /// 2. `` directory itself, when a complete set of pre-generated + /// snapshots already lives in `/snapshots/` (build-time output + /// or shipped artifacts) — using it avoids a redundant cold boot. + /// 3. OS-local data path (`%LOCALAPPDATA%\nanvix` on Windows, + /// `~/.local/share/nanvix` on Linux) + /// 4. `` directory itself as a last-resort fallback (dev builds — + /// nanvixd will write snapshots into `/snapshots/`). + fn resolve_snapshot_home(exe_dir: &Path) -> Result { + // 1. Env var override. + if let Some(val) = std::env::var_os(NANVIX_HOME_ENV) { + let p = PathBuf::from(val); + if !p.as_os_str().is_empty() { + std::fs::create_dir_all(&p).map_err(|e| { + NanVixError::Preflight(format!( + "cannot create ${} directory {:?}: {}", + NANVIX_HOME_ENV, p, e + )) + })?; + return Ok(p); + } + } + + // 2. Prefer exe-side snapshots when they're already complete, so + // build-time-generated artifacts shipped next to wxc-exec are + // actually used instead of triggering a fresh cold boot in + // %LOCALAPPDATA%. + let exe_snapshots = exe_dir.join(SNAPSHOTS_DIR); + let exe_snapshots_complete = nanvix_common::SNAPSHOT_FILES + .iter() + .all(|name| exe_snapshots.join(name).exists()); + if exe_snapshots_complete { + return Ok(exe_dir.to_path_buf()); + } + + // 3. OS-local data path. + if let Some(home) = Self::default_home() { + if home.exists() || std::fs::create_dir_all(&home).is_ok() { + return Ok(home); + } + } + + // 4. Fallback: exe directory itself (nanvixd writes to /snapshots/). + Ok(exe_dir.to_path_buf()) + } + + /// Default OS-local snapshot home path. + fn default_home() -> Option { + #[cfg(target_os = "windows")] + { + std::env::var_os("LOCALAPPDATA").map(|d| PathBuf::from(d).join(DEFAULT_HOME_LEAF)) + } + #[cfg(not(target_os = "windows"))] + { + std::env::var_os("HOME").map(|d| { + PathBuf::from(d) + .join(".local") + .join("share") + .join(DEFAULT_HOME_LEAF) + }) + } + } + + /// Generate a WHP snapshot via cold boot (one-time cost). + /// + /// Delegates to `nanvix_common::generate_snapshot` which runs nanvixd with + /// `-kernel-args snapshot` and cwd set to `snapshot_home`. nanvixd writes + /// snapshot files to `/snapshots/` directly. Subsequent runs + /// restore from the snapshot (~20 ms vs ~430 ms cold boot). + fn generate_snapshot( + exe_dir: &Path, + snapshot_home: &Path, + nanvixd: &Path, + ramfs: &Path, + initrd: &Path, + ) -> Result<(), NanVixError> { + std::fs::create_dir_all(snapshot_home).map_err(|e| { + NanVixError::Preflight(format!("failed to create snapshot home: {}", e)) + })?; + + eprintln!("nanvix: no snapshot found — generating via cold boot (one-time cost)..."); + + let start = Instant::now(); + nanvix_common::generate_snapshot( + snapshot_home, + nanvixd, + &exe_dir.join(BIN_DIR), + ramfs, + initrd, + ) + .map_err(NanVixError::Preflight)?; + + eprintln!( + "nanvix: snapshot generated in {:.0?} — subsequent runs will use warm start", + start.elapsed() + ); + Ok(()) } /// Compute total timeout: boot grace + staging overhead + script timeout. @@ -272,48 +453,47 @@ impl NanVixScriptRunner { Ok(()) } - fn build_guest_args() -> String { - // Build the NanVix guest argument string for mount-based script delivery. - // Format: "-S -B /mnt/.mxc-bootstrap.py;PYTHONHOME=/sysroot" - // - // -S: skip site.py (critical — site import is very slow with large ramfs) - // -B: no .pyc writing (read-only filesystem) - // - // The bootstrap script lives in the staging directory mounted at /mnt. - // It exec()s /mnt/.mxc-script.py which has host paths rewritten to - // guest mount paths by the staging layer. - // - // NanVix splits on spaces: argv = ["python3.12", "-S", "-B", "/mnt/..."] - // NanVix splits on ';': env = ["PYTHONHOME=/sysroot"] - format!("-S -B /mnt/.mxc-bootstrap.py;PYTHONHOME={}", PYTHON_HOME) - } - fn spawn_nanvixd( - paths: (&Path, &Path, &Path), - guest_args: &str, + paths: &ResolvedPaths, staging_dir: &Path, ) -> Result { - let (nanvixd_path, ramfs_path, python_path) = paths; - let bin_dir = nanvixd_path - .parent() - .expect("nanvixd path must have a parent directory"); - Command::new(nanvixd_path) + // nanvixd loads kernel.vmem from /snapshots/ so cwd must be + // the snapshot home. All other paths are passed as absolute. + // nanvixd.exe -snapshot snapshots/kernel.whp.cbor + // -bin-dir /bin -ramfs -mount -- python3.initrd + let snapshot_rel = Path::new(SNAPSHOTS_DIR).join(SNAPSHOT_CBOR); + let trace = nanvix_trace_enabled(); + // Default: silence nanvixd and inherit stderr so kernel traces (if + // any) stream straight to the parent terminal without a per-run + // host-side drain. Diagnostic mode pipes stderr so the runner can + // attach it to the wxc-exec log on failure. + let stderr = if trace { + Stdio::piped() + } else { + Stdio::inherit() + }; + let mut cmd = Command::new(&paths.nanvixd); + cmd.current_dir(&paths.snapshot_home) + .arg("-snapshot") + .arg(&snapshot_rel) .arg("-bin-dir") - .arg(bin_dir) + .arg(paths.exe_dir.join(BIN_DIR)) .arg("-ramfs") - .arg(ramfs_path) + .arg(&paths.ramfs) .arg("-mount") .arg(staging_dir) .arg("--") - .arg(python_path) - .arg(guest_args) + .arg(&paths.initrd) .stdin(Stdio::null()) .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .spawn() - .map_err(|e| { - NanVixError::Platform(format!("failed to spawn {}: {}", NANVIXD_BINARY, e)) - }) + .stderr(stderr); + if !trace { + // Suppress nanvixd's env_logger output and per-run log file. + cmd.env("RUST_LOG", "off"); + } + cmd.spawn().map_err(|e| { + NanVixError::Platform(format!("failed to spawn {}: {}", NANVIXD_BINARY, e)) + }) } fn start_watchdog( @@ -405,10 +585,11 @@ impl NanVixScriptRunner { Ok((watchdog, cancel_pair, timed_out)) } - fn log_resolved_paths(logger: &mut Logger, nanvixd: &Path, ramfs: &Path, python: &Path) { - let _ = writeln!(logger, "NanVix: nanvixd={:?}", nanvixd); - let _ = writeln!(logger, "NanVix: ramfs={:?}", ramfs); - let _ = writeln!(logger, "NanVix: python={:?}", python); + fn log_resolved_paths(logger: &mut Logger, paths: &ResolvedPaths) { + let _ = writeln!(logger, "NanVix: nanvixd={:?}", paths.nanvixd); + let _ = writeln!(logger, "NanVix: ramfs={:?}", paths.ramfs); + let _ = writeln!(logger, "NanVix: initrd={:?}", paths.initrd); + let _ = writeln!(logger, "NanVix: snapshot_home={:?}", paths.snapshot_home); } fn wait_and_respond( @@ -420,8 +601,25 @@ impl NanVixScriptRunner { script_timeout: u32, logger: &mut Logger, ) -> ScriptResponse { + // Drain stderr concurrently with `wait()` so a verbose child cannot + // block on a full pipe buffer. We retain only the last + // [`nanvix_common::STDERR_TAIL_BYTES`] bytes so an untrusted guest + // emitting unbounded stderr cannot cause host memory growth + // (availability / DoS hardening). In the default (non-trace) mode + // stderr is inherited and `child.stderr` is `None`, so the join + // returns the empty string immediately. + let stderr_handle = child + .stderr + .take() + .map(|s| thread::spawn(move || nanvix_common::drain_stderr_tail(s))); + let exit_status = child.wait(); + let stderr_output = stderr_handle + .and_then(|h| h.join().ok()) + .map(|(bytes, truncated)| nanvix_common::format_stderr_tail(&bytes, truncated)) + .unwrap_or_default(); + { let (lock, cvar) = &**cancel_pair; let mut cancelled = lock.lock().unwrap_or_else(|e| e.into_inner()); @@ -447,12 +645,18 @@ impl NanVixScriptRunner { Ok(status) => { let exit_code = status.code().unwrap_or(ERROR_EXIT_CODE); let _ = writeln!(logger, "NanVix: process exited with code {}", exit_code); + if exit_code != 0 && !stderr_output.is_empty() { + let _ = writeln!(logger, "NanVix stderr:\n{}", stderr_output); + } ScriptResponse { exit_code, ..Default::default() } } Err(e) => { + if !stderr_output.is_empty() { + let _ = writeln!(logger, "NanVix stderr:\n{}", stderr_output); + } let err = NanVixError::Runtime(format!("failed to wait for {}: {}", NANVIXD_BINARY, e)); let _ = writeln!(logger, "{}", err); @@ -475,8 +679,8 @@ impl ScriptRunner for NanVixScriptRunner { } fn execute(&mut self, request: &CodexRequest, logger: &mut Logger) -> ScriptResponse { - let (nanvixd_path, ramfs_path, python_path) = match self.resolve_paths() { - Ok(paths) => paths, + let paths = match self.resolve_paths() { + Ok(p) => p, Err(e) => return e.to_response(), }; @@ -501,15 +705,10 @@ impl ScriptRunner for NanVixScriptRunner { } }; - Self::log_resolved_paths(logger, &nanvixd_path, &ramfs_path, &python_path); + Self::log_resolved_paths(logger, &paths); let _ = writeln!(logger, "NanVix: staging_dir={:?}", staging.path()); - let guest_args = Self::build_guest_args(); - let mut child = match Self::spawn_nanvixd( - (&nanvixd_path, &ramfs_path, &python_path), - &guest_args, - staging.path(), - ) { + let mut child = match Self::spawn_nanvixd(&paths, staging.path()) { Ok(c) => c, Err(e) => { let _ = writeln!(logger, "{}", e); @@ -745,39 +944,12 @@ mod tests { } #[test] - fn python_home_constant_has_no_delimiters() { - // PYTHON_HOME must not contain ';' or spaces — these are NanVix - // argument delimiters that would corrupt the guest arg string. - assert!(!PYTHON_HOME.contains(';'), "PYTHON_HOME contains ';'"); - assert!(!PYTHON_HOME.contains(' '), "PYTHON_HOME contains space"); - } - - #[test] - fn guest_args_format_is_correct() { - let expected = "-S -B /mnt/.mxc-bootstrap.py;PYTHONHOME=/sysroot"; - let actual = NanVixScriptRunner::build_guest_args(); - assert_eq!(actual, expected); - // The bootstrap path segment itself must not contain spaces. - // (The -S and -B flags are intentional space-separated argv entries.) - assert!( - actual.contains("/mnt/.mxc-bootstrap.py"), - "must contain bootstrap path" - ); - } - - #[test] - fn guest_args_use_bootstrap_path() { - let args = NanVixScriptRunner::build_guest_args(); - assert!( - args.contains(".mxc-bootstrap.py"), - "guest args should reference bootstrap script, got: {}", - args - ); - assert!( - !args.contains("exec(__import__"), - "guest args should NOT use stdin exec trick, got: {}", - args - ); + fn resolve_paths_checks_for_snapshot() { + let runner = NanVixScriptRunner::new(); + let err = runner.resolve_paths().unwrap_err(); + // Should fail on missing binaries (not on snapshot specifically, + // since nanvixd.exe is checked first). + assert!(err.to_string().contains("not found"), "got: {}", err); } // -- Copyback decision tests ------------------------------------------------ diff --git a/src/wxc_e2e_tests/src/lib.rs b/src/wxc_e2e_tests/src/lib.rs index 38842e809..2119cff0d 100644 --- a/src/wxc_e2e_tests/src/lib.rs +++ b/src/wxc_e2e_tests/src/lib.rs @@ -153,15 +153,16 @@ pub fn has_nanvix_binaries() -> bool { let Some(exe) = find_binary("wxc-exec.exe") else { return false; }; - let bin_dir = exe.parent().unwrap_or(Path::new(".")); - let present = [ - "nanvixd.exe", - "kernel.elf", - "python3.12", - "nanvix_rootfs.img", - ] - .iter() - .all(|name| bin_dir.join(name).exists()); + let exe_dir = exe.parent().unwrap_or(Path::new(".")); + // Flat binaries staged next to wxc-exec.exe by `nanvix_binaries`. + let flat_present = ["nanvixd.exe", "nanvix_rootfs.img", "python3.initrd"] + .iter() + .all(|name| exe_dir.join(name).exists()); + // Kernel binary now lives under `bin/` (nanvixd locates it via -bin-dir). + let bin_present = ["kernel.elf"] + .iter() + .all(|name| exe_dir.join("bin").join(name).exists()); + let present = flat_present && bin_present; if !present { println!("SKIPPED: NanVix binaries not found next to wxc-exec.exe"); }