From 841155b30a2babc278922be35fa907caa47741eb Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 01:07:55 -0800 Subject: [PATCH 1/6] host: hand owned cold-launch commands to callers Move checked launch values and owned resources into VmmCommand without reopening them. Add standard owned-handle conversions and the compiled external cold-launch consumer, retaining the current merged preparation and spawn behavior. Expose the existing status-bearing launch wait for host-only callers and retain timeout, actual status and repeated-wait coverage. Public API snapshots will be generated during remote qualification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6789d491-7d13-4929-bc64-4edfec93ba60 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c6a9302c-f126-4231-832e-22426190bd6b Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89997040-ff41-4516-a86a-d81422d5a79f --- nvx-rs/Cargo.toml | 5 + nvx-rs/README.md | 25 +++- nvx-rs/examples/external-launch.rs | 174 ++++++++++++++++++++++ nvx-rs/src/host/platform/windows/spawn.rs | 10 +- nvx-rs/src/host/spawn.rs | 24 ++- nvx-rs/src/host/vm.rs | 134 ++++++++++++++++- nvx-rs/src/host/vm/tests.rs | 99 +++++++++++- nvx-rs/tests/checked_handoff.rs | 49 ++++++ 8 files changed, 499 insertions(+), 21 deletions(-) create mode 100644 nvx-rs/examples/external-launch.rs diff --git a/nvx-rs/Cargo.toml b/nvx-rs/Cargo.toml index 804ce2173..131b63937 100644 --- a/nvx-rs/Cargo.toml +++ b/nvx-rs/Cargo.toml @@ -132,6 +132,11 @@ path = "src/bin/nvx-rs.rs" required-features = ["cli"] doc = false +[[example]] +name = "external-launch" +path = "examples/external-launch.rs" +required-features = ["host"] + # The guest binaries. Their unit tests live in the library, so the binaries have no test harness. [[bin]] name = "nvx-console-pending" diff --git a/nvx-rs/README.md b/nvx-rs/README.md index d273551c9..73d5c5abb 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -124,7 +124,7 @@ changes only when the module's purpose does. | [`nvx::Hypervisor`](#nvxhypervisor) | KVM, MSHV, or WHP, with its serde form behind the `serde` feature | | [`nvx::exec`](#nvxexec) | The execution engine: the streams, credit, and outcome of an execution's two sides, without I/O, for any framing | | [`nvx::host::runtime`](#nvxhostruntime) | `Runtime`: what every launch shares, the runtime's files and their identity, the hypervisor and its probe, and the launch options | -| [`nvx::host::vm`](#nvxhostvm) | A private VM directory, cold and capture preparation, verified detached launch, confirmed stop, and caller-saved reattachment | +| [`nvx::host::vm`](#nvxhostvm) | A private VM directory, cold and capture preparation, owned cold-launch command, verified detached launch, confirmed stop, and caller-saved reattachment | | [`nvx::host::snapshot`](#nvxhostsnapshot) | Strict private snapshot directories, verified metadata, VM compatibility, captured NIC addresses, and time eligibility | | [`nvx::host::config`](#nvxhostconfig) | `VmConfig` and `CaptureConfig`: pure configuration, cold-boot kernel/OpenVMM arguments, portable length checks, and capture identity | | [`nvx::host::restore`](#nvxhostrestore) | A caller-driven restore to Ready without I/O or a clock, and its peer-verified restore-ready listener | @@ -540,7 +540,7 @@ these names before creating any output, including the log. `Prepared::capability()` and `outputs()` return what the caller may journal before a spawn. `log()` returns the same opened log, without reopening its -path. `inherit(file)` moves an already opened resource into the preparation's +path. `inherit(resource)` moves an already opened resource into the preparation's inheritance allowlist and keeps it open. `arguments()` returns the whole configuration's OpenVMM arguments. Creation makes caller-relative runtime and block-image paths absolute without resolving links or `..` components, @@ -569,6 +569,27 @@ blockless root or memory that cannot fit the record's byte count fails with `ErrorKind::Unsupported`. These projections do not enable the restore caller; `create` still accepts cold launches only. +`Prepared::into_command()` moves the complete launch into `VmmCommand`: its +program, arguments, working directory, capability, outputs, inherited resources, +opened log and Windows breakaway setting. It neither reopens nor duplicates +resources, renders again nor rediscovers paths. `InheritedHandle` owns its +resource, converts from and back to `OwnedFd` on Unix or `OwnedHandle` on Windows, +and also accepts an opened `File`. The command documents the caller's spawn +contract, including standard streams, allowlisted inheritance, Linux detachment +and umask, Windows log job and breakaway, and cleanup of a failed child. +Read the resolved NIC or capture context, or build an activation record, +before consuming `Prepared` into the command. + +The compiled [external-launch example](examples/external-launch.rs) consumes +this command, uses the existing public low-level spawn primitive without a +sandbox store, checks the cold guest's boot marker and shell response, then asks +it to exit cleanly. It requires the VMM's actual exit status 0 and a successful +outcome with complete teardown. Build with +`cargo build --locked --example external-launch` and supply the backend, OpenVMM, +kernel, initramfs, new VM directory, and the three artifact SHA-256 digests from +their installation metadata, in that order. Artifact paths may be relative to +the caller's directory. + `vm::spawn(prepared)` consumes that preparation and returns `Launched` with its verified `process`, the same `outputs`, and the same `capability`. The log and inherited resources are passed as already opened objects, never reopened diff --git a/nvx-rs/examples/external-launch.rs b/nvx-rs/examples/external-launch.rs new file mode 100644 index 000000000..3a10480c1 --- /dev/null +++ b/nvx-rs/examples/external-launch.rs @@ -0,0 +1,174 @@ +//! A caller-owned cold launch, using the existing low-level spawn primitive without a store. +//! +//! Run the compiled example with a backend, OpenVMM, kernel, initramfs, new VM directory, and +//! the three artifact SHA-256 digests from their installation metadata, in that order. + +use std::error::Error; +use std::io::{self, Write}; +use std::path::Path; +use std::time::{Duration, Instant}; + +use nvx::Hypervisor; +use nvx::activation::{MachineIdMode, View}; +use nvx::host::config::{Boot, ConsoleAttachment, RootConfig, VmConfig}; +use nvx::host::platform::Transport; +use nvx::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; +use nvx::host::supervisor::{self, Inherited}; +use nvx::host::{artifacts, report, session, vm}; + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args_os().collect(); + if args.len() != 9 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "usage: external-launch BACKEND OPENVMM KERNEL INITRAMFS VM-DIR \ + OPENVMM-SHA256 KERNEL-SHA256 INITRAMFS-SHA256", + ) + .into()); + } + let hypervisor = match args[1].to_str() { + Some("kvm") => Hypervisor::Kvm, + Some("mshv") => Hypervisor::Mshv, + Some("whp") => Hypervisor::Whp, + _ => return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid backend").into()), + }; + nvx::host::runtime::probe_hypervisor(hypervisor)?; + let identity = |index: usize| { + ArtifactIdentity::sha256(args[index].to_str().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "artifact digest is not Unicode", + ) + })?) + .map_err(Box::::from) + }; + let runtime = Runtime { + openvmm: artifacts::absolute(Path::new(&args[2]))?, + kernel: artifacts::absolute(Path::new(&args[3]))?, + initramfs: artifacts::absolute(Path::new(&args[4]))?, + identity: RuntimeIdentity::new(identity(6)?, identity(7)?, identity(8)?), + hypervisor, + cpu_profile: Some("auto".to_owned()), + windows_breakaway: false, + options: LaunchOptions::default(), + }; + let config = VmConfig { + processors: 1, + memory_mib: 256, + root: RootConfig::Blockless, + view: View { + hostname: b"external".to_vec(), + machine_id: MachineIdMode::Image, + }, + network: None, + filesystem: None, + console: ConsoleAttachment::Listener, + boot: Some(Boot { + command_line: "quiet loglevel=0".to_owned(), + }), + }; + vm::create_private_dir(Path::new(&args[5]))?; + let dir = vm::VmDir::open(Path::new(&args[5]))?; + let vm::VmmCommand { + program, + arguments, + working_dir, + capability, + outputs, + inherit, + log, + breakaway_from_job, + } = vm::create(&runtime, &config, None, &dir)?.into_command(); + let command = supervisor::VmmCommand { + program, + arguments, + working_dir, + breakaway_from_job, + inherit: inherit.iter().map(Inherited::new).collect(), + }; + let mut launched = supervisor::spawn(&command, capability.as_bytes(), log)?; + let result = (|| -> Result<(), Box> { + let process = launched.process()?; + let deadline = Instant::now() + Duration::from_secs(60); + let console = outputs.console.as_deref().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "no prepared boot console") + })?; + let mut console = session::connect( + console, + process.reference().pid, + process.reference().start_time, + deadline, + )?; + read_until(console.as_mut(), b"NVX-GUEST-BOOT-OK:", deadline)?; + console.write_all( + b"printf 'NVX-EXTERNAL-LAUNCH-OK\\n'; /sbin/nvx-exit 0\n", + Some(deadline.saturating_duration_since(Instant::now())), + )?; + read_until(console.as_mut(), b"\nNVX-EXTERNAL-LAUNCH-OK", deadline)?; + if !process.wait(deadline)? { + return Err(io::Error::new(io::ErrorKind::TimedOut, "VM did not exit").into()); + } + match launched.wait_timeout(Duration::ZERO)? { + Some(0) => {} + Some(status) => { + return Err(io::Error::other(format!("VMM exited with status {status}")).into()); + } + None => return Err(io::Error::other("VMM exit status is unavailable").into()), + } + let outcome = report::read(&outputs.outcome) + .ok_or_else(|| io::Error::other("missing or invalid VM outcome"))?; + if outcome.category.as_deref() != Some("success") + || outcome.status_code != Some(0) + || outcome.teardown_complete != Some(true) + { + return Err( + io::Error::other(format!("VM did not shut down cleanly: {outcome:?}")).into(), + ); + } + Ok(()) + })(); + if let Err(error) = result { + if !launched.kill_and_wait() { + return Err( + io::Error::other(format!("{error}; could not reap the launched VM")).into(), + ); + } + return Err(error); + } + launched.detach(); + println!( + "external cold launch completed; outcome={}", + outputs.outcome.display() + ); + Ok(()) +} + +/// Prints console output through a marker, retaining only enough bytes to match across reads. +fn read_until(console: &mut dyn Transport, marker: &[u8], deadline: Instant) -> io::Result<()> { + let mut pending = Vec::new(); + let mut buffer = [0; 4096]; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "console marker not received", + )); + } + let count = console.read(&mut buffer, Some(remaining))?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "boot console closed", + )); + } + io::stdout().write_all(&buffer[..count])?; + pending.extend_from_slice(&buffer[..count]); + if pending.windows(marker.len()).any(|bytes| bytes == marker) { + return Ok(()); + } + if pending.len() > marker.len() { + pending.drain(..pending.len() - marker.len()); + } + } +} diff --git a/nvx-rs/src/host/platform/windows/spawn.rs b/nvx-rs/src/host/platform/windows/spawn.rs index e89348406..dfe398380 100644 --- a/nvx-rs/src/host/platform/windows/spawn.rs +++ b/nvx-rs/src/host/platform/windows/spawn.rs @@ -11,26 +11,27 @@ use std::{mem, ptr}; #[cfg(feature = "cli")] use windows_sys::Win32::Foundation::{ - DUPLICATE_SAME_ACCESS, DuplicateHandle, INVALID_HANDLE_VALUE, WAIT_TIMEOUT, + DUPLICATE_SAME_ACCESS, DuplicateHandle, INVALID_HANDLE_VALUE, }; use windows_sys::Win32::Foundation::{ GetHandleInformation, HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation, WAIT_OBJECT_0, + WAIT_TIMEOUT, }; #[cfg(feature = "cli")] use windows_sys::Win32::System::Console::{ GetStdHandle, STD_ERROR_HANDLE, STD_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, }; +#[cfg(feature = "cli")] +use windows_sys::Win32::System::Threading::GetCurrentProcess; use windows_sys::Win32::System::Threading::{ CREATE_BREAKAWAY_FROM_JOB, CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, CreateProcessW, DeleteProcThreadAttributeList, - EXTENDED_STARTUPINFO_PRESENT, INFINITE, InitializeProcThreadAttributeList, + EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess, INFINITE, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_JOB_LIST, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, }; -#[cfg(feature = "cli")] -use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetExitCodeProcess}; #[cfg(feature = "cli")] use super::process::refused_termination; @@ -99,7 +100,6 @@ impl LaunchedProcess { /// Waits up to `timeout` for the process to exit, and returns its exit status if it has, /// as `Popen.wait` reads it with `GetExitCodeProcess`. - #[cfg(feature = "cli")] pub(crate) fn wait_timeout(&self, timeout: Duration) -> io::Result> { let milliseconds = u32::try_from(timeout.as_millis()).unwrap_or(INFINITE - 1); // SAFETY: CreateProcessW returned the handle with full access. diff --git a/nvx-rs/src/host/spawn.rs b/nvx-rs/src/host/spawn.rs index fd71827ab..f767b756a 100644 --- a/nvx-rs/src/host/spawn.rs +++ b/nvx-rs/src/host/spawn.rs @@ -10,7 +10,6 @@ use std::path::PathBuf; use std::process::{Child, Command, Stdio}; #[cfg(not(windows))] use std::thread; -#[cfg(feature = "cli")] use std::time::Duration; #[cfg(not(windows))] use std::time::Instant; @@ -173,8 +172,9 @@ fn spawn_inner( } /// A VMM that [`spawn`] started, which the caller has not released yet: on Unix, its child -/// process, which the caller has not reaped, so that its ID names no other process; on Windows, -/// its process handle. +/// process; on Windows, its process handle. On Unix the child's ID names no other process until +/// [`wait_timeout`](Self::wait_timeout) reaps it; hold it with [`process`](Self::process) before +/// waiting to keep a verified handle after it exits. /// /// Record it with [`Launch::record_process`](crate::host::store::Launch::record_process), or hold /// it with [`process`](Self::process). Then release it with [`detach`](Self::detach), or end it @@ -311,8 +311,10 @@ impl Launched { /// Waits up to `timeout` for the process to exit, and returns its status if it has, as /// `Popen.wait` and `Popen.poll` report it: the exit code, or on Unix the negated number of /// the signal that ended the process. - #[cfg(feature = "cli")] - pub(crate) fn wait_timeout(&mut self, timeout: Duration) -> io::Result> { + /// + /// On Unix a successful wait reaps the child; call [`process`](Self::process) before it to + /// retain a verified process handle. Later waits return the same status. + pub fn wait_timeout(&mut self, timeout: Duration) -> io::Result> { #[cfg(windows)] return Ok(self.process.wait_timeout(timeout)?.map(i64::from)); #[cfg(not(windows))] @@ -333,7 +335,7 @@ impl Launched { } /// Reaps the child if it has exited, and returns its status if it has. - #[cfg(all(feature = "cli", not(windows)))] + #[cfg(not(windows))] fn try_reap(&mut self) -> io::Result> { let status = self.child.try_wait()?; self.reaped |= status.is_some(); @@ -376,11 +378,17 @@ impl Launched { } /// Returns `Popen.returncode` for `status`: the exit code, or the negated signal number. -#[cfg(all(feature = "cli", not(windows)))] +#[cfg(not(windows))] fn status_value(status: std::process::ExitStatus) -> i64 { + #[cfg(unix)] use std::os::unix::process::ExitStatusExt; - match (status.code(), status.signal()) { + #[cfg(unix)] + let signal = status.signal(); + #[cfg(not(unix))] + let signal: Option = None; + + match (status.code(), signal) { (Some(code), _) => i64::from(code), (None, Some(signal)) => -i64::from(signal), (None, None) => 0, diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index 35d4125ea..758baac91 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -37,7 +37,7 @@ use crate::host::platform::{check_vm_dir as check_dir, create_vm_dir as create_d use crate::host::report::{self, Report}; use crate::host::runtime::{Runtime, RuntimeIdentity}; use crate::host::snapshot::{CaptureContext, Snapshot}; -use crate::host::spawn::{self, VmmCommand}; +use crate::host::spawn; use crate::host::transport::{self, HostConnection}; use crate::host::{ platform::inherit::Inherited, @@ -290,6 +290,110 @@ pub fn reattach( }) } +/// An owned descriptor on Unix. +#[cfg(unix)] +type Owned = std::os::fd::OwnedFd; +/// An owned handle on Windows. +#[cfg(windows)] +type Owned = std::os::windows::io::OwnedHandle; +/// An owned file on hosts without descriptors or handles. +#[cfg(not(any(unix, windows)))] +type Owned = File; + +/// An already opened resource that a VMM inherits, owned until dropped or transferred. +/// +/// Conversions move the same descriptor or handle; they neither duplicate nor reopen it. +/// Borrowed raw descriptors and handles cannot transfer ownership into this type. +#[derive(Debug)] +pub struct InheritedHandle { + /// The owned platform resource. + handle: Owned, +} + +impl From for InheritedHandle { + fn from(file: File) -> Self { + Self { + handle: file.into(), + } + } +} + +#[cfg(unix)] +impl From for InheritedHandle { + fn from(handle: std::os::fd::OwnedFd) -> Self { + Self { handle } + } +} + +#[cfg(unix)] +impl From for std::os::fd::OwnedFd { + fn from(resource: InheritedHandle) -> Self { + resource.handle + } +} + +#[cfg(unix)] +impl std::os::fd::AsFd for InheritedHandle { + fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { + std::os::fd::AsFd::as_fd(&self.handle) + } +} + +#[cfg(windows)] +impl From for InheritedHandle { + fn from(handle: std::os::windows::io::OwnedHandle) -> Self { + Self { handle } + } +} + +#[cfg(windows)] +impl From for std::os::windows::io::OwnedHandle { + fn from(resource: InheritedHandle) -> Self { + resource.handle + } +} + +#[cfg(windows)] +impl std::os::windows::io::AsHandle for InheritedHandle { + fn as_handle(&self) -> std::os::windows::io::BorrowedHandle<'_> { + std::os::windows::io::AsHandle::as_handle(&self.handle) + } +} + +/// A complete cold launch handed to the caller's launcher, with all its resources owned. +/// +/// The launcher supplies the capability on stdin through a pipe closed to writers before +/// spawning, and uses the opened log for stdout and stderr. It allows only the standard streams +/// and `inherit`, under their original descriptor or handle numbers, into the child. +/// +/// Follow [`spawn`]'s launch contract: detach on Linux, clear only the child's owner umask bits, +/// and keep allowed descriptors close-on-exec in the caller; on Windows, use an explicit handle +/// list, restore temporary inheritability, start without a console in the log-named job, +/// refuse a taken or untrusted job name, and honor `breakaway_from_job`. Windows callers +/// coordinate their other spawns with the inheritance window. If setup fails after spawning, +/// terminate and reap only that child before returning an error. The caller journals recovery +/// data and keeps the VM directory and runtime inputs stable; this value starts no process or +/// listener. +#[derive(Debug)] +pub struct VmmCommand { + /// The checked OpenVMM executable. + pub program: PathBuf, + /// The complete, checked arguments, without the executable. + pub arguments: Vec, + /// The VM directory in which OpenVMM runs. + pub working_dir: PathBuf, + /// The control credential, never written to disk by the library. + pub capability: Capability, + /// The already chosen output files and endpoints. + pub outputs: VmOutputs, + /// Owned resources allowed into the child besides its standard streams. + pub inherit: Vec, + /// The same private log that preparation opened. + pub log: File, + /// Whether Windows leaves the caller's job, which must permit breakaway. + pub breakaway_from_job: bool, +} + /// A checked cold launch, with its capability, chosen outputs, and already opened log. /// /// Creation starts no process and binds no listener. The caller keeps the VM directory and @@ -316,7 +420,7 @@ pub struct Prepared { /// The effective NIC, whose presence and address a snapshot fixes for a restore. nic: Option, /// Already opened resources added by the caller, retained until the preparation is dropped. - inherit: Vec, + inherit: Vec, /// The checked context of a capture, absent for an ordinary cold launch. capture_context: Option, } @@ -429,8 +533,8 @@ impl Prepared { /// Adds an already opened resource to the inheritance allowlist, transferring ownership. /// /// The resource is not reopened, and remains open for the lifetime of this preparation. - pub fn inherit(&mut self, file: File) { - self.inherit.push(file); + pub fn inherit(&mut self, resource: impl Into) { + self.inherit.push(resource.into()); } /// Returns the same opened log that creation made, for example to take a liveness claim. @@ -449,6 +553,26 @@ impl Prepared { pub fn capture_context(&self) -> Option<&CaptureContext> { self.capture_context.as_ref() } + + /// Transfers the complete checked launch to the caller's launcher. + /// + /// Moves the same opened log and inherited resources, without cloning, reopening, rendering + /// arguments again or rediscovering paths. Starts no process or listener. The caller follows + /// [`VmmCommand`]'s launch contract and may retain its capability and outputs for recovery. + /// Read [`nic`](Self::nic) or [`capture_context`](Self::capture_context), or build an + /// [`activation_record`](Self::activation_record), before consuming the preparation. + pub fn into_command(self) -> VmmCommand { + VmmCommand { + program: self.program, + arguments: self.arguments, + working_dir: self.working_dir, + capability: self.capability, + outputs: self.outputs, + inherit: self.inherit, + log: self.log, + breakaway_from_job: self.windows_breakaway, + } + } } /// Creates an inspectable cold launch in `dir`, without starting a process or binding endpoints. @@ -795,7 +919,7 @@ fn spawn_verified( inherit, .. } = prepared; - let command = VmmCommand { + let command = spawn::VmmCommand { program, arguments, working_dir, diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index f79e431c7..f50abcc27 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -1437,11 +1437,108 @@ fn the_log_and_inherited_resources_stay_open_without_reopening_their_paths() { prepared.inherit(file); fs::rename(&input, &moved_input).unwrap(); fs::write(&input, b"replacement input").unwrap(); - prepared.inherit[0].write_all(b"original input").unwrap(); + inherited_file(prepared.inherit.pop().unwrap()) + .write_all(b"original input") + .unwrap(); assert_eq!(fs::read(&moved_input).unwrap(), b"original input"); assert_eq!(fs::read(&input).unwrap(), b"replacement input"); } +fn inherited_file(resource: InheritedHandle) -> File { + #[cfg(any(unix, windows))] + return File::from(Owned::from(resource)); + #[cfg(not(any(unix, windows)))] + resource.handle +} + +#[test] +fn a_command_moves_the_checked_launch_and_the_same_opened_resources() { + use std::io::Write; + + let (_directory, vm) = vm_dir(); + let mut runtime = cold_runtime(); + runtime.windows_breakaway = true; + let mut prepared = create(&runtime, &cold_config(), None, &vm).unwrap(); + let program = absolute(&runtime.openvmm).unwrap(); + let arguments = prepared.arguments().as_ptr(); + let capability = *prepared.capability().as_bytes(); + let outputs = prepared.outputs().clone(); + let original_log = vm.path().join("original-log"); + fs::rename(&outputs.log, &original_log).unwrap(); + fs::write(&outputs.log, b"replacement log").unwrap(); + let input = vm.path().join("input"); + let original_input = vm.path().join("original-input"); + prepared.inherit(File::create(&input).unwrap()); + let resources = prepared.inherit.as_ptr(); + fs::rename(&input, &original_input).unwrap(); + fs::write(&input, b"replacement input").unwrap(); + #[cfg(unix)] + let log_handle = std::os::fd::AsRawFd::as_raw_fd(prepared.log()); + #[cfg(windows)] + let log_handle = std::os::windows::io::AsRawHandle::as_raw_handle(prepared.log()); + runtime.openvmm = "different-program".into(); + runtime.windows_breakaway = false; + + let mut command = prepared.into_command(); + assert_eq!(command.program, program); + assert_eq!(command.working_dir, vm.path()); + assert!(command.breakaway_from_job); + assert_eq!(command.arguments.as_ptr(), arguments); + assert_eq!(command.inherit.as_ptr(), resources); + assert_eq!(command.capability.as_bytes(), &capability); + assert_eq!(command.outputs, outputs); + #[cfg(unix)] + assert_eq!(std::os::fd::AsRawFd::as_raw_fd(&command.log), log_handle); + #[cfg(windows)] + assert_eq!( + std::os::windows::io::AsRawHandle::as_raw_handle(&command.log), + log_handle + ); + command.log.write_all(b"original log").unwrap(); + inherited_file(command.inherit.pop().unwrap()) + .write_all(b"original input") + .unwrap(); + assert_eq!(fs::read(original_log).unwrap(), b"original log"); + assert_eq!(fs::read(original_input).unwrap(), b"original input"); + assert_eq!(fs::read(&outputs.log).unwrap(), b"replacement log"); + assert_eq!(fs::read(input).unwrap(), b"replacement input"); + drop(command); + assert!(outputs.log.exists()); + assert!(!outputs.outcome.exists()); +} + +#[cfg(unix)] +#[test] +fn an_owned_descriptor_moves_into_and_out_of_an_inherited_handle() { + use std::io::Write; + use std::os::fd::{AsFd, AsRawFd, OwnedFd}; + + let file = tempfile::tempfile().unwrap(); + let number = file.as_raw_fd(); + let descriptor: OwnedFd = file.into(); + let resource: InheritedHandle = descriptor.into(); + assert_eq!(resource.as_fd().as_raw_fd(), number); + let descriptor: OwnedFd = resource.into(); + assert_eq!(descriptor.as_raw_fd(), number); + File::from(descriptor).write_all(b"still open").unwrap(); +} + +#[cfg(windows)] +#[test] +fn an_owned_windows_handle_moves_into_and_out_of_an_inherited_handle() { + use std::io::Write; + use std::os::windows::io::{AsHandle, AsRawHandle, OwnedHandle}; + + let file = tempfile::tempfile().unwrap(); + let number = file.as_raw_handle(); + let handle: OwnedHandle = file.into(); + let resource: InheritedHandle = handle.into(); + assert_eq!(resource.as_handle().as_raw_handle(), number); + let handle: OwnedHandle = resource.into(); + assert_eq!(handle.as_raw_handle(), number); + File::from(handle).write_all(b"still open").unwrap(); +} + #[test] fn creation_checks_the_complete_actual_command_line_at_the_windows_boundary() { let (_directory, vm) = vm_dir(); diff --git a/nvx-rs/tests/checked_handoff.rs b/nvx-rs/tests/checked_handoff.rs index 8f1c7b07b..0d21eba1a 100644 --- a/nvx-rs/tests/checked_handoff.rs +++ b/nvx-rs/tests/checked_handoff.rs @@ -179,6 +179,55 @@ fn fixture_arguments(name: &str) -> Vec { .to_vec() } +/// A VMM fixture that exits with the requested status once the caller lets it go. +#[test] +#[ignore = "fixture that the exit-status test runs as a VMM"] +fn exit_status_fixture() { + let directory = env::current_dir().unwrap(); + if !directory.join(VMM_MARKER).exists() { + return; + } + let mut capability = [0; CAPABILITY_LEN]; + io::stdin().read_exact(&mut capability).unwrap(); + assert_eq!(capability, CAPABILITY); + let deadline = Instant::now() + VMM_LIFETIME; + while !directory.join(GO_MARKER).exists() { + if directory.join(EXIT_MARKER).exists() || Instant::now() >= deadline { + return; + } + thread::sleep(Duration::from_millis(5)); + } + let status = fs::read_to_string(directory.join("exit-status")).unwrap(); + std::process::exit(status.parse().unwrap()); +} + +#[test] +fn launched_waits_return_and_retain_the_actual_exit_status() { + for status in [0, 17] { + let mut vmm = VmmDirectory::new(); + fs::write(vmm.path().join("exit-status"), status.to_string()).unwrap(); + let command = VmmCommand::new( + env::current_exe().unwrap(), + fixture_arguments("exit_status_fixture"), + vmm.path(), + ); + let log = File::create(vmm.path().join("vmm.log")).unwrap(); + vmm.launched = Some(supervisor::spawn(&command, &CAPABILITY, log).unwrap()); + assert_eq!( + vmm.launched + .as_mut() + .unwrap() + .wait_timeout(Duration::ZERO) + .unwrap(), + None + ); + File::create(vmm.path().join(GO_MARKER)).unwrap(); + let launched = vmm.launched.as_mut().unwrap(); + assert_eq!(launched.wait_timeout(TIMEOUT).unwrap(), Some(status)); + assert_eq!(launched.wait_timeout(Duration::ZERO).unwrap(), Some(status)); + } +} + /// Returns the arguments that pass a VMM fixture the `--microvm-sandbox-block` values of /// `prepared`. fn block_arguments(prepared: &PreparedLaunch) -> Vec { From 53c4fda1f0e36c5303e2d666f055dd7f6421ec27 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 01:29:06 -0800 Subject: [PATCH 2/6] host: reconcile the external example and API snapshots Initialize the merged cold-boot capture option explicitly for the external launcher. Record the platform public API snapshots generated from the private source checkpoint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6789d491-7d13-4929-bc64-4edfec93ba60 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c6a9302c-f126-4231-832e-22426190bd6b Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89997040-ff41-4516-a86a-d81422d5a79f --- nvx-rs/examples/external-launch.rs | 1 + nvx-rs/public-api/linux.txt | 12 ++++++++++++ nvx-rs/public-api/windows.txt | 12 ++++++++++++ 3 files changed, 25 insertions(+) diff --git a/nvx-rs/examples/external-launch.rs b/nvx-rs/examples/external-launch.rs index 3a10480c1..b62daeb04 100644 --- a/nvx-rs/examples/external-launch.rs +++ b/nvx-rs/examples/external-launch.rs @@ -65,6 +65,7 @@ fn main() -> Result<(), Box> { console: ConsoleAttachment::Listener, boot: Some(Boot { command_line: "quiet loglevel=0".to_owned(), + capture: None, }), }; vm::create_private_dir(Path::new(&args[5]))?; diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index cc479bde1..8630bb9fc 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -3276,6 +3276,7 @@ method nvx::host::supervisor::Launched::detach method nvx::host::supervisor::Launched::id method nvx::host::supervisor::Launched::kill_and_wait method nvx::host::supervisor::Launched::process +method nvx::host::supervisor::Launched::wait_timeout enum nvx::host::supervisor::Reattached variant nvx::host::supervisor::Reattached::Exited field nvx::host::supervisor::Reattached::Exited::record @@ -3766,6 +3767,7 @@ fn nvx::host::transport::connect_verified mod nvx::host::vm struct nvx::host::vm::Capability method nvx::host::vm::Capability::as_bytes +struct nvx::host::vm::InheritedHandle struct nvx::host::vm::Launched field nvx::host::vm::Launched::capability field nvx::host::vm::Launched::outputs @@ -3776,6 +3778,7 @@ method nvx::host::vm::Prepared::arguments method nvx::host::vm::Prepared::capability method nvx::host::vm::Prepared::capture_context method nvx::host::vm::Prepared::inherit +method nvx::host::vm::Prepared::into_command method nvx::host::vm::Prepared::log method nvx::host::vm::Prepared::nic method nvx::host::vm::Prepared::outputs @@ -3800,6 +3803,15 @@ field nvx::host::vm::VmOutputs::console field nvx::host::vm::VmOutputs::endpoint field nvx::host::vm::VmOutputs::log field nvx::host::vm::VmOutputs::outcome +struct nvx::host::vm::VmmCommand +field nvx::host::vm::VmmCommand::arguments +field nvx::host::vm::VmmCommand::breakaway_from_job +field nvx::host::vm::VmmCommand::capability +field nvx::host::vm::VmmCommand::inherit +field nvx::host::vm::VmmCommand::log +field nvx::host::vm::VmmCommand::outputs +field nvx::host::vm::VmmCommand::program +field nvx::host::vm::VmmCommand::working_dir fn nvx::host::vm::create fn nvx::host::vm::create_private_dir fn nvx::host::vm::reattach diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 248f6add5..feba67b2d 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -1861,6 +1861,7 @@ method nvx::host::supervisor::Launched::detach method nvx::host::supervisor::Launched::id method nvx::host::supervisor::Launched::kill_and_wait method nvx::host::supervisor::Launched::process +method nvx::host::supervisor::Launched::wait_timeout enum nvx::host::supervisor::Reattached variant nvx::host::supervisor::Reattached::Exited field nvx::host::supervisor::Reattached::Exited::record @@ -2351,6 +2352,7 @@ fn nvx::host::transport::connect_verified mod nvx::host::vm struct nvx::host::vm::Capability method nvx::host::vm::Capability::as_bytes +struct nvx::host::vm::InheritedHandle struct nvx::host::vm::Launched field nvx::host::vm::Launched::capability field nvx::host::vm::Launched::outputs @@ -2361,6 +2363,7 @@ method nvx::host::vm::Prepared::arguments method nvx::host::vm::Prepared::capability method nvx::host::vm::Prepared::capture_context method nvx::host::vm::Prepared::inherit +method nvx::host::vm::Prepared::into_command method nvx::host::vm::Prepared::log method nvx::host::vm::Prepared::nic method nvx::host::vm::Prepared::outputs @@ -2385,6 +2388,15 @@ field nvx::host::vm::VmOutputs::console field nvx::host::vm::VmOutputs::endpoint field nvx::host::vm::VmOutputs::log field nvx::host::vm::VmOutputs::outcome +struct nvx::host::vm::VmmCommand +field nvx::host::vm::VmmCommand::arguments +field nvx::host::vm::VmmCommand::breakaway_from_job +field nvx::host::vm::VmmCommand::capability +field nvx::host::vm::VmmCommand::inherit +field nvx::host::vm::VmmCommand::log +field nvx::host::vm::VmmCommand::outputs +field nvx::host::vm::VmmCommand::program +field nvx::host::vm::VmmCommand::working_dir fn nvx::host::vm::create fn nvx::host::vm::create_private_dir fn nvx::host::vm::reattach From 09c33213d45f3b00d0c12cd2bf47c3a4ae320881 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 06:46:12 -0800 Subject: [PATCH 3/6] host: require trusted log jobs for external VM launches Expose the existing strict spawn path for caller-owned VM commands and use it in the external launcher, preserving the legacy spawn fallback. Exercise real Windows ACL, object-type and object-manager-link collisions before the process-creation window, and retain the permissive non-disk-log behavior only for ordinary spawns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d5cb71c8-5f7f-4248-836b-4aae842c213d Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c6a9302c-f126-4231-832e-22426190bd6b Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89997040-ff41-4516-a86a-d81422d5a79f --- nvx-rs/README.md | 12 +++- nvx-rs/examples/external-launch.rs | 4 +- nvx-rs/public-api/linux.txt | 1 + nvx-rs/public-api/windows.txt | 1 + nvx-rs/src/host/platform/windows/tests.rs | 86 +++++++++++++++++++++++ nvx-rs/src/host/spawn.rs | 20 ++++-- nvx-rs/src/host/supervisor/mod.rs | 6 +- 7 files changed, 119 insertions(+), 11 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 73d5c5abb..3fe00bfb2 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -581,9 +581,9 @@ Read the resolved NIC or capture context, or build an activation record, before consuming `Prepared` into the command. The compiled [external-launch example](examples/external-launch.rs) consumes -this command, uses the existing public low-level spawn primitive without a -sandbox store, checks the cold guest's boot marker and shell response, then asks -it to exit cleanly. It requires the VMM's actual exit status 0 and a successful +this command, uses `supervisor::spawn_vm` without a sandbox store, checks the +cold guest's boot marker and shell response, then asks it to exit cleanly. +It requires the VMM's actual exit status 0 and a successful outcome with complete teardown. Build with `cargo build --locked --example external-launch` and supply the backend, OpenVMM, kernel, initramfs, new VM directory, and the three artifact SHA-256 digests from @@ -1115,6 +1115,12 @@ after, and returns a `Starting` launch that the caller commits once the VMM serves its control endpoint, or abandons. A launch that fails never leaves an unrecorded VMM running. +`spawn_vm` starts a caller-owned command without a store, with the same strict +Windows log-job requirement as `vm::spawn`: an untrusted job name or a log that +cannot name a job is refused before child creation. The legacy `spawn` keeps +its fallback without a job for those logs and names. Other spawn behavior is +shared. + `VmmCommand::inherit` lists the descriptors (Linux) or handles (Windows) that the VMM inherits under their numbers, besides its standard streams, and it inherits nothing else of the caller's: on Linux, they must be close-on-exec, a diff --git a/nvx-rs/examples/external-launch.rs b/nvx-rs/examples/external-launch.rs index b62daeb04..593617f00 100644 --- a/nvx-rs/examples/external-launch.rs +++ b/nvx-rs/examples/external-launch.rs @@ -1,4 +1,4 @@ -//! A caller-owned cold launch, using the existing low-level spawn primitive without a store. +//! A caller-owned cold launch, using the strict low-level spawn primitive without a store. //! //! Run the compiled example with a backend, OpenVMM, kernel, initramfs, new VM directory, and //! the three artifact SHA-256 digests from their installation metadata, in that order. @@ -87,7 +87,7 @@ fn main() -> Result<(), Box> { breakaway_from_job, inherit: inherit.iter().map(Inherited::new).collect(), }; - let mut launched = supervisor::spawn(&command, capability.as_bytes(), log)?; + let mut launched = supervisor::spawn_vm(&command, capability.as_bytes(), log)?; let result = (|| -> Result<(), Box> { let process = launched.process()?; let deadline = Instant::now() + Duration::from_secs(60); diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 8630bb9fc..dfbf5c973 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -3323,6 +3323,7 @@ fn nvx::host::supervisor::lock_spawns fn nvx::host::supervisor::reattach fn nvx::host::supervisor::spawn fn nvx::host::supervisor::spawn_command +fn nvx::host::supervisor::spawn_vm mod nvx::host::template struct nvx::host::template::Acquired field nvx::host::template::Acquired::lease diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index feba67b2d..3eb1321db 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -1908,6 +1908,7 @@ fn nvx::host::supervisor::lock_spawns fn nvx::host::supervisor::reattach fn nvx::host::supervisor::spawn fn nvx::host::supervisor::spawn_command +fn nvx::host::supervisor::spawn_vm mod nvx::host::template struct nvx::host::template::Acquired field nvx::host::template::Acquired::lease diff --git a/nvx-rs/src/host/platform/windows/tests.rs b/nvx-rs/src/host/platform/windows/tests.rs index 7da84f781..54440c3ad 100644 --- a/nvx-rs/src/host/platform/windows/tests.rs +++ b/nvx-rs/src/host/platform/windows/tests.rs @@ -1,3 +1,4 @@ +use std::cell::Cell; use std::ffi::OsString; use std::fs::{self, OpenOptions}; use std::io::{self, Read, Write}; @@ -5,6 +6,7 @@ use std::mem; use std::os::windows::fs::OpenOptionsExt; use std::os::windows::io::AsHandle; use std::path::Path; +use std::rc::Rc; use std::thread; use std::time::{Duration, Instant}; @@ -940,6 +942,81 @@ fn local_link(name: &[u16], target: &[u16]) -> OwnedHandle { owned(link).unwrap() } +#[test] +fn vm_spawns_refuse_untrusted_log_job_names_before_creating_a_child() { + use crate::host::supervisor::{self, VmmCommand}; + use crate::protocol::CAPABILITY_LEN; + + let directory = tempfile::tempdir().unwrap(); + let log = fs::File::create(directory.path().join("vmm.log")).unwrap(); + let id = log_job_id(&log).unwrap().unwrap(); + let name = log_job_name(id); + let system = std::env::var_os("SystemRoot").unwrap(); + let command = VmmCommand::new( + Path::new(&system).join("System32").join("cmd.exe"), + ["/c", "exit", "0"].map(OsString::from).to_vec(), + directory.path(), + ); + let refused = || { + let entered = Rc::new(Cell::new(false)); + let observed = Rc::clone(&entered); + let result = with_spawn_window( + move || observed.set(true), + || supervisor::spawn_vm(&command, &[7; CAPABILITY_LEN], log.try_clone().unwrap()), + ); + let error = match result { + Err(error) => error, + Ok(child) => { + assert!(child.kill_and_wait(), "the unexpected child did not exit"); + panic!("an untrusted log-job name started a child"); + } + }; + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}"); + assert!( + !entered.get(), + "a refused spawn entered the creation window" + ); + }; + + for sddl in ["D:P", "D:P(A;;GA;;;WD)"] { + let other = job_with_descriptor(&name, sddl); + refused(); + drop(other); + } + // SAFETY: the name is NUL-terminated; this event occupies only this test's fresh log name. + let event = owned(unsafe { CreateEventW(ptr::null(), 1, 0, name.as_ptr()) }).unwrap(); + refused(); + drop(event); + + let other_log = fs::File::create(directory.path().join("other.log")).unwrap(); + let other = create_log_job(&other_log).unwrap().unwrap(); + let link = local_link(&name, &object_name(raw(&other)).unwrap()); + refused(); + drop(link); + drop(other); + let elsewhere: Vec = local_directory() + .unwrap() + .into_iter() + .chain(format!(r"\nvx-vm-spawn-{:032x}", id.1).encode_utf16()) + .collect(); + let link = local_link(&name, &elsewhere); + refused(); + drop(link); + + let entered = Rc::new(Cell::new(false)); + let observed = Rc::clone(&entered); + let child = with_spawn_window( + move || observed.set(true), + || supervisor::spawn_vm(&command, &[7; CAPABILITY_LEN], log), + ) + .unwrap(); + assert!(child.kill_and_wait(), "the trusted child did not exit"); + assert!( + entered.get(), + "the successful spawn bypassed the observation" + ); +} + #[test] fn what_a_link_under_the_name_of_a_logs_job_leads_to_is_not_the_logs() { let directory = tempfile::tempdir().unwrap(); @@ -1033,6 +1110,15 @@ fn a_vmm_whose_log_is_no_file_of_a_disk_volume_starts_without_a_job() { std::env::temp_dir(), ); for log in [null, pipe] { + let error = + match supervisor::spawn_vm(&command, &[7; CAPABILITY_LEN], log.try_clone().unwrap()) { + Err(error) => error, + Ok(child) => { + assert!(child.kill_and_wait(), "the unexpected child did not exit"); + panic!("a strict spawn accepted a log without a job"); + } + }; + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}"); let launched = supervisor::spawn(&command, &[7; CAPABILITY_LEN], log).unwrap(); assert!(launched.kill_and_wait(), "the stand-in did not exit"); } diff --git a/nvx-rs/src/host/spawn.rs b/nvx-rs/src/host/spawn.rs index f767b756a..e3ba23a12 100644 --- a/nvx-rs/src/host/spawn.rs +++ b/nvx-rs/src/host/spawn.rs @@ -21,9 +21,9 @@ use crate::host::process::POLL_INTERVAL; use crate::host::process::{BootId, KILL_TIMEOUT, Process, ProcessRef}; use crate::protocol::CAPABILITY_LEN; -/// How [`spawn`] starts a VMM: its executable, arguments, and working directory, whether it -/// leaves the caller's job object on Windows, and the descriptors or handles of the caller that it -/// inherits besides its standard streams. +/// How [`spawn`] or [`spawn_vm`] starts a VMM: its executable, arguments, and working directory, +/// whether it leaves the caller's job object on Windows, and the descriptors or handles of the +/// caller that it inherits besides its standard streams. #[derive(Debug, Clone)] pub struct VmmCommand<'a> { /// The VMM's executable. @@ -109,8 +109,18 @@ pub fn spawn( spawn_inner(command, capability, log, false) } -/// Starts a library VM only with a trusted log-named job on Windows. -pub(crate) fn spawn_vm( +/// Starts a VMM with the strict launch contract of [`crate::host::vm::VmmCommand`]. +/// +/// Uses [`spawn`]'s standard streams, inheritance allowlist, detachment, and process lifetime. +/// Unlike [`spawn`], on Windows this requires a trusted log-named job: a log that is not a file +/// of a disk volume, or an untrusted object or object-manager link under its job name, fails with +/// [`io::ErrorKind::PermissionDenied`] before any child is created. A job of the user already +/// holding that name fails with [`io::ErrorKind::AlreadyExists`], as in [`spawn`]. +/// +/// On other hosts this has the same behavior and errors as [`spawn`]. The caller retains its +/// owned inherited resources until the spawn returns, and keeps or ends the returned child as +/// [`Launched`] describes. +pub fn spawn_vm( command: &VmmCommand<'_>, capability: &[u8; CAPABILITY_LEN], log: File, diff --git a/nvx-rs/src/host/supervisor/mod.rs b/nvx-rs/src/host/supervisor/mod.rs index de970be30..5596cce43 100644 --- a/nvx-rs/src/host/supervisor/mod.rs +++ b/nvx-rs/src/host/supervisor/mod.rs @@ -26,6 +26,10 @@ //! A launch that fails at any step ends as [`Launch::abandon`] ends it, and never leaves an //! unrecorded VMM running: a VMM whose identity is not recorded is ended through its handle first. //! +//! A caller launching an owned [`crate::host::vm::VmmCommand`] without a store uses [`spawn_vm`], +//! which refuses an untrusted Windows log-job name rather than taking [`spawn`]'s fallback +//! without a job. +//! //! ```no_run //! use std::time::{Duration, Instant}; //! @@ -234,4 +238,4 @@ pub(crate) use self::reattach::reattach_reporting; pub use self::reattach::{Reattached, reattach}; pub use self::vmm::{StopOutcome, Stopped, Vmm}; pub use super::platform::inherit::{Inherited, SpawnLock, lock_spawns, spawn_command}; -pub use super::spawn::{Launched, VmmCommand, spawn}; +pub use super::spawn::{Launched, VmmCommand, spawn, spawn_vm}; From fafa94c5981a1083b743be5d3b57710d0d6b2812 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 07:40:49 -0800 Subject: [PATCH 4/6] host: reconcile the external launcher with verified exit results Preserve the complete owned-command and strict-log-job handoff while replaying the PR onto the current feature input. Treat a missing exit proof as a timeout and propagate the outcome reader error before checking for a missing report. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d5cb71c8-5f7f-4248-836b-4aae842c213d Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c6a9302c-f126-4231-832e-22426190bd6b Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89997040-ff41-4516-a86a-d81422d5a79f --- nvx-rs/examples/external-launch.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nvx-rs/examples/external-launch.rs b/nvx-rs/examples/external-launch.rs index 593617f00..ef12e4d0b 100644 --- a/nvx-rs/examples/external-launch.rs +++ b/nvx-rs/examples/external-launch.rs @@ -106,7 +106,7 @@ fn main() -> Result<(), Box> { Some(deadline.saturating_duration_since(Instant::now())), )?; read_until(console.as_mut(), b"\nNVX-EXTERNAL-LAUNCH-OK", deadline)?; - if !process.wait(deadline)? { + if process.wait(deadline)?.is_none() { return Err(io::Error::new(io::ErrorKind::TimedOut, "VM did not exit").into()); } match launched.wait_timeout(Duration::ZERO)? { @@ -116,7 +116,7 @@ fn main() -> Result<(), Box> { } None => return Err(io::Error::other("VMM exit status is unavailable").into()), } - let outcome = report::read(&outputs.outcome) + let outcome = report::read(&outputs.outcome)? .ok_or_else(|| io::Error::other("missing or invalid VM outcome"))?; if outcome.category.as_deref() != Some("success") || outcome.status_code != Some(0) From 4a04aed8a252af244804348172d8faf2ab15bd3f Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 10:05:11 -0800 Subject: [PATCH 5/6] host: confirm external-launch cleanup after failed setup Expose the existing strict child termination and confirmed-wait operation for external callers. Use it on example setup errors, preserve both failure messages, and cover running and already-reaped children through the public host-only API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d5cb71c8-5f7f-4248-836b-4aae842c213d Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c6a9302c-f126-4231-832e-22426190bd6b Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89997040-ff41-4516-a86a-d81422d5a79f --- nvx-rs/README.md | 5 ++++- nvx-rs/examples/external-launch.rs | 7 +++++-- nvx-rs/public-api/linux.txt | 1 + nvx-rs/public-api/windows.txt | 1 + nvx-rs/src/host/spawn.rs | 27 +++++++++++++++++---------- nvx-rs/tests/checked_handoff.rs | 18 ++++++++++++++++++ 6 files changed, 46 insertions(+), 13 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 3fe00bfb2..fddbb10a8 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -1119,7 +1119,10 @@ unrecorded VMM running. Windows log-job requirement as `vm::spawn`: an untrusted job name or a log that cannot name a job is refused before child creation. The legacy `spawn` keeps its fallback without a job for those logs and names. Other spawn behavior is -shared. +shared. If setup fails after a child is created, including failed process +verification, `Launched::kill_and_confirm_exit` waits without a deadline for +its exit and reaps it on Unix. The caller reports any cleanup error; the +legacy `kill_and_wait` remains time-bounded and may release a running child. `VmmCommand::inherit` lists the descriptors (Linux) or handles (Windows) that the VMM inherits under their numbers, besides its standard streams, and it diff --git a/nvx-rs/examples/external-launch.rs b/nvx-rs/examples/external-launch.rs index ef12e4d0b..7a41676ef 100644 --- a/nvx-rs/examples/external-launch.rs +++ b/nvx-rs/examples/external-launch.rs @@ -129,9 +129,12 @@ fn main() -> Result<(), Box> { Ok(()) })(); if let Err(error) = result { - if !launched.kill_and_wait() { + if let Err(cleanup) = launched.kill_and_confirm_exit() { return Err( - io::Error::other(format!("{error}; could not reap the launched VM")).into(), + io::Error::other(format!( + "{error}; could not confirm exit of the launched VM: {cleanup}" + )) + .into(), ); } return Err(error); diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index dfbf5c973..9d90dd239 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -3274,6 +3274,7 @@ method nvx::host::supervisor::Inherited::proc_path struct nvx::host::supervisor::Launched method nvx::host::supervisor::Launched::detach method nvx::host::supervisor::Launched::id +method nvx::host::supervisor::Launched::kill_and_confirm_exit method nvx::host::supervisor::Launched::kill_and_wait method nvx::host::supervisor::Launched::process method nvx::host::supervisor::Launched::wait_timeout diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 3eb1321db..bb4241b0b 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -1859,6 +1859,7 @@ method nvx::host::supervisor::Inherited::new struct nvx::host::supervisor::Launched method nvx::host::supervisor::Launched::detach method nvx::host::supervisor::Launched::id +method nvx::host::supervisor::Launched::kill_and_confirm_exit method nvx::host::supervisor::Launched::kill_and_wait method nvx::host::supervisor::Launched::process method nvx::host::supervisor::Launched::wait_timeout diff --git a/nvx-rs/src/host/spawn.rs b/nvx-rs/src/host/spawn.rs index e3ba23a12..11561d7be 100644 --- a/nvx-rs/src/host/spawn.rs +++ b/nvx-rs/src/host/spawn.rs @@ -181,14 +181,15 @@ fn spawn_inner( } } -/// A VMM that [`spawn`] started, which the caller has not released yet: on Unix, its child -/// process; on Windows, its process handle. On Unix the child's ID names no other process until -/// [`wait_timeout`](Self::wait_timeout) reaps it; hold it with [`process`](Self::process) before -/// waiting to keep a verified handle after it exits. +/// A VMM that [`spawn`] or [`spawn_vm`] started, which the caller has not released yet: on Unix, +/// its child process; on Windows, its process handle. On Unix the child's ID names no other +/// process until [`wait_timeout`](Self::wait_timeout) reaps it; hold it with +/// [`process`](Self::process) before waiting to keep a verified handle after it exits. /// /// Record it with [`Launch::record_process`](crate::host::store::Launch::record_process), or hold /// it with [`process`](Self::process). Then release it with [`detach`](Self::detach), or end it -/// with [`kill_and_wait`](Self::kill_and_wait); the [`Process`] stays valid either way. +/// with [`kill_and_confirm_exit`](Self::kill_and_confirm_exit); the [`Process`] stays valid +/// either way. [`kill_and_wait`](Self::kill_and_wait) provides legacy, time-bounded cleanup. pub struct Launched { #[cfg(not(windows))] /// Child process retained until it is detached or terminated. @@ -224,9 +225,9 @@ impl Launched { /// The caller has not reaped the process, so its ID and start time still identify it, and /// the reference of the `Process` is the one to record: a successor of the caller can find /// the process again with [`Process::open`]. The `Process` stays valid after - /// [`detach`](Self::detach) or [`kill_and_wait`](Self::kill_and_wait) releases this - /// `Launched`. A process that exited already keeps its identity, and the `Process` reports - /// that it exited. + /// [`detach`](Self::detach), [`kill_and_confirm_exit`](Self::kill_and_confirm_exit), or + /// [`kill_and_wait`](Self::kill_and_wait) releases this `Launched`. A process that exited + /// already keeps its identity, and the `Process` reports that it exited. /// /// Fails if the identity of the process or of the current boot cannot be read, and with /// [`io::ErrorKind::Unsupported`] on hosts other than Linux and Windows. @@ -300,8 +301,14 @@ impl Launched { /// Terminates this owned child and waits without a deadline for proof of its exit. /// - /// Unlike legacy cleanup's timeout, this never detaches a child after a failed verification. - pub(crate) fn kill_and_confirm_exit(self) -> io::Result<()> { + /// Unlike [`kill_and_wait`](Self::kill_and_wait), this has no timeout and never hands off a + /// child to a background reaper. On Unix a successful call reaps the child. Even if + /// termination fails, it waits for the child's exit; a child that exited already succeeds. + /// Use this when setup fails after [`spawn_vm`] created a child, including when + /// [`process`](Self::process) cannot verify its identity. + /// + /// Fails if waiting cannot confirm exit, retaining both failures if termination also failed. + pub fn kill_and_confirm_exit(self) -> io::Result<()> { #[cfg(windows)] return self.process.terminate_and_wait(); #[cfg(not(windows))] diff --git a/nvx-rs/tests/checked_handoff.rs b/nvx-rs/tests/checked_handoff.rs index 0d21eba1a..f8ff09199 100644 --- a/nvx-rs/tests/checked_handoff.rs +++ b/nvx-rs/tests/checked_handoff.rs @@ -225,9 +225,27 @@ fn launched_waits_return_and_retain_the_actual_exit_status() { let launched = vmm.launched.as_mut().unwrap(); assert_eq!(launched.wait_timeout(TIMEOUT).unwrap(), Some(status)); assert_eq!(launched.wait_timeout(Duration::ZERO).unwrap(), Some(status)); + vmm.launched.take().unwrap().kill_and_confirm_exit().unwrap(); } } +#[test] +fn strict_cleanup_terminates_and_confirms_a_running_child() { + let mut vmm = VmmDirectory::new(); + let command = VmmCommand::new( + env::current_exe().unwrap(), + fixture_arguments("vmm_fixture"), + vmm.path(), + ); + let log = File::create(vmm.path().join("vmm.log")).unwrap(); + vmm.launched = Some(supervisor::spawn_vm(&command, &CAPABILITY, log).unwrap()); + vmm.report(STARTED_REPORT); + let process = vmm.launched.as_ref().unwrap().process().unwrap(); + assert!(!process.has_exited().unwrap()); + vmm.launched.take().unwrap().kill_and_confirm_exit().unwrap(); + assert!(process.has_exited().unwrap()); +} + /// Returns the arguments that pass a VMM fixture the `--microvm-sandbox-block` values of /// `prepared`. fn block_arguments(prepared: &PreparedLaunch) -> Vec { From cfb4363ebc981a3810d5f6f910ba01bc94a99708 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 10:13:02 -0800 Subject: [PATCH 6/6] style: format confirmed external-launch cleanup Apply the repository rustfmt output from the pre-test formatting check without changing cleanup behavior or assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d5cb71c8-5f7f-4248-836b-4aae842c213d Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c6a9302c-f126-4231-832e-22426190bd6b Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 89997040-ff41-4516-a86a-d81422d5a79f --- nvx-rs/examples/external-launch.rs | 10 ++++------ nvx-rs/tests/checked_handoff.rs | 12 ++++++++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/nvx-rs/examples/external-launch.rs b/nvx-rs/examples/external-launch.rs index 7a41676ef..634d5e548 100644 --- a/nvx-rs/examples/external-launch.rs +++ b/nvx-rs/examples/external-launch.rs @@ -130,12 +130,10 @@ fn main() -> Result<(), Box> { })(); if let Err(error) = result { if let Err(cleanup) = launched.kill_and_confirm_exit() { - return Err( - io::Error::other(format!( - "{error}; could not confirm exit of the launched VM: {cleanup}" - )) - .into(), - ); + return Err(io::Error::other(format!( + "{error}; could not confirm exit of the launched VM: {cleanup}" + )) + .into()); } return Err(error); } diff --git a/nvx-rs/tests/checked_handoff.rs b/nvx-rs/tests/checked_handoff.rs index f8ff09199..4f7f7f03c 100644 --- a/nvx-rs/tests/checked_handoff.rs +++ b/nvx-rs/tests/checked_handoff.rs @@ -225,7 +225,11 @@ fn launched_waits_return_and_retain_the_actual_exit_status() { let launched = vmm.launched.as_mut().unwrap(); assert_eq!(launched.wait_timeout(TIMEOUT).unwrap(), Some(status)); assert_eq!(launched.wait_timeout(Duration::ZERO).unwrap(), Some(status)); - vmm.launched.take().unwrap().kill_and_confirm_exit().unwrap(); + vmm.launched + .take() + .unwrap() + .kill_and_confirm_exit() + .unwrap(); } } @@ -242,7 +246,11 @@ fn strict_cleanup_terminates_and_confirms_a_running_child() { vmm.report(STARTED_REPORT); let process = vmm.launched.as_ref().unwrap().process().unwrap(); assert!(!process.has_exited().unwrap()); - vmm.launched.take().unwrap().kill_and_confirm_exit().unwrap(); + vmm.launched + .take() + .unwrap() + .kill_and_confirm_exit() + .unwrap(); assert!(process.has_exited().unwrap()); }