diff --git a/Cargo.lock b/Cargo.lock index 42d7f41398..9c0fa10d62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1023,6 +1023,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1739,6 +1749,7 @@ dependencies = [ "env_logger", "fallible-iterator", "flatbuffers", + "flate2", "framehop", "gdbstub", "gdbstub_arch", @@ -1774,6 +1785,7 @@ dependencies = [ "serial_test", "sha2 0.11.0", "signal-hook-registry", + "tar", "tempfile", "termcolor", "thiserror", @@ -3714,6 +3726,17 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.13.3" @@ -4696,6 +4719,16 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xi-unicode" version = "0.3.0" diff --git a/docs/snapshot-oci-format.md b/docs/snapshot-oci-format.md index 4c51fee9e6..9dbe170d19 100644 --- a/docs/snapshot-oci-format.md +++ b/docs/snapshot-oci-format.md @@ -111,6 +111,40 @@ one manifest in `index.json`, is rejected. [`OciReference`]: https://docs.rs/hyperlight-host/latest/hyperlight_host/sandbox/snapshot/enum.OciReference.html +## Archive form (`.tar` / `.tar.gz`) + +The same layout can be stored as a single file instead of a directory. +`Snapshot::save_archive(path, tag, format)` packs the OCI layout it +would otherwise write into a `.tar` or `.tar.gz` archive, with the +layout entries (`oci-layout`, `index.json`, `blobs/sha256/...`) at the +archive root. `Snapshot::load_archive` and +`Snapshot::checked_load_archive` read one back. + +The archive is exactly the directory layout, so every structural, +digest, arch / hypervisor / ABI, and bounds check runs unchanged. The +archive is written directly to the tar stream: the (large) memory image +is streamed once from its in-memory mapping, with no intermediate copy +on disk. Loading extracts into a temporary directory and then loads from +it using the directory loader. Because the memory image is mmap'd from +the extracted file, the temporary directory is kept alive for the +lifetime of the loaded snapshot and removed when it is dropped. + +The format is chosen by [`ArchiveFormat`]: `Tar` (uncompressed) or +`TarGz` (gzip). `ArchiveFormat::from_path` infers it from a `.tar`, +`.tar.gz`, or `.tgz` extension; `load_archive` also falls back to +sniffing the gzip magic bytes. The memory image is mostly zero pages and +compresses by tens of times, so `TarGz` is a large space win for a +modest CPU cost. + +`save_archive` writes atomically (stream into a sibling temp file, then +rename over `path`) and merges into an existing archive under `tag`, the +same merge semantics as `save` on a directory: blobs from the existing +archive are streamed straight into the new one, so — exactly as with the +directory form — blobs orphaned by a replaced tag remain present rather +than being dropped. + +[`ArchiveFormat`]: https://docs.rs/hyperlight-host/latest/hyperlight_host/sandbox/snapshot/enum.ArchiveFormat.html + ## Portability Snapshot images are bound to a specific CPU architecture and diff --git a/src/hyperlight_host/Cargo.toml b/src/hyperlight_host/Cargo.toml index 36e4f0df02..af9a6fcbce 100644 --- a/src/hyperlight_host/Cargo.toml +++ b/src/hyperlight_host/Cargo.toml @@ -56,6 +56,8 @@ oci-spec = { version = "0.10", default-features = false, features = ["image"] } sha2 = "0.11" hex = "0.4" tempfile = "3.27.0" +tar = "0.4" +flate2 = "1" [target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ diff --git a/src/hyperlight_host/src/error.rs b/src/hyperlight_host/src/error.rs index c6738374d0..66fb7c29de 100644 --- a/src/hyperlight_host/src/error.rs +++ b/src/hyperlight_host/src/error.rs @@ -76,6 +76,10 @@ pub enum HyperlightError { #[error("Execution was cancelled by the host.")] ExecutionCanceledByHost(), + /// Guest execution was paused by the host + #[error("Execution was paused by the host.")] + ExecutionPaused(), + /// Accessing the value of a flatbuffer parameter failed #[error("Failed to get a value from flat buffer parameter")] FailedToGetValueFromParameter(), @@ -369,6 +373,7 @@ impl HyperlightError { | HyperlightError::CheckedAddOverflow(_, _) | HyperlightError::CStringConversionError(_) | HyperlightError::Error(_) + | HyperlightError::ExecutionPaused() | HyperlightError::FailedToGetValueFromParameter() | HyperlightError::FieldIsMissingInGuestLogData(_) | HyperlightError::GuestBinVersionMismatch { .. } diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs index 56f7d635d8..ff997ce889 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs @@ -136,6 +136,7 @@ impl DispatchGuestCallError { match self { // These errors poison the sandbox because they can leave it in an inconsistent state // by returning before the guest can unwind properly + DispatchGuestCallError::Run(RunVmError::ExecutionPaused) => false, DispatchGuestCallError::Run(_) => true, DispatchGuestCallError::SetupRegs(_) | DispatchGuestCallError::Uninitialized => false, } @@ -153,6 +154,10 @@ impl DispatchGuestCallError { HyperlightError::ExecutionCanceledByHost() } + DispatchGuestCallError::Run(RunVmError::ExecutionPaused) => { + HyperlightError::ExecutionPaused() + } + DispatchGuestCallError::Run(RunVmError::HandleIo(HandleIoError::Outb( HandleOutbError::GuestAborted { code, message }, ))) => HyperlightError::GuestAborted(code, message), @@ -194,6 +199,8 @@ pub enum RunVmError { DebugHandler(#[from] HandleDebugError), #[error("Execution was cancelled by the host")] ExecutionCancelledByHost, + #[error("Execution was paused by the host")] + ExecutionPaused, #[error("Failed to access page: {0}")] PageTableAccess(AccessPageTableError), #[cfg(feature = "trace_guest")] @@ -583,6 +590,10 @@ impl HyperlightVm { self.interrupt_handle.clear_cancel(); } + pub(crate) fn clear_pause(&self) { + self.interrupt_handle.clear_pause(); + } + pub(super) fn run( &mut self, mem_mgr: &mut SandboxMemoryManager, @@ -604,6 +615,12 @@ impl HyperlightVm { // NOTE: `set_running()`` must be called before checking `is_cancelled()` // otherwise we risk missing a call to `kill()` because the vcpu would not be marked as running yet so signals won't be sent + // NOTE: a pending pause is deliberately *not* short-circuited here. + // Doing so could stop the vcpu right after a host call was serviced + // but before hardware advanced RIP past the `OUT` (see the IoOut + // arm below), producing an inconsistent snapshot. Instead we let the + // vcpu re-enter — which lets KVM complete the deferred RIP advance — + // and observe the pause at the next host-call (`IoOut`) boundary. let exit_reason = if self.interrupt_handle.is_cancelled() || self.interrupt_handle.is_debug_interrupted() { @@ -653,6 +670,7 @@ impl HyperlightVm { // - Signals will not be sent let cancel_requested = self.interrupt_handle.is_cancelled(); let debug_interrupted = self.interrupt_handle.is_debug_interrupted(); + let pause_requested = self.interrupt_handle.is_paused(); // ===== KILL() TIMING POINT 6: Before checking exit_reason ===== // If kill() is called and ran to completion BEFORE this line executes: @@ -693,6 +711,35 @@ impl HyperlightVm { } Ok(VmExit::IoOut(port, data)) => { self.handle_io(mem_mgr, host_funcs, port, data)?; + + // A pause may have been requested either before this exit + // was observed (the sticky pause flag was already set) or + // *while* this call was being serviced (a host function, or + // another thread, called `pause()`). In both cases the vcpu + // was not in its run ioctl, so no signal was delivered — + // only the sticky flag was set. Honor it the same way in + // both cases: now that the call has been serviced, re-enter + // the kernel just far enough to finish this `OUT` (advancing + // RIP past it) without executing any further guest + // instructions, then break at that clean, self-consistent + // point. This stops right after the call instead of running + // on to the next host-call boundary, and — crucially — still + // stops even if the guest would otherwise halt before making + // another host call. + // + // Servicing the call before pausing means the host function + // runs exactly once (at pause time): RIP ends up past the + // `OUT` and the guest's input buffer holds the result, so + // this is an ordinary arbitrary-pause point. Resuming it — + // in place or from a restored snapshot — simply continues + // from here with no host call left to replay. + // + // Backends that cannot complete the pending IO this way + // return `false`; for them the pause is left to be honored + // by a subsequent signal-based cancellation. + if self.interrupt_handle.is_paused() && self.vm.complete_pending_io()? { + break Err(RunVmError::ExecutionPaused); + } } Ok(VmExit::MmioRead(addr)) => { let all_regions = self.get_mapped_regions(); @@ -733,11 +780,11 @@ impl HyperlightVm { } } Ok(VmExit::Cancelled()) => { - // If cancellation was not requested for this specific guest function call, + // If no interrupt was actually requested for this specific guest function call, // the vcpu was interrupted by a stale cancellation. This can occur when: // - Linux: A signal from a previous call arrives late // - Windows: WHvCancelRunVirtualProcessor called right after vcpu exits but RUNNING_BIT is still true - if !cancel_requested && !debug_interrupted { + if !cancel_requested && !debug_interrupted && !pause_requested { // Track that an erroneous vCPU kick occurred metrics::counter!(METRIC_ERRONEOUS_VCPU_KICKS).increment(1); // treat this the same as a VmExit::Retry, the cancel was not meant for this call @@ -755,8 +802,16 @@ impl HyperlightVm { } } - metrics::counter!(METRIC_GUEST_CANCELLATION).increment(1); - break Err(RunVmError::ExecutionCancelledByHost); + // Cancel takes priority over pause + if cancel_requested { + metrics::counter!(METRIC_GUEST_CANCELLATION).increment(1); + break Err(RunVmError::ExecutionCancelledByHost); + } + + // Pause was requested — break without poisoning + if pause_requested { + break Err(RunVmError::ExecutionPaused); + } } Ok(VmExit::Unknown(reason)) => { break Err(RunVmError::UnexpectedVmExit(reason)); @@ -774,6 +829,10 @@ impl HyperlightVm { // no need to crashdump this Err(RunVmError::ExecutionCancelledByHost) } + Err(RunVmError::ExecutionPaused) => { + // no need to crashdump this — the VM is paused, not crashed + Err(RunVmError::ExecutionPaused) + } Err(e) => { #[cfg(crashdump)] if self.rt_cfg.guest_core_dump { diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs index e5f94760e1..b6f5eca2bc 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs @@ -69,6 +69,21 @@ use crate::sandbox::trace::MemTraceInfo; #[cfg(crashdump)] use crate::sandbox::uninitialized::SandboxRuntimeConfig; +/// Model-specific registers captured and restored as part of a paused snapshot. +/// These cover the syscall entry points and segment base registers that are not +/// part of the special registers, and must be restored for a paused guest to +/// resume correctly (notably KERNEL_GS_BASE for swapgs-based per-CPU state). +const SNAPSHOT_MSRS: &[u32] = &[ + 0xC000_0080, // IA32_EFER + 0xC000_0081, // STAR + 0xC000_0082, // LSTAR + 0xC000_0083, // CSTAR + 0xC000_0084, // SFMASK + 0xC000_0100, // FS_BASE + 0xC000_0101, // GS_BASE + 0xC000_0102, // KERNEL_GS_BASE +]; + impl HyperlightVm { /// Create a new HyperlightVm instance (will not run vm until calling `initialise`) #[instrument(err(Debug), skip_all, parent = Span::current(), level = "Trace")] @@ -270,6 +285,25 @@ impl HyperlightVm { Ok(self.vm.sregs()?) } + /// Get the general-purpose registers for a snapshot. + pub(crate) fn get_snapshot_regs(&self) -> Result { + Ok(self.vm.regs()?) + } + + /// Get the FPU/SSE state for a snapshot. + pub(crate) fn get_snapshot_fpu(&self) -> Result { + Ok(self.vm.fpu()?) + } + + /// Get the model-specific registers that need to be stored in a snapshot. + /// These hold the syscall/segment-base state (EFER, STAR/LSTAR/CSTAR/SFMASK, + /// FS/GS/KERNEL_GS base) that is not covered by the special registers and + /// must be restored for a paused guest to resume correctly. + pub(crate) fn get_snapshot_msrs(&self) -> Result, AccessPageTableError> { + let values = self.vm.read_msrs(SNAPSHOT_MSRS)?; + Ok(SNAPSHOT_MSRS.iter().copied().zip(values).collect()) + } + /// Dispatch a call from the host to the guest using the given pointer /// to the dispatch function _in the guest's address space_. /// @@ -332,6 +366,34 @@ impl HyperlightVm { result } + /// Resume a paused guest call without resetting registers. + /// + /// Unlike [`dispatch_call_from_host`], this does NOT set RIP/RSP/FPU. + /// The vCPU registers are exactly as they were when the VM was paused, + /// so `run()` picks up execution at the exact interrupted instruction. + #[instrument(err(Debug), skip_all, parent = Span::current(), level = "Trace")] + pub(crate) fn dispatch_resume( + &mut self, + mem_mgr: &mut SandboxMemoryManager, + host_funcs: &Arc>, + #[cfg(gdb)] dbg_mem_access_fn: Arc>>, + ) -> std::result::Result<(), DispatchGuestCallError> { + // Do NOT reset registers — they contain the paused state + // Do NOT clear pending_tlb_flush — it was already handled or will be on resume + + // The pause was taken at a clean, self-consistent point: if it happened + // at an `IoOut` (host-call) boundary, the call was already serviced and + // RIP advanced past the `OUT` (see the `IoOut` arm of `run`). There is + // therefore no pending host call to replay — simply re-enter the vcpu. + self.run( + mem_mgr, + host_funcs, + #[cfg(gdb)] + dbg_mem_access_fn, + ) + .map_err(DispatchGuestCallError::Run) + } + /// Resets the following vCPU state: /// - General purpose registers /// - Debug registers @@ -371,6 +433,27 @@ impl HyperlightVm { Ok(()) } + /// Restore general-purpose registers from a snapshot. + pub(crate) fn restore_regs( + &self, + regs: &CommonRegisters, + ) -> std::result::Result<(), RegisterError> { + self.vm.set_regs(regs) + } + + /// Restore FPU/SSE state from a snapshot. + pub(crate) fn restore_fpu(&self, fpu: &CommonFpu) -> std::result::Result<(), RegisterError> { + self.vm.set_fpu(fpu) + } + + /// Restore model-specific registers from a snapshot. + pub(crate) fn restore_msrs( + &self, + msrs: &[(u32, u64)], + ) -> std::result::Result<(), RegisterError> { + self.vm.write_msrs(msrs) + } + // Handle a debug exit #[cfg(gdb)] pub(super) fn handle_debug( diff --git a/src/hyperlight_host/src/hypervisor/mod.rs b/src/hyperlight_host/src/hypervisor/mod.rs index 732f085639..713824104b 100644 --- a/src/hyperlight_host/src/hypervisor/mod.rs +++ b/src/hyperlight_host/src/hypervisor/mod.rs @@ -67,6 +67,12 @@ pub(crate) trait InterruptHandleImpl: InterruptHandle { /// Clear the cancellation request flag fn clear_cancel(&self); + /// Check if pause was requested + fn is_paused(&self) -> bool; + + /// Clear the pause request flag + fn clear_pause(&self); + /// Check if debug interrupt was requested (always returns false when gdb feature is disabled) fn is_debug_interrupted(&self) -> bool; @@ -86,6 +92,20 @@ pub trait InterruptHandle: Send + Sync + Debug { /// This function will block for the duration of the time it takes for the vcpu thread to be interrupted. fn kill(&self) -> bool; + /// Pause the corresponding sandbox. + /// + /// Unlike [`kill()`](Self::kill), pausing preserves the full VM state (registers, + /// instruction pointer, etc.) so that execution can be resumed from exactly where + /// it was interrupted. + /// + /// - If this is called while the sandbox is executing a guest function call, it will + /// interrupt the sandbox and return `true`. + /// - If this is called while the sandbox is not running, it will do nothing and return `false`. + /// + /// # Note + /// This function will block for the duration of the time it takes for the vcpu thread to be interrupted. + fn pause(&self) -> bool; + /// Used by a debugger to interrupt the corresponding sandbox from running. /// /// - If this is called while the vcpu is running, then it will interrupt the vcpu and return `true`. @@ -136,6 +156,7 @@ pub(super) struct LinuxInterruptHandle { impl LinuxInterruptHandle { const RUNNING_BIT: u8 = 1 << 1; const CANCEL_BIT: u8 = 1 << 0; + const PAUSE_BIT: u8 = 1 << 3; #[cfg(gdb)] const DEBUG_INTERRUPT_BIT: u8 = 1 << 2; @@ -155,16 +176,22 @@ impl LinuxInterruptHandle { (running, cancel, debug) } + fn get_pause(&self) -> bool { + let state = self.state.load(Ordering::Acquire); + state & Self::PAUSE_BIT != 0 + } + fn send_signal(&self) -> bool { let signal_number = libc::SIGRTMIN() + self.sig_rt_min_offset as libc::c_int; let mut sent_signal = false; loop { let (running, cancel, debug) = self.get_running_cancel_debug(); + let paused = self.get_pause(); // Check if we should continue sending signals - // Exit if not running OR if neither cancel nor debug_interrupt is set - let should_continue = running && (cancel || debug); + // Exit if not running OR if neither cancel, debug_interrupt, nor pause is set + let should_continue = running && (cancel || debug || paused); if !should_continue { break; @@ -214,6 +241,14 @@ impl InterruptHandleImpl for LinuxInterruptHandle { self.state.fetch_and(!Self::CANCEL_BIT, Ordering::Release); } + fn is_paused(&self) -> bool { + self.state.load(Ordering::Acquire) & Self::PAUSE_BIT != 0 + } + + fn clear_pause(&self) { + self.state.fetch_and(!Self::PAUSE_BIT, Ordering::Release); + } + fn clear_running(&self) { // Release ordering to ensure all vcpu operations are visible before clearing running self.state.fetch_and(!Self::RUNNING_BIT, Ordering::Release); @@ -254,6 +289,15 @@ impl InterruptHandle for LinuxInterruptHandle { self.send_signal() } + fn pause(&self) -> bool { + // Release ordering ensures that any writes before pause() are visible to the vcpu thread + // when it checks is_paused() with Acquire ordering + self.state.fetch_or(Self::PAUSE_BIT, Ordering::Release); + + // Send signals to interrupt the vcpu if it's currently running + self.send_signal() + } + #[cfg(gdb)] fn kill_from_debugger(&self) -> bool { self.state @@ -318,6 +362,7 @@ pub(super) struct PartitionState { impl WindowsInterruptHandle { const RUNNING_BIT: u8 = 1 << 1; const CANCEL_BIT: u8 = 1 << 0; + const PAUSE_BIT: u8 = 1 << 3; #[cfg(gdb)] const DEBUG_INTERRUPT_BIT: u8 = 1 << 2; } @@ -342,6 +387,14 @@ impl InterruptHandleImpl for WindowsInterruptHandle { self.state.fetch_and(!Self::CANCEL_BIT, Ordering::Release); } + fn is_paused(&self) -> bool { + self.state.load(Ordering::Acquire) & Self::PAUSE_BIT != 0 + } + + fn clear_pause(&self) { + self.state.fetch_and(!Self::PAUSE_BIT, Ordering::Release); + } + fn clear_running(&self) { // Release ordering to ensure all vcpu operations are visible before clearing running self.state.fetch_and(!Self::RUNNING_BIT, Ordering::Release); @@ -415,6 +468,30 @@ impl InterruptHandle for WindowsInterruptHandle { unsafe { WHvCancelRunVirtualProcessor(guard.handle, 0, 0).is_ok() } } + fn pause(&self) -> bool { + use windows::Win32::System::Hypervisor::WHvCancelRunVirtualProcessor; + + self.state.fetch_or(Self::PAUSE_BIT, Ordering::Release); + + let state = self.state.load(Ordering::Acquire); + if state & Self::RUNNING_BIT == 0 { + return false; + } + + let guard = match self.partition_state.read() { + Ok(guard) => guard, + Err(e) => { + tracing::error!("Failed to acquire partition_state read lock: {}", e); + return false; + } + }; + + if guard.dropped { + return false; + } + + unsafe { WHvCancelRunVirtualProcessor(guard.handle, 0, 0).is_ok() } + } #[cfg(gdb)] fn kill_from_debugger(&self) -> bool { use windows::Win32::System::Hypervisor::WHvCancelRunVirtualProcessor; diff --git a/src/hyperlight_host/src/hypervisor/regs/x86_64/fpu.rs b/src/hyperlight_host/src/hypervisor/regs/x86_64/fpu.rs index 93907c6a4b..419e560733 100644 --- a/src/hyperlight_host/src/hypervisor/regs/x86_64/fpu.rs +++ b/src/hyperlight_host/src/hypervisor/regs/x86_64/fpu.rs @@ -21,6 +21,7 @@ use std::collections::HashSet; use kvm_bindings::kvm_fpu; #[cfg(mshv3)] use mshv_bindings::FloatingPointUnit; +use serde::{Deserialize, Serialize}; #[cfg(target_os = "windows")] use super::Align16; @@ -30,8 +31,10 @@ use crate::hypervisor::regs::FromWhpRegisterError; pub(crate) const FP_CONTROL_WORD_DEFAULT: u16 = 0x37f; // mask all fp-exception, set rounding to nearest, set precision to 64-bit pub(crate) const MXCSR_DEFAULT: u32 = 0x1f80; // mask simd fp-exceptions, clear exception flags, set rounding to nearest, disable flush-to-zero mode, disable denormals-are-zero mode -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub(crate) struct CommonFpu { + #[serde(serialize_with = "serialize_fpr", deserialize_with = "deserialize_fpr")] pub fpr: [[u8; 16]; 8], pub fcw: u16, pub fsw: u16, @@ -39,10 +42,71 @@ pub(crate) struct CommonFpu { pub last_opcode: u16, pub last_ip: u64, pub last_dp: u64, + #[serde(serialize_with = "serialize_xmm", deserialize_with = "deserialize_xmm")] pub xmm: [[u8; 16]; 16], pub mxcsr: u32, } +fn serialize_fpr( + fpr: &[[u8; 16]; 8], + s: S, +) -> std::result::Result { + use serde::ser::SerializeSeq; + let mut seq = s.serialize_seq(Some(8))?; + for reg in fpr { + seq.serialize_element(&hex::encode(reg))?; + } + seq.end() +} + +fn deserialize_fpr<'de, D: serde::Deserializer<'de>>( + d: D, +) -> std::result::Result<[[u8; 16]; 8], D::Error> { + let strs: Vec = serde::Deserialize::deserialize(d)?; + if strs.len() != 8 { + return Err(serde::de::Error::custom("expected 8 fpr entries")); + } + let mut result = [[0u8; 16]; 8]; + for (i, s) in strs.iter().enumerate() { + let bytes = hex::decode(s).map_err(serde::de::Error::custom)?; + if bytes.len() != 16 { + return Err(serde::de::Error::custom("fpr entry must be 16 bytes")); + } + result[i].copy_from_slice(&bytes); + } + Ok(result) +} + +fn serialize_xmm( + xmm: &[[u8; 16]; 16], + s: S, +) -> std::result::Result { + use serde::ser::SerializeSeq; + let mut seq = s.serialize_seq(Some(16))?; + for reg in xmm { + seq.serialize_element(&hex::encode(reg))?; + } + seq.end() +} + +fn deserialize_xmm<'de, D: serde::Deserializer<'de>>( + d: D, +) -> std::result::Result<[[u8; 16]; 16], D::Error> { + let strs: Vec = serde::Deserialize::deserialize(d)?; + if strs.len() != 16 { + return Err(serde::de::Error::custom("expected 16 xmm entries")); + } + let mut result = [[0u8; 16]; 16]; + for (i, s) in strs.iter().enumerate() { + let bytes = hex::decode(s).map_err(serde::de::Error::custom)?; + if bytes.len() != 16 { + return Err(serde::de::Error::custom("xmm entry must be 16 bytes")); + } + result[i].copy_from_slice(&bytes); + } + Ok(result) +} + impl Default for CommonFpu { fn default() -> Self { Self { diff --git a/src/hyperlight_host/src/hypervisor/regs/x86_64/standard_regs.rs b/src/hyperlight_host/src/hypervisor/regs/x86_64/standard_regs.rs index 03dcf4b939..2cb87d75ba 100644 --- a/src/hyperlight_host/src/hypervisor/regs/x86_64/standard_regs.rs +++ b/src/hyperlight_host/src/hypervisor/regs/x86_64/standard_regs.rs @@ -18,8 +18,10 @@ limitations under the License. use kvm_bindings::kvm_regs; #[cfg(mshv3)] use mshv_bindings::StandardRegisters; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Default, Copy, Clone, PartialEq)] +#[derive(Debug, Default, Copy, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub(crate) struct CommonRegisters { pub rax: u64, pub rbx: u64, diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/aarch64.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/aarch64.rs index 07c6db9533..73958ce341 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/aarch64.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/aarch64.rs @@ -272,6 +272,18 @@ impl VirtualMachine for KvmVm { } } + fn complete_pending_io(&mut self) -> std::result::Result { + // As with the Halt case in `run_vcpu`, AArch64 KVM defers advancing the + // program counter past an I/O instruction until the next KVM_RUN. To + // honor a pause requested during a host call, re-enter with + // immediate_exit set so the in-flight write is finished (PC advances + // past it) without executing any further guest instructions, landing at + // a clean, self-consistent point right after the host-call instruction. + self.run_immediate_exit() + .map_err(|e| RunVcpuError::CompletePendingIo(format!("{:?}", e)))?; + Ok(true) + } + fn regs(&self) -> std::result::Result { use crate::hypervisor::regs::kvm_reg::{PC, PSTATE, SP, X}; let mut x: [u64; 31] = [0; 31]; diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/x86_64.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/x86_64.rs index 3dc8ec87a8..41cc6c6d6f 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/x86_64.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/x86_64.rs @@ -20,7 +20,8 @@ use hyperlight_common::outb::VmAction; #[cfg(gdb)] use kvm_bindings::kvm_guest_debug; use kvm_bindings::{ - kvm_debugregs, kvm_fpu, kvm_regs, kvm_sregs, kvm_userspace_memory_region, kvm_xsave, + Msrs, kvm_debugregs, kvm_fpu, kvm_msr_entry, kvm_regs, kvm_sregs, kvm_userspace_memory_region, + kvm_xsave, }; use kvm_ioctls::Cap::UserMemory; use kvm_ioctls::{Kvm, VcpuExit, VcpuFd, VmFd}; @@ -224,7 +225,8 @@ impl KvmVm { if (0x40..=0x43).contains(&port) { continue; } - return Ok(VmExit::IoOut(port, data.to_vec())); + let data_vec = data.to_vec(); + return Ok(VmExit::IoOut(port, data_vec)); } Ok(VcpuExit::MmioRead(addr, _)) => return Ok(VmExit::MmioRead(addr)), Ok(VcpuExit::MmioWrite(addr, _)) => return Ok(VmExit::MmioWrite(addr)), @@ -241,10 +243,8 @@ impl KvmVm { _ => return Err(RunVcpuError::Unknown(e.into())), }, Ok(other) => { - return Ok(VmExit::Unknown(format!( - "Unknown KVM VCPU exit: {:?}", - other - ))); + let msg = format!("Unknown KVM VCPU exit: {other:?}"); + return Ok(VmExit::Unknown(msg)); } } } @@ -335,6 +335,39 @@ impl VirtualMachine for KvmVm { self.run_vcpu_default() } + fn complete_pending_io(&mut self) -> std::result::Result { + // Per the KVM API docs for KVM_EXIT_IO, after an IO exit the operation + // is completed — and guest state (including advancing RIP past the + // `OUT`) made consistent — only once userspace re-enters the kernel with + // KVM_RUN: "The kernel side will first finish incomplete operations and + // then check for pending signals [...] Userspace can re-enter the guest + // with [...] the immediate_exit field set to complete pending operations + // without allowing any further instructions to be executed." + // + // That is exactly what we need to honor a pause requested during a host + // call: finish the in-flight `OUT` (so RIP advances past it and the + // guest's input buffer is the source of truth) and stop immediately, + // landing at a clean arbitrary-pause point rather than running on to the + // next host-call boundary. + self.vcpu_fd.set_kvm_immediate_exit(1u8); + let result = loop { + match self.vcpu_fd.run() { + Err(e) => match e.errno() { + libc::EINTR => break Ok(true), + libc::EAGAIN => continue, + _ => break Err(RunVcpuError::CompletePendingIo(format!("{e:?}"))), + }, + Ok(exit) => { + break Err(RunVcpuError::CompletePendingIo(format!( + "unexpected vcpu exit while completing pending IO: {exit:?}" + ))); + } + } + }; + self.vcpu_fd.set_kvm_immediate_exit(0u8); + result + } + fn regs(&self) -> std::result::Result { let kvm_regs = self .vcpu_fd @@ -387,6 +420,55 @@ impl VirtualMachine for KvmVm { Ok(()) } + fn read_msrs(&self, indices: &[u32]) -> std::result::Result, RegisterError> { + let entries: Vec = indices + .iter() + .map(|&index| kvm_msr_entry { + index, + ..Default::default() + }) + .collect(); + let mut msrs = + Msrs::from_entries(&entries).map_err(|e| RegisterError::GetMsrs(format!("{e:?}")))?; + let read = self + .vcpu_fd + .get_msrs(&mut msrs) + .map_err(|e| RegisterError::GetMsrs(e.to_string()))?; + if read != indices.len() { + return Err(RegisterError::GetMsrs(format!( + "requested {} MSRs but only {} were read", + indices.len(), + read + ))); + } + Ok(msrs.as_slice().iter().map(|e| e.data).collect()) + } + + fn write_msrs(&self, entries: &[(u32, u64)]) -> std::result::Result<(), RegisterError> { + let entries: Vec = entries + .iter() + .map(|&(index, data)| kvm_msr_entry { + index, + data, + ..Default::default() + }) + .collect(); + let msrs = + Msrs::from_entries(&entries).map_err(|e| RegisterError::SetMsrs(format!("{e:?}")))?; + let written = self + .vcpu_fd + .set_msrs(&msrs) + .map_err(|e| RegisterError::SetMsrs(e.to_string()))?; + if written != entries.len() { + return Err(RegisterError::SetMsrs(format!( + "requested {} MSRs but only {} were written", + entries.len(), + written + ))); + } + Ok(()) + } + fn debug_regs(&self) -> std::result::Result { let kvm_debug_regs = self .vcpu_fd diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs index dac344711b..2990f725fa 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs @@ -205,6 +205,8 @@ pub enum RunVcpuError { #[cfg(target_arch = "aarch64")] #[error("Flush MMIO pending state failed: {0}")] FlushMmioPending(String), + #[error("Failed to complete pending IO: {0}")] + CompletePendingIo(String), #[error("Unknown error: {0}")] Unknown(HypervisorError), } @@ -224,6 +226,10 @@ pub enum RegisterError { GetSregs(HypervisorError), #[error("Failed to set special registers: {0}")] SetSregs(HypervisorError), + #[error("Failed to get MSRs: {0}")] + GetMsrs(String), + #[error("Failed to set MSRs: {0}")] + SetMsrs(String), #[error("Failed to get debug registers: {0}")] GetDebugRegs(HypervisorError), #[error("Failed to set debug registers: {0}")] @@ -337,6 +343,28 @@ pub(crate) trait VirtualMachine: Debug + Send { #[cfg(feature = "trace_guest")] tc: &mut SandboxTraceContext, ) -> std::result::Result; + /// Complete a pending IO operation left by the most recent + /// [`VmExit::IoOut`] — advancing the instruction pointer past the `OUT` — + /// *without* executing any further guest instructions, leaving guest state + /// self-consistent for a snapshot. + /// + /// This is used to honor a pause requested at a host-call boundary — + /// whether the pause was already pending when the `OUT` exit was observed or + /// was requested *during* the call (e.g. a host function calling `pause()`). + /// The call has already been serviced on this vCPU, so the run loop re-enters + /// the kernel just far enough to finish the pending `OUT` and then stops + /// right after it, instead of running on to the next host-call boundary. + /// + /// Returns `Ok(true)` if the guest is now left at a clean, snapshot-safe + /// point right after the `OUT` — either because the pending IO was completed + /// (KVM) or because the backend already advanced past the `OUT` at exit time + /// so there was nothing pending to finish (mshv, WHP). Returns `Ok(false)` + /// if the backend does not support this, in which case the pause is left to + /// be honored by a subsequent signal-based cancellation. + fn complete_pending_io(&mut self) -> std::result::Result { + Ok(false) + } + /// Get regs #[allow(dead_code)] fn regs(&self) -> std::result::Result; @@ -352,6 +380,13 @@ pub(crate) trait VirtualMachine: Debug + Send { fn sregs(&self) -> std::result::Result; /// Set special regs fn set_sregs(&self, sregs: &CommonSpecialRegisters) -> std::result::Result<(), RegisterError>; + /// Read the given MSRs by index, returning their values in the same order. + /// x86_64 only (other architectures do not have MSRs). + #[cfg(target_arch = "x86_64")] + fn read_msrs(&self, indices: &[u32]) -> std::result::Result, RegisterError>; + /// Write the given `(index, value)` MSR pairs. x86_64 only. + #[cfg(target_arch = "x86_64")] + fn write_msrs(&self, entries: &[(u32, u64)]) -> std::result::Result<(), RegisterError>; /// Get the debug registers of the vCPU #[allow(dead_code)] fn debug_regs(&self) -> std::result::Result; diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/x86_64.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/x86_64.rs index 1fd50d29a9..ac67bd00e3 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/x86_64.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/x86_64.rs @@ -26,13 +26,13 @@ use mshv_bindings::LapicState; #[cfg(gdb)] use mshv_bindings::{DebugRegisters, hv_message_type_HVMSG_X64_EXCEPTION_INTERCEPT}; use mshv_bindings::{ - FloatingPointUnit, HV_X64_REGISTER_CLASS_IP, SpecialRegisters, StandardRegisters, XSave, + FloatingPointUnit, HV_X64_REGISTER_CLASS_IP, Msrs, SpecialRegisters, StandardRegisters, XSave, hv_message_type, hv_message_type_HVMSG_GPA_INTERCEPT, hv_message_type_HVMSG_UNMAPPED_GPA, hv_message_type_HVMSG_X64_HALT, hv_message_type_HVMSG_X64_IO_PORT_INTERCEPT, hv_partition_property_code_HV_PARTITION_PROPERTY_SYNTHETIC_PROC_FEATURES, hv_partition_synthetic_processor_features, hv_register_assoc, hv_register_name_HV_X64_REGISTER_RIP, hv_register_value, mshv_create_partition_v2, - mshv_user_mem_region, + mshv_user_mem_region, msr_entry, }; #[cfg(feature = "hw-interrupts")] use mshv_bindings::{ @@ -368,6 +368,16 @@ impl VirtualMachine for MshvVm { } } + fn complete_pending_io(&mut self) -> std::result::Result { + // Unlike KVM, mshv does not defer IO completion to the next vcpu run: + // `run_vcpu` advances RIP past the `OUT` synchronously, before the host + // call is serviced (see the IO_PORT_INTERCEPT arm above). So at an + // `IoOut` boundary there is no pending IO and guest state is already + // self-consistent for a snapshot — we can honor an in-flight pause + // immediately with no further work. + Ok(true) + } + fn regs(&self) -> std::result::Result { let mshv_regs = self .vcpu_fd @@ -416,6 +426,55 @@ impl VirtualMachine for MshvVm { Ok(()) } + fn read_msrs(&self, indices: &[u32]) -> std::result::Result, RegisterError> { + let entries: Vec = indices + .iter() + .map(|&index| msr_entry { + index, + ..Default::default() + }) + .collect(); + let mut msrs = + Msrs::from_entries(&entries).map_err(|e| RegisterError::GetMsrs(format!("{e:?}")))?; + let read = self + .vcpu_fd + .get_msrs(&mut msrs) + .map_err(|e| RegisterError::GetMsrs(e.to_string()))?; + if read != indices.len() { + return Err(RegisterError::GetMsrs(format!( + "requested {} MSRs but only {} were read", + indices.len(), + read + ))); + } + Ok(msrs.as_slice().iter().map(|e| e.data).collect()) + } + + fn write_msrs(&self, entries: &[(u32, u64)]) -> std::result::Result<(), RegisterError> { + let entries: Vec = entries + .iter() + .map(|&(index, data)| msr_entry { + index, + data, + ..Default::default() + }) + .collect(); + let msrs = + Msrs::from_entries(&entries).map_err(|e| RegisterError::SetMsrs(format!("{e:?}")))?; + let written = self + .vcpu_fd + .set_msrs(&msrs) + .map_err(|e| RegisterError::SetMsrs(e.to_string()))?; + if written != entries.len() { + return Err(RegisterError::SetMsrs(format!( + "requested {} MSRs but only {} were written", + entries.len(), + written + ))); + } + Ok(()) + } + fn debug_regs(&self) -> std::result::Result { let debug_regs = self .vcpu_fd diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/whp.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/whp.rs index 6de2b29f14..73ba82e414 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/whp.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/whp.rs @@ -228,6 +228,23 @@ impl WhpVm { } } +/// Maps a snapshot MSR index to the corresponding WHP register name. +/// Only the MSRs captured/restored by the snapshot machinery are supported. +#[cfg(target_arch = "x86_64")] +fn msr_index_to_whv_name(index: u32) -> Option { + Some(match index { + 0xC000_0080 => WHvX64RegisterEfer, + 0xC000_0081 => WHvX64RegisterStar, + 0xC000_0082 => WHvX64RegisterLstar, + 0xC000_0083 => WHvX64RegisterCstar, + 0xC000_0084 => WHvX64RegisterSfmask, + 0xC000_0100 => WHvX64RegisterFsBase, + 0xC000_0101 => WHvX64RegisterGsBase, + 0xC000_0102 => WHvX64RegisterKernelGsBase, + _ => return None, + }) +} + impl VirtualMachine for WhpVm { unsafe fn map_memory( &mut self, @@ -535,6 +552,16 @@ impl VirtualMachine for WhpVm { } } + fn complete_pending_io(&mut self) -> std::result::Result { + // Unlike KVM, WHP does not defer IO completion to the next vcpu run: + // `run_vcpu` advances RIP past the `OUT` synchronously, before the host + // call is serviced (see the X64IoPortAccess arm above). So at an + // `IoOut` boundary there is no pending IO and guest state is already + // self-consistent for a snapshot — we can honor an in-flight pause + // immediately with no further work. + Ok(true) + } + fn regs(&self) -> std::result::Result { let mut whv_regs_values: [Align16; WHP_REGS_NAMES_LEN] = unsafe { std::mem::zeroed() }; @@ -667,6 +694,45 @@ impl VirtualMachine for WhpVm { } } + fn read_msrs(&self, indices: &[u32]) -> std::result::Result, RegisterError> { + let names: Vec = indices + .iter() + .map(|&index| { + msr_index_to_whv_name(index) + .ok_or_else(|| RegisterError::GetMsrs(format!("unsupported MSR {index:#x}"))) + }) + .collect::>()?; + let mut values: Vec> = + vec![Align16(unsafe { std::mem::zeroed() }); names.len()]; + + unsafe { + WHvGetVirtualProcessorRegisters( + self.partition, + 0, + names.as_ptr(), + values.len() as u32, + values.as_mut_ptr() as *mut WHV_REGISTER_VALUE, + ) + .map_err(|e| RegisterError::GetMsrs(e.to_string()))?; + } + + Ok(values.iter().map(|v| unsafe { v.0.Reg64 }).collect()) + } + + fn write_msrs(&self, entries: &[(u32, u64)]) -> std::result::Result<(), RegisterError> { + let regs: Vec<(WHV_REGISTER_NAME, Align16)> = entries + .iter() + .map(|&(index, data)| { + let name = msr_index_to_whv_name(index) + .ok_or_else(|| RegisterError::SetMsrs(format!("unsupported MSR {index:#x}")))?; + Ok((name, Align16(WHV_REGISTER_VALUE { Reg64: data }))) + }) + .collect::>()?; + self.set_registers(®s) + .map_err(|e| RegisterError::SetMsrs(e.to_string()))?; + Ok(()) + } + fn debug_regs(&self) -> std::result::Result { let mut whp_debug_regs_values: [Align16; WHP_DEBUG_REGS_NAMES_LEN] = Default::default(); diff --git a/src/hyperlight_host/src/mem/mgr.rs b/src/hyperlight_host/src/mem/mgr.rs index a70b5fba7e..3888318521 100644 --- a/src/hyperlight_host/src/mem/mgr.rs +++ b/src/hyperlight_host/src/mem/mgr.rs @@ -30,7 +30,7 @@ use super::layout::SandboxMemoryLayout; use super::shared_mem::{ ExclusiveSharedMemory, GuestSharedMemory, HostSharedMemory, ReadonlySharedMemory, SharedMemory, }; -use crate::hypervisor::regs::CommonSpecialRegisters; +use crate::hypervisor::regs::{CommonFpu, CommonRegisters, CommonSpecialRegisters}; use crate::mem::memory_region::MemoryRegion; #[cfg(crashdump)] use crate::mem::memory_region::{CrashDumpRegion, MemoryRegionFlags, MemoryRegionType}; @@ -130,6 +130,29 @@ impl ReadonlySharedMemory { } } pub(crate) use unused_hack::SnapshotSharedMemory; + +/// A capture of the guest<->host IO data buffers (the input and +/// output data stacks) that live in scratch memory. +/// +/// Scratch memory is deliberately *not* part of a snapshot's memory +/// image, and `update_scratch_bookkeeping` resets the input/output +/// buffer stack pointers to "empty" on every restore. That is fine +/// for a quiescent snapshot, but a snapshot taken while the guest is +/// paused mid host-call has live data in these buffers (e.g. the +/// unconsumed response to the host call the guest was parked in). +/// Losing it makes the guest crash immediately on resume. For paused +/// snapshots we therefore capture the used bytes of each buffer here +/// and write them back into scratch after the bookkeeping reset. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct IoBuffers { + /// Used bytes of the guest input data buffer (host -> guest), + /// including the 8-byte stack-pointer header at its start. + pub(crate) input: Vec, + /// Used bytes of the guest output data buffer (guest -> host), + /// including the 8-byte stack-pointer header at its start. + pub(crate) output: Vec, +} + /// A struct that is responsible for laying out and managing the memory /// for a given `Sandbox`. #[derive(Clone)] @@ -150,6 +173,16 @@ pub(crate) struct SandboxMemoryManager { /// restored snapshot's own generation number so the guest-visible /// counter tracks which snapshot the sandbox is a clone of. pub(crate) snapshot_count: u64, + /// IO data buffers captured from a paused snapshot, to be written + /// back into scratch memory after `update_scratch_bookkeeping` + /// resets it on restore. `None` for sandboxes that were not built + /// from a paused snapshot. + pub(crate) pending_io_buffers: Option, + /// `(rsp, stack_top_gva)` of a paused snapshot, used by `build` to + /// eagerly materialise the live stack's CoW pages so the resumed + /// guest does not triple-fault on its first write. `None` for + /// sandboxes that were not built from a paused snapshot. + pub(crate) pending_resume_stack: Option<(u64, u64)>, } /// Buffer for building guest page tables during snapshot creation. @@ -284,6 +317,8 @@ where entrypoint, abort_buffer: Vec::new(), snapshot_count: 0, + pending_io_buffers: None, + pending_resume_stack: None, } } @@ -300,6 +335,9 @@ where root_pt_gpas: &[u64], rsp_gva: u64, sregs: CommonSpecialRegisters, + regs: Option, + fpu: Option, + msrs: Option>, entrypoint: NextAction, host_functions: HostFunctionDetails, ) -> Result { @@ -313,6 +351,9 @@ where root_pt_gpas, rsp_gva, sregs, + regs, + fpu, + msrs, entrypoint, self.snapshot_count, host_functions, @@ -332,6 +373,13 @@ impl SandboxMemoryManager { // reflects "which snapshot is the sandbox currently a clone // of", not "how many snapshots this partition has taken". mgr.snapshot_count = s.snapshot_generation(); + // Carry any paused-snapshot IO buffers so `build` can restore + // them into scratch after `update_scratch_bookkeeping`. + mgr.pending_io_buffers = s.io_buffers().cloned(); + // Carry the paused stack pointer + top so `build` can eagerly + // materialise the live stack's CoW pages (see + // `materialize_stack_cow_pages`). + mgr.pending_resume_stack = s.regs().map(|r| (r.rsp, s.stack_top_gva())); Ok(mgr) } @@ -360,6 +408,8 @@ impl SandboxMemoryManager { entrypoint: self.entrypoint, abort_buffer: self.abort_buffer, snapshot_count: self.snapshot_count, + pending_io_buffers: None, + pending_resume_stack: None, }; let guest_mgr = SandboxMemoryManager { shared_mem: gshm, @@ -368,13 +418,215 @@ impl SandboxMemoryManager { entrypoint: self.entrypoint, abort_buffer: Vec::new(), // Guest doesn't need abort buffer snapshot_count: self.snapshot_count, + pending_io_buffers: None, + pending_resume_stack: None, }; host_mgr.update_scratch_bookkeeping()?; + // If we were built from a paused snapshot, the bookkeeping + // reset above clobbered the live IO buffers; restore them so + // the resumed guest sees the in-flight host-call data, and + // eagerly materialise the live stack's CoW pages so resuming + // mid-execution does not triple-fault on the first write to a + // CoW page (see `materialize_stack_cow_pages`). + if let Some(io) = self.pending_io_buffers.as_ref() { + host_mgr.restore_io_buffers(io)?; + if let Some((rsp, stack_top)) = self.pending_resume_stack { + host_mgr.materialize_cow_pages(rsp, stack_top)?; + } + } Ok((host_mgr, guest_mgr)) } } impl SandboxMemoryManager { + /// Write previously-captured IO data buffers back into scratch + /// memory. Call after `update_scratch_bookkeeping`, which resets + /// the buffers to empty, so the restored bytes win. + pub(crate) fn restore_io_buffers(&mut self, io: &IoBuffers) -> Result<()> { + self.scratch_mem.copy_from_slice( + &io.input, + self.layout.get_input_data_buffer_scratch_host_offset(), + )?; + self.scratch_mem.copy_from_slice( + &io.output, + self.layout.get_output_data_buffer_scratch_host_offset(), + )?; + Ok(()) + } + + /// Eagerly materialise copy-on-write pages into writable scratch + /// memory before a paused guest resumes. + /// + /// When a snapshot is rebuilt, writable guest pages are remapped + /// copy-on-write: read-only PTEs (with the `PAGE_AVL_COW` bit set) + /// whose contents live in the snapshot blob, which is mapped into + /// the VM read-only. Normally the guest's own page-fault handler + /// copies such a page into writable scratch on the first write. + /// That works when execution starts cleanly (e.g. booting from a + /// golden snapshot), where the guest re-runs its boot path. + /// + /// Resuming a *paused* snapshot is different: execution continues at + /// the exact instruction following the host call the guest was + /// parked in. The unikraft guest cannot reliably service a CoW + /// page-fault taken in that resumed state, and triple-faults on the + /// first write to a CoW page. To sidestep that entirely we eagerly + /// materialise every present CoW page so the resumed guest never + /// takes a CoW fault: each such page's contents are copied from the + /// snapshot blob into a freshly bump-allocated scratch page and its + /// PTE rewritten plain-writable, mirroring the guest's own + /// copy-on-write handler. + /// + /// This is best-effort: if scratch fills up before every CoW page is + /// materialised we stop and leave the remainder lazy. Sandboxes are + /// sized with ample scratch for their working set, so in practice the + /// whole writable set is materialised; the early-stop path only + /// matters for tiny test sandboxes whose guests cooperate with lazy + /// CoW anyway. + pub(crate) fn materialize_cow_pages(&mut self, rsp: u64, stack_top: u64) -> Result<()> { + use hyperlight_common::layout::{SCRATCH_TOP_ALLOCATOR_OFFSET, scratch_base_gpa}; + + const PAGE_PRESENT: u64 = 1 << 0; + const PAGE_RW: u64 = 1 << 1; + const PAGE_PS: u64 = 1 << 7; + const PAGE_AVL_COW: u64 = 1 << 9; + const ADDR_MASK: u64 = 0x000F_FFFF_FFFF_F000; + const PAGE: u64 = hyperlight_common::vmem::PAGE_SIZE as u64; + + let scratch_size = self.scratch_mem.mem_size(); + let scratch_base = scratch_base_gpa(scratch_size); + let pt_base_gpa = self.layout.get_pt_base_gpa(); + let pt_size = self.layout.get_pt_size() as u64; + let base_addr = SandboxMemoryLayout::BASE_ADDRESS as u64; + let snapshot_size = self.shared_mem.mem_size() as u64; + + // Resolve a page-table GPA (always inside the scratch region) to + // its host offset within scratch. + let pt_lo = pt_base_gpa; + let pt_hi = pt_base_gpa + pt_size; + let table_off = |gpa: u64| -> Option { + if gpa >= pt_lo && gpa < pt_hi { + Some((gpa - scratch_base) as usize) + } else { + None + } + }; + + // Mirror the guest's bump allocator (`alloc_phys_pages`): the + // next-free scratch GPA lives in the bookkeeping area at the top + // of scratch, so pages we hand out won't collide with the + // guest's own subsequent allocations. + let alloc_host_off = scratch_size - SCRATCH_TOP_ALLOCATOR_OFFSET as usize; + let mut next_free: u64 = self.scratch_mem.read::(alloc_host_off)?; + // The guest reserves two pages at the top of scratch for the + // exception stack and shared state; respect the same limit. + let max_avail = scratch_base + scratch_size as u64 - PAGE * 2; + + // Walk the full four-level page table from the (single) snapshot + // root and collect every present CoW leaf that points into the + // snapshot blob. All page-table pages live contiguously in the + // scratch-resident PT region. + let read_entry = |off: usize| -> Result { self.scratch_mem.read::(off) }; + // (page-table entry host offset, snapshot-blob host offset, entry flags) + let mut candidates: Vec<(usize, usize, u64)> = Vec::new(); + + // Eagerly materialise EVERY present CoW leaf, not just the live + // stack. A guest resumed mid-execution from a paused snapshot + // cannot service its own copy-on-write page-faults during the + // early kernel resume path (doing so triple-faults on the first + // touched CoW page), so every page it might read or write must + // already be a private, writable page before the vCPU runs. + // `rsp`/`stack_top` are retained for diagnostics/future tuning. + let _ = (rsp, stack_top); + + for i4 in 0..512usize { + let Some(t4) = table_off(pt_base_gpa) else { + break; + }; + let e4 = read_entry(t4 + i4 * 8)?; + if e4 & PAGE_PRESENT == 0 || e4 & PAGE_PS != 0 { + continue; + } + let Some(t3) = table_off(e4 & ADDR_MASK) else { + continue; + }; + for i3 in 0..512usize { + let e3 = read_entry(t3 + i3 * 8)?; + if e3 & PAGE_PRESENT == 0 || e3 & PAGE_PS != 0 { + continue; + } + let Some(t2) = table_off(e3 & ADDR_MASK) else { + continue; + }; + for i2 in 0..512usize { + let e2 = read_entry(t2 + i2 * 8)?; + if e2 & PAGE_PRESENT == 0 || e2 & PAGE_PS != 0 { + continue; + } + let Some(t1) = table_off(e2 & ADDR_MASK) else { + continue; + }; + for i1 in 0..512usize { + let entry_off = t1 + i1 * 8; + let entry = read_entry(entry_off)?; + if entry & PAGE_PRESENT == 0 || entry & PAGE_AVL_COW == 0 { + continue; + } + let src_gpa = entry & ADDR_MASK; + // CoW leaves always point into the snapshot blob's + // data region. Anything else would be a bug; skip + // defensively. + if src_gpa < base_addr || src_gpa + PAGE > base_addr + snapshot_size { + continue; + } + let src_off = (src_gpa - base_addr) as usize; + candidates.push((entry_off, src_off, entry)); + } + } + } + } + + // Full materialisation can need a large slice of scratch. If the + // snapshot's scratch was sized too small to hold the whole CoW + // footprint plus the guest's post-resume allocation headroom, + // bail rather than overflow (the guest will then fault and, being + // unable to service it, triple-fault — a clear signal scratch is + // undersized). + let needed = candidates.len() as u64 * PAGE; + if next_free + needed > max_avail { + tracing::error!( + materialize_pages = candidates.len(), + next_free, + max_avail, + "resume CoW materialisation would overflow scratch; skipping" + ); + return Ok(()); + } + + // Snapshot blob is read-only; borrow it for the copies. This + // coexists with the mutating scratch accesses below because they + // touch a distinct field (`scratch_mem`). + let blob = self.shared_mem.as_slice(); + for (entry_off, src_off, entry) in &candidates { + let page = &blob[*src_off..*src_off + PAGE as usize]; + let new_gpa = next_free; + next_free += PAGE; + let dst_off = (new_gpa - scratch_base) as usize; + self.scratch_mem.copy_from_slice(page, dst_off)?; + + let new_entry = (entry & !ADDR_MASK & !PAGE_AVL_COW) | new_gpa | PAGE_RW; + self.scratch_mem.write::(*entry_off, new_entry)?; + } + + // Persist the bumped allocator so the guest continues allocating + // above the pages we just materialised. + self.scratch_mem.write::(alloc_host_off, next_free)?; + tracing::debug!( + materialized = candidates.len(), + "materialised all CoW pages on resume" + ); + Ok(()) + } + /// Reads a host function call from memory #[instrument(err(Debug), skip_all, parent = Span::current(), level= "Trace")] pub(crate) fn get_host_function_call(&mut self) -> Result { @@ -505,6 +757,18 @@ impl SandboxMemoryManager { self.snapshot_count = snapshot.snapshot_generation(); self.update_scratch_bookkeeping()?; + // For a paused snapshot, restore the live IO data buffers that + // `update_scratch_bookkeeping` just reset; otherwise the + // resumed guest loses the in-flight host-call data and crashes. + // Then eagerly materialise CoW pages so resuming mid-execution + // does not triple-fault on the first write to a CoW page (see + // `materialize_cow_pages`). + if let Some(io) = snapshot.io_buffers() { + self.restore_io_buffers(io)?; + if let Some(regs) = snapshot.regs() { + self.materialize_cow_pages(regs.rsp, snapshot.stack_top_gva())?; + } + } Ok((gsnapshot, gscratch)) } diff --git a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs index 76452cf643..172a3e630c 100644 --- a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs +++ b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs @@ -396,6 +396,9 @@ impl MultiUseSandbox { &root_pt_gpas, stack_top_gpa, sregs, + None, + None, + None, entrypoint, host_functions, )?; @@ -404,6 +407,68 @@ impl MultiUseSandbox { Ok(snapshot) } + /// Take a snapshot that includes full vCPU register state (GPRs + FPU). + /// + /// This is used when snapshotting a paused VM so that execution can be + /// resumed after restore. + pub(crate) fn snapshot_with_regs(&mut self) -> Result> { + // Invalidate any cached snapshot since we want a fresh one with regs + self.snapshot = None; + + let mapped_regions_iter = self.vm.get_mapped_regions(); + let mapped_regions_vec: Vec = mapped_regions_iter.cloned().collect(); + let cr3 = self + .vm + .get_root_pt() + .map_err(|e| HyperlightError::HyperlightVmError(e.into()))?; + let root_pt_gpas = if let Some(finder) = &self.pt_root_finder { + let roots = self.mem_mgr.shared_mem.with_contents(|snap| { + self.mem_mgr + .scratch_mem + .with_contents(|scratch| finder(snap, scratch, cr3)) + })??; + if roots.is_empty() { vec![cr3] } else { roots } + } else { + vec![cr3] + }; + + let stack_top_gpa = self.vm.get_stack_top(); + let sregs = self + .vm + .get_snapshot_sregs() + .map_err(|e| HyperlightError::HyperlightVmError(e.into()))?; + let regs = self + .vm + .get_snapshot_regs() + .map_err(|e| HyperlightError::HyperlightVmError(e.into()))?; + let fpu = self + .vm + .get_snapshot_fpu() + .map_err(|e| HyperlightError::HyperlightVmError(e.into()))?; + let msrs = self + .vm + .get_snapshot_msrs() + .map_err(|e| HyperlightError::HyperlightVmError(e.into()))?; + let entrypoint = self.vm.get_entrypoint(); + let host_functions = (&*self.host_funcs.try_lock().map_err(|e| { + crate::new_error!("Error locking host_funcs at {}:{}: {}", file!(), line!(), e) + })?) + .into(); + + let memory_snapshot = self.mem_mgr.snapshot( + mapped_regions_vec, + &root_pt_gpas, + stack_top_gpa, + sregs, + Some(regs), + Some(fpu), + Some(msrs), + entrypoint, + host_functions, + )?; + Ok(Arc::new(memory_snapshot)) + } + /// Restores the sandbox's memory to a previously captured snapshot state. /// /// The snapshot's memory layout must be structurally compatible @@ -488,6 +553,17 @@ impl MultiUseSandbox { /// ``` #[instrument(err(Debug), skip_all, parent = Span::current())] pub fn restore(&mut self, snapshot: Arc) -> Result<()> { + if snapshot.regs().is_some() { + return Err(HyperlightError::Error( + "cannot restore a paused snapshot with restore(); use restore_paused() to resume mid-execution state".to_string(), + )); + } + + self.restore_impl(snapshot) + } + + /// Internal restore implementation shared by `restore()` and `restore_paused()`. + pub(crate) fn restore_impl(&mut self, snapshot: Arc) -> Result<()> { // Currently, we do not try to optimise restore to the // most-current snapshot. This is because the most-current // snapshot, while it must have identical virtual memory @@ -543,6 +619,31 @@ impl MultiUseSandbox { HyperlightVmError::Restore(e) })?; + // Restore general-purpose registers and FPU/SSE state if the snapshot + // captured them (i.e. if it was taken mid-execution). This enables + // resuming paused execution after a restore. + if let Some(regs) = snapshot.regs() { + self.vm.restore_regs(regs).map_err(|e| { + self.poisoned = true; + crate::new_error!("failed to restore registers: {}", e) + })?; + } + if let Some(fpu) = snapshot.fpu() { + self.vm.restore_fpu(fpu).map_err(|e| { + self.poisoned = true; + crate::new_error!("failed to restore FPU state: {}", e) + })?; + } + // Restore model-specific registers (syscall/segment-base MSRs such as + // KERNEL_GS_BASE) last, so the sregs reset in reset_vcpu does not + // clobber them. Required for a paused guest to resume correctly. + if let Some(msrs) = snapshot.msrs() { + self.vm.restore_msrs(msrs).map_err(|e| { + self.poisoned = true; + crate::new_error!("failed to restore MSR state: {}", e) + })?; + } + self.vm.set_stack_top(snapshot.stack_top_gva()); self.vm.set_entrypoint(snapshot.entrypoint()); @@ -726,6 +827,282 @@ impl MultiUseSandbox { }) } + /// Calls a guest function by name, returning a [`PendingCall`] that can be + /// paused and resumed. + /// + /// The returned handle holds an exclusive borrow of this sandbox, preventing + /// other operations while the call is in flight. Drive the call forward by + /// calling [`PendingCall::poll()`]. + /// + /// ## Pausing + /// + /// Obtain an [`InterruptHandle`] via [`PendingCall::sandbox().interrupt_handle()`] + /// and call [`pause()`](InterruptHandle::pause) from any thread to pause the VM. + /// The next call to [`PendingCall::poll()`] will return + /// [`CallProgress::Paused`](super::pending_call::CallProgress::Paused). + /// Calling `poll()` again resumes execution. + /// + /// ## Poisoned Sandbox + /// + /// This method will return a `PendingCall` that immediately errors if the sandbox + /// is poisoned. Use [`restore()`](Self::restore) to recover first. + /// + /// # Examples + /// + /// ```no_run + /// # use hyperlight_host::{MultiUseSandbox, UninitializedSandbox, GuestBinary}; + /// # use hyperlight_host::sandbox::pending_call::CallProgress; + /// # use std::thread; + /// # use std::time::Duration; + /// # fn example() -> Result<(), Box> { + /// let mut sandbox: MultiUseSandbox = UninitializedSandbox::new( + /// GuestBinary::FilePath("guest.bin".into()), + /// None + /// )?.evolve()?; + /// + /// let mut call = sandbox.call_async::("LongRunning", (42,)); + /// + /// // Pause from another thread after 1 second + /// let handle = call.sandbox().interrupt_handle(); + /// thread::spawn(move || { + /// thread::sleep(Duration::from_secs(1)); + /// handle.pause(); + /// }); + /// + /// loop { + /// match call.poll()? { + /// CallProgress::Completed(result) => { + /// println!("Got: {result}"); + /// break; + /// } + /// CallProgress::Paused => { + /// println!("Paused! Snapshotting..."); + /// let _snap = call.snapshot()?; + /// // poll() again resumes + /// } + /// } + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn call_async<'a, Output: SupportedReturnType>( + &'a mut self, + func_name: &str, + args: impl ParameterTuple, + ) -> super::pending_call::PendingCall<'a, Output> { + super::pending_call::PendingCall::new(self, func_name, Output::TYPE, args.into_value()) + } + + /// Calls a guest function by name, taking ownership of the sandbox. + /// + /// Similar to [`call_async`](Self::call_async), but consumes the sandbox. + /// The sandbox can be recovered via + /// [`PendingCallOwned::into_sandbox()`](super::pending_call::PendingCallOwned::into_sandbox). + /// + /// This is useful when you need to store the pending call in a struct, + /// move it across threads, or avoid lifetime annotations. + /// + /// # Examples + /// + /// ```no_run + /// # use hyperlight_host::{MultiUseSandbox, UninitializedSandbox, GuestBinary}; + /// # use hyperlight_host::sandbox::pending_call::CallProgress; + /// # fn example() -> Result<(), Box> { + /// let sandbox: MultiUseSandbox = UninitializedSandbox::new( + /// GuestBinary::FilePath("guest.bin".into()), + /// None + /// )?.evolve()?; + /// + /// let mut call = sandbox.call_async_owned::("Compute", (42,)); + /// loop { + /// match call.poll()? { + /// CallProgress::Completed(result) => { + /// let sandbox = call.into_sandbox(); + /// break; + /// } + /// CallProgress::Paused => { /* resume on next poll() */ } + /// } + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn call_async_owned( + self, + func_name: &str, + args: impl ParameterTuple, + ) -> super::pending_call::PendingCallOwned { + super::pending_call::PendingCallOwned::new(self, func_name, Output::TYPE, args.into_value()) + } + + /// Restore a paused snapshot and return a handle to resume execution. + /// + /// This consumes the sandbox, restores the snapshot (including register + /// state), and returns a [`PendingCallOwned`](super::pending_call::PendingCallOwned) + /// ready to resume. The first call to + /// [`poll()`](super::pending_call::PendingCallOwned::poll) will continue + /// execution from exactly where the snapshot was taken. + /// + /// The snapshot must have been taken while the guest was paused + /// (i.e. it must contain register state). Returns an error if the + /// snapshot does not contain register state. + /// + /// # Examples + /// + /// ```no_run + /// # use hyperlight_host::{MultiUseSandbox, UninitializedSandbox, GuestBinary}; + /// # use hyperlight_host::sandbox::pending_call::CallProgress; + /// # fn example() -> Result<(), Box> { + /// # let mut sandbox: MultiUseSandbox = UninitializedSandbox::new( + /// # GuestBinary::FilePath("guest.bin".into()), None + /// # )?.evolve()?; + /// # let snapshot = sandbox.snapshot()?; + /// // Restore and resume a paused snapshot: + /// let mut call = sandbox.restore_paused::(snapshot)?; + /// let sandbox = loop { + /// match call.poll()? { + /// CallProgress::Completed(result) => { + /// println!("Done: {result}"); + /// break call.into_sandbox(); + /// } + /// CallProgress::Paused => println!("Paused again, resuming..."), + /// } + /// }; + /// # Ok(()) + /// # } + /// ``` + pub fn restore_paused( + mut self, + snapshot: Arc, + ) -> Result> { + if snapshot.regs().is_none() { + return Err(HyperlightError::Error( + "snapshot does not contain register state; use restore() for quiescent snapshots" + .to_string(), + )); + } + self.restore_impl(snapshot)?; + Ok(super::pending_call::PendingCallOwned::new_paused(self)) + } + + /// Internal: dispatch a guest call that may be paused instead of completed. + /// Returns the guest return value on success, or `ExecutionPaused` if paused. + pub(crate) fn call_guest_function_pausable( + &mut self, + function_name: &str, + return_type: ReturnType, + args: Vec, + ) -> Result { + if self.poisoned { + return Err(crate::HyperlightError::PoisonedSandbox); + } + self.vm.clear_cancel(); + self.vm.clear_pause(); + + let res = self.dispatch_and_read_result(function_name, return_type, args); + + // Clear partial abort bytes so they don't leak across calls. + self.mem_mgr.abort_buffer.clear(); + + if let Err(e) = &res { + if !matches!(e, HyperlightError::ExecutionPaused()) { + self.mem_mgr.clear_io_buffers(); + } + self.poisoned |= e.is_poison_error(); + } + + res + } + + /// Internal: resume a paused VM call and read the result. + pub(crate) fn resume_paused_call(&mut self) -> Result { + // Clear the pause bit so the run loop doesn't immediately re-pause + self.vm.clear_pause(); + + let dispatch_res = self.vm.dispatch_resume( + &mut self.mem_mgr, + &self.host_funcs, + #[cfg(gdb)] + self.dbg_mem_access_fn.clone(), + ); + + if let Err(e) = dispatch_res { + let (error, should_poison) = e.promote(); + self.poisoned |= should_poison; + return Err(error); + } + + let guest_result = self.mem_mgr.get_guest_function_call_result()?.into_inner(); + + match guest_result { + Ok(val) => Ok(val), + Err(guest_error) => { + metrics::counter!( + METRIC_GUEST_ERROR, + METRIC_GUEST_ERROR_LABEL_CODE => (guest_error.code as u64).to_string() + ) + .increment(1); + + Err(HyperlightError::GuestError( + guest_error.code, + guest_error.message, + )) + } + } + } + + /// Internal helper: dispatch a guest call and read the result. + fn dispatch_and_read_result( + &mut self, + function_name: &str, + return_type: ReturnType, + args: Vec, + ) -> Result { + let estimated_capacity = estimate_flatbuffer_capacity(function_name, &args); + + let fc = FunctionCall::new( + function_name.to_string(), + Some(args), + FunctionCallType::Guest, + return_type, + ); + + let mut builder = FlatBufferBuilder::with_capacity(estimated_capacity); + let buffer = fc.encode(&mut builder); + + self.mem_mgr.write_guest_function_call(buffer)?; + + let dispatch_res = self.vm.dispatch_call_from_host( + &mut self.mem_mgr, + &self.host_funcs, + #[cfg(gdb)] + self.dbg_mem_access_fn.clone(), + ); + + if let Err(e) = dispatch_res { + let (error, should_poison) = e.promote(); + self.poisoned |= should_poison; + return Err(error); + } + + let guest_result = self.mem_mgr.get_guest_function_call_result()?.into_inner(); + + match guest_result { + Ok(val) => Ok(val), + Err(guest_error) => { + metrics::counter!( + METRIC_GUEST_ERROR, + METRIC_GUEST_ERROR_LABEL_CODE => (guest_error.code as u64).to_string() + ) + .increment(1); + + Err(HyperlightError::GuestError( + guest_error.code, + guest_error.message, + )) + } + } + } + /// Maps a region of host memory into the sandbox address space. /// /// The base address and length must meet platform alignment requirements @@ -860,6 +1237,7 @@ impl MultiUseSandbox { // Clear any stale cancellation from a previous guest function call or if kill() was called too early. // Any kill() that completed (even partially) BEFORE this line has NO effect on this call. self.vm.clear_cancel(); + self.vm.clear_pause(); let res = (|| { let estimated_capacity = estimate_flatbuffer_capacity(function_name, &args); @@ -923,6 +1301,10 @@ impl MultiUseSandbox { // Determine if we should poison the sandbox. self.poisoned |= e.is_poison_error(); + + // ExecutionPaused through the normal call() path is unrecoverable — + // there's no CallFuture to resume from, so the sandbox is inconsistent. + self.poisoned |= matches!(e, HyperlightError::ExecutionPaused()); } // Note: clear_call_active() is automatically called when _guard is dropped here @@ -963,6 +1345,19 @@ impl MultiUseSandbox { self.vm.interrupt_handle() } + /// Mark this sandbox as poisoned. + /// + /// Used when the guest was interrupted mid-execution and there is no way + /// to resume (e.g., dropping a paused `CallFuture`). + pub(crate) fn poison(&mut self) { + self.poisoned = true; + } + + /// Clear any pending pause request on the interrupt handle. + pub(crate) fn clear_pause(&self) { + self.vm.clear_pause(); + } + /// Generate a crash dump of the current state of the VM underlying this sandbox. /// /// Creates an ELF core dump file that can be used for debugging. The dump diff --git a/src/hyperlight_host/src/sandbox/mod.rs b/src/hyperlight_host/src/sandbox/mod.rs index 066903657e..982db3bfd7 100644 --- a/src/hyperlight_host/src/sandbox/mod.rs +++ b/src/hyperlight_host/src/sandbox/mod.rs @@ -24,6 +24,8 @@ pub(crate) mod host_funcs; /// call 0 or more guest functions pub mod initialized_multi_use; pub(crate) mod outb; +/// A handle to an in-progress guest function call that supports pausing and resuming. +pub mod pending_call; /// Functionality for creating uninitialized sandboxes, manipulating them, /// and converting them to initialized sandboxes. pub mod uninitialized; diff --git a/src/hyperlight_host/src/sandbox/pending_call.rs b/src/hyperlight_host/src/sandbox/pending_call.rs new file mode 100644 index 0000000000..188488eb3b --- /dev/null +++ b/src/hyperlight_host/src/sandbox/pending_call.rs @@ -0,0 +1,429 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +use std::marker::PhantomData; +use std::sync::Arc; + +use hyperlight_common::flatbuffer_wrappers::function_types::ReturnType; + +use super::initialized_multi_use::MultiUseSandbox; +use super::snapshot::Snapshot; +use crate::func::SupportedReturnType; +use crate::{HyperlightError, Result}; + +/// The result of driving a [`PendingCall`] or [`PendingCallOwned`] forward. +#[derive(Debug)] +pub enum CallProgress { + /// The guest function completed and returned this value. + Completed(T), + /// The VM was paused mid-execution. The call handle can be used to + /// inspect, snapshot, or resume the paused VM. + Paused, +} + +/// Internal state of the pending call. +enum CallState { + /// The guest call has not been dispatched yet. + NotStarted { + function_name: String, + return_type: ReturnType, + args: Vec, + }, + /// The VM is paused mid-execution and can be resumed. + Paused, + /// The call has completed or been killed — the pending call is consumed. + Done, +} + +/// A handle to an in-progress guest function call that can be paused and resumed. +/// +/// Created by [`MultiUseSandbox::call_async()`]. Holds an exclusive borrow of +/// the sandbox, preventing other operations while the call is in flight. +/// +/// # Usage +/// +/// ```no_run +/// # use hyperlight_host::{MultiUseSandbox, UninitializedSandbox, GuestBinary}; +/// # use hyperlight_host::sandbox::pending_call::CallProgress; +/// # use std::thread; +/// # use std::time::Duration; +/// # fn example() -> Result<(), Box> { +/// let mut sandbox: MultiUseSandbox = UninitializedSandbox::new( +/// GuestBinary::FilePath("guest.bin".into()), +/// None +/// )?.evolve()?; +/// +/// let mut call = sandbox.call_async::("LongRunning", (42,)); +/// +/// // Pause from another thread +/// let handle = call.sandbox().interrupt_handle(); +/// thread::spawn(move || { +/// thread::sleep(Duration::from_secs(1)); +/// handle.pause(); +/// }); +/// +/// loop { +/// match call.poll()? { +/// CallProgress::Completed(result) => { +/// println!("result: {result}"); +/// break; +/// } +/// CallProgress::Paused => { +/// println!("VM paused, resuming..."); +/// // Calling poll() again resumes execution +/// } +/// } +/// } +/// # Ok(()) +/// # } +/// ``` +pub struct PendingCall<'a, Output> { + sandbox: &'a mut MultiUseSandbox, + state: CallState, + _phantom: PhantomData, +} + +impl<'a, Output: SupportedReturnType> PendingCall<'a, Output> { + /// Create a new `PendingCall` for the given guest function call. + pub(crate) fn new( + sandbox: &'a mut MultiUseSandbox, + function_name: &str, + return_type: ReturnType, + args: Vec, + ) -> Self { + Self { + sandbox, + state: CallState::NotStarted { + function_name: function_name.to_string(), + return_type, + args, + }, + _phantom: PhantomData, + } + } + + /// Drive execution until completion or pause. + /// + /// - On the first call, dispatches the guest function. + /// - On subsequent calls after a pause, resumes execution from + /// exactly where it was interrupted. + /// + /// Returns [`CallProgress::Completed`] when the guest function finishes, + /// or [`CallProgress::Paused`] if the VM was paused mid-execution. + pub fn poll(&mut self) -> Result> { + match std::mem::replace(&mut self.state, CallState::Done) { + CallState::NotStarted { + function_name, + return_type, + args, + } => { + let (result, new_state) = + dispatch_and_wait::(self.sandbox, &function_name, return_type, args); + self.state = new_state; + result + } + CallState::Paused => { + let (result, new_state) = resume_and_wait::(self.sandbox); + self.state = new_state; + result + } + CallState::Done => Err(HyperlightError::Error( + "PendingCall has already completed or been killed".to_string(), + )), + } + } + + /// Returns `true` if the VM is currently paused mid-execution. + pub fn is_paused(&self) -> bool { + matches!(self.state, CallState::Paused) + } + + /// Take a snapshot of the VM in its current state. + /// + /// If the VM is paused, this captures a mid-execution snapshot + /// including full register state (GPRs + FPU), enabling resume + /// after restore via [`MultiUseSandbox::restore_paused()`]. + /// If the VM has not started, this captures the current quiescent state. + pub fn snapshot(&mut self) -> Result> { + if self.is_paused() { + self.sandbox.snapshot_with_regs() + } else { + self.sandbox.snapshot() + } + } + + /// Cancel the in-progress call. This poisons the sandbox. + pub fn kill(mut self) { + self.sandbox.interrupt_handle().kill(); + self.state = CallState::Done; + } + + /// Read-only access to the underlying sandbox. + pub fn sandbox(&self) -> &MultiUseSandbox { + self.sandbox + } +} + +impl Drop for PendingCall<'_, Output> { + fn drop(&mut self) { + if matches!(self.state, CallState::Paused) { + // The guest was mid-execution and there's no way to resume. + // This is equivalent to kill() during execution — poison the sandbox. + self.sandbox.poison(); + self.sandbox.clear_pause(); + } + } +} + +// ─── Shared dispatch/resume logic ─────────────────────────────────────────── + +fn dispatch_and_wait( + sandbox: &mut MultiUseSandbox, + function_name: &str, + return_type: ReturnType, + args: Vec, +) -> (Result>, CallState) { + // Reset snapshot since we are mutating the sandbox state + sandbox.snapshot = None; + + let res = sandbox.call_guest_function_pausable(function_name, return_type, args); + + match res { + Ok(val) => match Output::from_value(val) { + Ok(output) => (Ok(CallProgress::Completed(output)), CallState::Done), + Err(e) => (Err(e.into()), CallState::Done), + }, + Err(HyperlightError::ExecutionPaused()) => (Ok(CallProgress::Paused), CallState::Paused), + Err(e) => (Err(e), CallState::Done), + } +} + +fn resume_and_wait( + sandbox: &mut MultiUseSandbox, +) -> (Result>, CallState) { + let res = sandbox.resume_paused_call(); + + match res { + Ok(val) => match Output::from_value(val) { + Ok(output) => (Ok(CallProgress::Completed(output)), CallState::Done), + Err(e) => (Err(e.into()), CallState::Done), + }, + Err(HyperlightError::ExecutionPaused()) => (Ok(CallProgress::Paused), CallState::Paused), + Err(e) => (Err(e), CallState::Done), + } +} + +// ─── PendingCallOwned ─────────────────────────────────────────────────────── + +/// An owned handle to an in-progress guest function call that can be paused +/// and resumed. +/// +/// Unlike [`PendingCall`], this takes ownership of the [`MultiUseSandbox`], +/// eliminating lifetime parameters. The sandbox can be recovered via +/// [`into_sandbox()`](Self::into_sandbox) after the call completes or is +/// cancelled. +/// +/// Created by [`MultiUseSandbox::call_async_owned()`] or +/// [`MultiUseSandbox::restore_paused()`]. +/// +/// # Usage +/// +/// ```no_run +/// # use hyperlight_host::{MultiUseSandbox, UninitializedSandbox, GuestBinary}; +/// # use hyperlight_host::sandbox::pending_call::CallProgress; +/// # use std::thread; +/// # use std::time::Duration; +/// # fn example() -> Result<(), Box> { +/// let sandbox: MultiUseSandbox = UninitializedSandbox::new( +/// GuestBinary::FilePath("guest.bin".into()), +/// None +/// )?.evolve()?; +/// +/// let mut call = sandbox.call_async_owned::("LongRunning", (42,)); +/// +/// // Pause from another thread +/// let handle = call.sandbox().interrupt_handle(); +/// thread::spawn(move || { +/// thread::sleep(Duration::from_secs(1)); +/// handle.pause(); +/// }); +/// +/// let sandbox = loop { +/// match call.poll()? { +/// CallProgress::Completed(result) => { +/// println!("result: {result}"); +/// break call.into_sandbox(); +/// } +/// CallProgress::Paused => { +/// println!("VM paused, resuming..."); +/// } +/// } +/// }; +/// # Ok(()) +/// # } +/// ``` +pub struct PendingCallOwned { + sandbox: Option, + state: CallState, + _phantom: PhantomData, +} + +impl PendingCallOwned { + /// Create a new `PendingCallOwned` for the given guest function call. + pub(crate) fn new( + sandbox: MultiUseSandbox, + function_name: &str, + return_type: ReturnType, + args: Vec, + ) -> Self { + Self { + sandbox: Some(sandbox), + state: CallState::NotStarted { + function_name: function_name.to_string(), + return_type, + args, + }, + _phantom: PhantomData, + } + } + + /// Create a `PendingCallOwned` in the paused state, for resuming from + /// a restored snapshot that was taken mid-execution. + pub(crate) fn new_paused(sandbox: MultiUseSandbox) -> Self { + Self { + sandbox: Some(sandbox), + state: CallState::Paused, + _phantom: PhantomData, + } + } + + /// Restore a sandbox from a mid-execution (paused) snapshot and return + /// a `PendingCallOwned` ready to resume. + /// + /// The snapshot must contain register state (i.e., it must have been + /// taken while the VM was paused). Use [`MultiUseSandbox::restore()`] + /// for quiescent snapshots instead. + /// + /// This is equivalent to [`MultiUseSandbox::restore_paused()`] but + /// expressed as an associated function on `PendingCallOwned`. + pub fn from_paused_snapshot( + mut sandbox: MultiUseSandbox, + snapshot: Arc, + ) -> Result { + if snapshot.regs().is_none() { + return Err(HyperlightError::Error( + "snapshot does not contain register state; use restore() for quiescent snapshots" + .to_string(), + )); + } + sandbox.restore_impl(snapshot)?; + Ok(Self::new_paused(sandbox)) + } + + /// Drive execution until completion or pause. + /// + /// - On the first call, dispatches the guest function. + /// - On subsequent calls after a pause, resumes execution from + /// exactly where it was interrupted. + /// + /// Returns [`CallProgress::Completed`] when the guest function finishes, + /// or [`CallProgress::Paused`] if the VM was paused mid-execution. + pub fn poll(&mut self) -> Result> { + let sandbox = self.sandbox.as_mut().expect("sandbox taken after Done"); + match std::mem::replace(&mut self.state, CallState::Done) { + CallState::NotStarted { + function_name, + return_type, + args, + } => { + let (result, new_state) = + dispatch_and_wait::(sandbox, &function_name, return_type, args); + self.state = new_state; + result + } + CallState::Paused => { + let (result, new_state) = resume_and_wait::(sandbox); + self.state = new_state; + result + } + CallState::Done => Err(HyperlightError::Error( + "PendingCallOwned has already completed or been killed".to_string(), + )), + } + } + + /// Returns `true` if the VM is currently paused mid-execution. + pub fn is_paused(&self) -> bool { + matches!(self.state, CallState::Paused) + } + + /// Take a snapshot of the VM in its current state. + /// + /// If the VM is paused, this captures a mid-execution snapshot + /// including full register state, enabling resume after restore. + pub fn snapshot(&mut self) -> Result> { + let paused = self.is_paused(); + let sandbox = self.sandbox.as_mut().expect("sandbox taken after Done"); + if paused { + sandbox.snapshot_with_regs() + } else { + sandbox.snapshot() + } + } + + /// Cancel the in-progress call. This poisons the sandbox. + /// + /// Returns the (poisoned) sandbox so you can restore it from a snapshot. + pub fn kill(mut self) -> MultiUseSandbox { + let sandbox = self.sandbox.as_mut().expect("sandbox taken after Done"); + sandbox.interrupt_handle().kill(); + self.state = CallState::Done; + self.sandbox.take().unwrap() + } + + /// Consume this handle and return the underlying sandbox. + /// + /// - If the call has completed (state is `Done`), returns the sandbox + /// in a non-poisoned, usable state. + /// - If the call is still paused, this poisons the sandbox before + /// returning it (since the mid-execution state cannot be resumed + /// without the `PendingCallOwned`). + pub fn into_sandbox(mut self) -> MultiUseSandbox { + if matches!(self.state, CallState::Paused) { + let sandbox = self.sandbox.as_mut().unwrap(); + sandbox.poison(); + sandbox.clear_pause(); + } + self.state = CallState::Done; + self.sandbox.take().unwrap() + } + + /// Read-only access to the underlying sandbox. + pub fn sandbox(&self) -> &MultiUseSandbox { + self.sandbox.as_ref().expect("sandbox taken after Done") + } +} + +impl Drop for PendingCallOwned { + fn drop(&mut self) { + if matches!(self.state, CallState::Paused) + && let Some(sandbox) = self.sandbox.as_mut() + { + sandbox.poison(); + sandbox.clear_pause(); + } + } +} diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/archive.rs b/src/hyperlight_host/src/sandbox/snapshot/file/archive.rs new file mode 100644 index 0000000000..b811bbe9eb --- /dev/null +++ b/src/hyperlight_host/src/sandbox/snapshot/file/archive.rs @@ -0,0 +1,286 @@ +/* +Copyright 2025 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Stream a snapshot's OCI Image Layout into a single `.tar` or +//! `.tar.gz` archive, and read one back. +//! +//! The archive is just the OCI layout (`oci-layout`, `index.json`, +//! `blobs/sha256/...`) stored at the archive root. The writer +//! ([`ArchiveWriter`]) appends entries directly to the tar stream, so a +//! snapshot is packed without first materialising the layout in a +//! temporary directory: the (large) memory image is streamed straight +//! from its in-memory mapping into the archive. The reader extracts the +//! layout into a directory so the regular directory loader can run +//! against it unchanged. + +use std::collections::HashSet; +use std::fs::File; +use std::io::{BufReader, BufWriter, Read, Write}; +use std::path::Path; + +use flate2::Compression; +use flate2::read::GzDecoder; +use flate2::write::GzEncoder; +use tar::{Archive, Builder, EntryType, Header}; + +/// The container format used to store a snapshot's OCI Image Layout as a +/// single file. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ArchiveFormat { + /// An uncompressed POSIX tar archive (`.tar`). + Tar, + /// A gzip-compressed tar archive (`.tar.gz` / `.tgz`). + TarGz, +} + +impl ArchiveFormat { + /// Infer the archive format from a path's extension. Recognises + /// `.tar`, `.tar.gz`, and `.tgz` (case-insensitively). Returns + /// `None` for any other path so the caller can demand an explicit + /// format. + pub fn from_path(path: impl AsRef) -> Option { + let name = path.as_ref().file_name()?.to_str()?.to_ascii_lowercase(); + if name.ends_with(".tar.gz") || name.ends_with(".tgz") { + Some(ArchiveFormat::TarGz) + } else if name.ends_with(".tar") { + Some(ArchiveFormat::Tar) + } else { + None + } + } +} + +/// Normalise a tar entry path to a layout-relative path with forward +/// slashes and no leading `./`. Archives written by older code packed +/// the layout under `./`, so both forms read back the same. +fn normalise_entry_path(raw: &str) -> String { + raw.replace('\\', "/").trim_start_matches("./").to_string() +} + +/// Writes an OCI Image Layout into a tar (optionally gzip-compressed) +/// stream. The compression choice is fixed at construction; the enum +/// keeps callers free of the writer's generic type. +pub(crate) enum ArchiveWriter { + Plain(Builder>), + Gz(Builder>>), +} + +impl ArchiveWriter { + /// Create an archive file at `path` and prepare to write entries in + /// `format`. + pub(crate) fn create(path: &Path, format: ArchiveFormat) -> crate::Result { + let file = File::create(path) + .map_err(|e| crate::new_error!("save_archive: failed to create {:?}: {}", path, e))?; + let writer = BufWriter::new(file); + Ok(match format { + ArchiveFormat::Tar => ArchiveWriter::Plain(Builder::new(writer)), + ArchiveFormat::TarGz => { + ArchiveWriter::Gz(Builder::new(GzEncoder::new(writer, Compression::default()))) + } + }) + } + + /// Append a single regular-file entry at layout-relative `rel_path`, + /// reading exactly `size` bytes from `data`. The bulk memory image + /// is written this way straight from its mapping, so it never lands + /// in a temporary file. + pub(crate) fn append( + &mut self, + rel_path: &str, + size: u64, + data: &mut dyn Read, + ) -> crate::Result<()> { + let mut header = Header::new_gnu(); + header.set_size(size); + header.set_mode(0o644); + header.set_mtime(0); + header.set_entry_type(EntryType::Regular); + let res = match self { + ArchiveWriter::Plain(b) => b.append_data(&mut header, rel_path, data), + ArchiveWriter::Gz(b) => b.append_data(&mut header, rel_path, data), + }; + res.map_err(|e| crate::new_error!("save_archive: failed to add {}: {}", rel_path, e)) + } + + /// Append an in-memory blob (small JSON: manifest, config, index, + /// marker). + pub(crate) fn append_bytes(&mut self, rel_path: &str, bytes: &[u8]) -> crate::Result<()> { + self.append(rel_path, bytes.len() as u64, &mut &bytes[..]) + } + + /// Finish the archive: flush the tar trailer and, for gzip, the + /// compression trailer, then flush the buffered file. + pub(crate) fn finish(self) -> crate::Result<()> { + match self { + ArchiveWriter::Plain(b) => { + let mut w = b + .into_inner() + .map_err(|e| crate::new_error!("save_archive: failed to finish tar: {}", e))?; + w.flush().map_err(|e| { + crate::new_error!("save_archive: failed to flush archive: {}", e) + })?; + } + ArchiveWriter::Gz(b) => { + let enc = b + .into_inner() + .map_err(|e| crate::new_error!("save_archive: failed to finish tar: {}", e))?; + let mut w = enc + .finish() + .map_err(|e| crate::new_error!("save_archive: failed to finish gzip: {}", e))?; + w.flush().map_err(|e| { + crate::new_error!("save_archive: failed to flush archive: {}", e) + })?; + } + } + Ok(()) + } +} + +/// Stream every blob entry from the existing snapshot archive at `src` +/// into `out`, and return the archive's `index.json` bytes if present. +/// +/// Used to merge a new snapshot into an existing archive without a +/// temporary directory: blobs (including other tags' memory images) are +/// copied through the tar streams, while `oci-layout` and `index.json` +/// are dropped here because the caller rewrites them. A blob whose +/// layout-relative path is already in `written` is skipped; copied +/// paths are inserted into `written` so the caller's own blobs dedup +/// against them. +pub(crate) fn copy_existing_blobs( + src: &Path, + format: ArchiveFormat, + out: &mut ArchiveWriter, + written: &mut HashSet, +) -> crate::Result>> { + let file = File::open(src) + .map_err(|e| crate::new_error!("save_archive: failed to open {:?}: {}", src, e))?; + let reader = BufReader::new(file); + let mut index_bytes = None; + match format { + ArchiveFormat::Tar => { + copy_entries(Archive::new(reader), out, written, &mut index_bytes)?; + } + ArchiveFormat::TarGz => { + copy_entries( + Archive::new(GzDecoder::new(reader)), + out, + written, + &mut index_bytes, + )?; + } + } + Ok(index_bytes) +} + +fn copy_entries( + mut archive: Archive, + out: &mut ArchiveWriter, + written: &mut HashSet, + index_bytes: &mut Option>, +) -> crate::Result<()> { + let entries = archive + .entries() + .map_err(|e| crate::new_error!("save_archive: failed to read existing archive: {}", e))?; + for entry in entries { + let mut entry = entry.map_err(|e| { + crate::new_error!("save_archive: corrupt entry in existing archive: {}", e) + })?; + let raw = entry + .path() + .map_err(|e| crate::new_error!("save_archive: bad path in existing archive: {}", e))? + .to_string_lossy() + .into_owned(); + let rel = normalise_entry_path(&raw); + + if rel == "index.json" { + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).map_err(|e| { + crate::new_error!("save_archive: failed to read existing index.json: {}", e) + })?; + *index_bytes = Some(buf); + continue; + } + // The marker is rewritten by the caller; directory entries are + // recreated on extraction; non-blob entries are ignored. + if rel == "oci-layout" || entry.header().entry_type().is_dir() || !rel.starts_with("blobs/") + { + continue; + } + if !written.insert(rel.clone()) { + continue; + } + let size = entry + .header() + .size() + .map_err(|e| crate::new_error!("save_archive: bad size in existing archive: {}", e))?; + out.append(&rel, size, &mut entry)?; + } + Ok(()) +} + +/// Unpack the tar (or tar.gz) archive at `archive` into the directory +/// `dir`, which must already exist. `format` selects the decoder; pass +/// the format inferred from the path, or detected by [`detect_format`]. +pub(crate) fn unpack_archive_to_dir( + archive: &Path, + dir: &Path, + format: ArchiveFormat, +) -> crate::Result<()> { + let file = File::open(archive) + .map_err(|e| crate::new_error!("load_archive: failed to open {:?}: {}", archive, e))?; + let reader = BufReader::new(file); + + // `Archive::unpack` rejects entries whose paths escape `dir` + // (absolute paths or `..` traversal), so a hostile archive cannot + // write outside the extraction directory. + match format { + ArchiveFormat::Tar => { + let mut tar = Archive::new(reader); + tar.unpack(dir) + .map_err(|e| crate::new_error!("load_archive: failed to extract tar: {}", e))?; + } + ArchiveFormat::TarGz => { + let mut tar = Archive::new(GzDecoder::new(reader)); + tar.unpack(dir) + .map_err(|e| crate::new_error!("load_archive: failed to extract tar.gz: {}", e))?; + } + } + + Ok(()) +} + +/// Detect the archive format for `path`. Prefers the path extension; +/// when the extension is unknown, sniffs the first two bytes for the +/// gzip magic (`0x1f 0x8b`) and otherwise assumes an uncompressed tar. +pub(crate) fn detect_format(path: &Path) -> crate::Result { + if let Some(fmt) = ArchiveFormat::from_path(path) { + return Ok(fmt); + } + + let mut magic = [0u8; 2]; + let n = { + let mut f = File::open(path) + .map_err(|e| crate::new_error!("load_archive: failed to open {:?}: {}", path, e))?; + f.read(&mut magic) + .map_err(|e| crate::new_error!("load_archive: failed to read {:?}: {}", path, e))? + }; + + if n == 2 && magic == [0x1f, 0x8b] { + Ok(ArchiveFormat::TarGz) + } else { + Ok(ArchiveFormat::Tar) + } +} diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs index 71b65ca179..8efa9767f7 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs @@ -20,7 +20,7 @@ use hyperlight_common::vmem::PAGE_SIZE; use serde::{Deserialize, Serialize}; use super::media_types::SNAPSHOT_ABI_VERSION; -use crate::hypervisor::regs::CommonSpecialRegisters; +use crate::hypervisor::regs::{CommonFpu, CommonRegisters, CommonSpecialRegisters}; use crate::mem::layout::SandboxMemoryLayout; // --- Arch and hypervisor identifiers -------------------------------- @@ -130,6 +130,19 @@ pub(super) struct OciSnapshotConfig { /// Special registers captured from the paused vCPU, restored /// verbatim when resuming the call. pub(super) sregs: CommonSpecialRegisters, + /// General-purpose registers captured from a paused vCPU. + /// Present only for mid-execution (paused) snapshots. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) regs: Option, + /// FPU/SSE state captured from a paused vCPU. + /// Present only for mid-execution (paused) snapshots. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) fpu: Option, + /// Model-specific registers (index, value) captured from a paused vCPU + /// (EFER, STAR/LSTAR/CSTAR/SFMASK, FS/GS/KERNEL_GS base). Present only + /// for mid-execution (paused) snapshots; required to resume correctly. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) msrs: Option>, pub(super) layout: MemoryLayout, /// Total size of the memory blob in bytes (including the guest /// page-table tail, if any). Equal to `self.memory.mem_size()`. @@ -142,6 +155,44 @@ pub(super) struct OciSnapshotConfig { /// `SCRATCH_TOP_SNAPSHOT_GENERATION_OFFSET` is continuous across /// save/load. pub(super) snapshot_generation: u64, + /// Live guest<->host IO data buffers captured from scratch memory. + /// Present only for paused (mid host-call) snapshots; scratch is + /// not part of the memory blob, so without this the in-flight + /// host-call data is lost on restore and the guest crashes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) io_buffers: Option, +} + +/// JSON-friendly mirror of [`crate::mem::mgr::IoBuffers`]. The buffer +/// bytes are hex-encoded so they survive a JSON round-trip. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct IoBuffersRepr { + /// Hex-encoded used bytes of the guest input data buffer. + pub(super) input: String, + /// Hex-encoded used bytes of the guest output data buffer. + pub(super) output: String, +} + +impl From<&crate::mem::mgr::IoBuffers> for IoBuffersRepr { + fn from(b: &crate::mem::mgr::IoBuffers) -> Self { + Self { + input: hex::encode(&b.input), + output: hex::encode(&b.output), + } + } +} + +impl TryFrom for crate::mem::mgr::IoBuffers { + type Error = crate::HyperlightError; + fn try_from(r: IoBuffersRepr) -> crate::Result { + Ok(Self { + input: hex::decode(&r.input) + .map_err(|e| crate::new_error!("invalid io_buffers.input hex: {e}"))?, + output: hex::decode(&r.output) + .map_err(|e| crate::new_error!("invalid io_buffers.output hex: {e}"))?, + }) + } } /// Sizes and permissions of the regions inside the snapshot blob, diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs b/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs index a725bf3780..35c874edfa 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file/mod.rs @@ -17,13 +17,16 @@ limitations under the License. //! OCI Image Layout serde for [`Snapshot`]. See //! `docs/snapshot-oci-format.md` for the on-disk format. +pub(crate) mod archive; mod config; mod digest; mod fsutil; mod media_types; pub(crate) mod reference; +use std::collections::HashSet; use std::path::{Path, PathBuf}; +use std::sync::Arc; use hyperlight_common::flatbuffer_wrappers::host_function_details::HostFunctionDetails; use hyperlight_common::vmem::PAGE_SIZE; @@ -32,6 +35,9 @@ use oci_spec::image::{ ImageManifestBuilder, MediaType, SCHEMA_VERSION, }; +use self::archive::{ + ArchiveFormat, ArchiveWriter, copy_existing_blobs, detect_format, unpack_archive_to_dir, +}; use self::config::{Arch, HostFunction, Hypervisor, MemoryLayout, OciSnapshotConfig}; use self::digest::{Digest256, oci_digest, parse_oci_digest, verify_blob_bytes, verify_blob_file}; use self::fsutil::{put_blob, put_blob_if_absent, read_bounded, replace_file_atomic}; @@ -67,7 +73,23 @@ fn check_json_blob_size(what: &str, len: usize) -> crate::Result<()> { Ok(()) } -/// Select one manifest descriptor from `index` by `reference`. +/// The in-memory products of building a snapshot's OCI layout for one +/// tag: the blob digests (whose hex is also the blob filename), the +/// serialised manifest blob, and the index manifest descriptor. Built +/// by [`Snapshot::build_layout_artifacts`] and consumed both when +/// writing a directory layout and when streaming an archive. +struct LayoutArtifacts { + /// Digest of the raw memory image blob. + snapshot_digest: Digest256, + /// Digest of the config JSON blob. + config_digest: Digest256, + /// The serialised OCI image manifest blob. + manifest_bytes: Vec, + /// Digest of `manifest_bytes`. + manifest_digest: Digest256, + /// The descriptor recorded for this tag in `index.json`. + descriptor: Descriptor, +} /// /// A tag matches the `org.opencontainers.image.ref.name` annotation /// and must be unique. A digest matches the manifest content digest. @@ -456,13 +478,186 @@ impl Snapshot { Ok(written_digest) } - fn write_blobs_and_build_descriptor( + /// Save this snapshot as a single `.tar` or `.tar.gz` archive at + /// `path`, in addition to the directory form written by + /// [`Snapshot::save`]. + /// + /// The archive holds exactly the OCI Image Layout that [`save`] + /// writes (`oci-layout`, `index.json`, `blobs/sha256/...`), stored + /// at the archive root. Load it back with + /// [`Snapshot::load_archive`] or [`Snapshot::checked_load_archive`]. + /// + /// [`save`]: Snapshot::save + /// + /// # `path` + /// + /// The archive file to write. Its parent directory must exist. The + /// write is atomic: a temp file in the same directory is packed and + /// then renamed over `path`, so a reader sees either the old archive + /// or the new one. + /// + /// If `path` already holds a snapshot archive, this snapshot is + /// merged into it under `tag`, matching the merge behaviour of + /// [`save`] on a directory: other tags are preserved and a tag equal + /// to `tag` is replaced. Blobs are streamed straight from the old + /// archive into the new one, so — exactly as with a directory layout + /// — blobs orphaned by a replaced tag remain present. + /// + /// # `format` + /// + /// [`ArchiveFormat::Tar`] for an uncompressed archive, or + /// [`ArchiveFormat::TarGz`] for gzip compression. The memory image + /// is highly compressible (mostly zero pages), so `TarGz` typically + /// shrinks a snapshot by tens of times at the cost of some CPU. + /// [`ArchiveFormat::from_path`] can infer the format from `path`'s + /// extension. + /// + /// The layout is written directly into the tar stream: the (large) + /// memory image is streamed once from its in-memory mapping, with no + /// intermediate copy on disk. + /// + /// See [`save`] for portability and compatibility notes that apply + /// equally to the archive form. + pub fn save_archive( + &self, + path: impl AsRef, + tag: &OciTag, + format: ArchiveFormat, + ) -> crate::Result { + let path = path.as_ref(); + + // Build the config and every in-memory layout artifact up front. + // This can reject the snapshot (e.g. a pre-init snapshot), so do + // it before creating any file. + let cfg = self.build_config()?; + let cfg_bytes = serde_json::to_vec_pretty(&cfg).map_err(|e| { + crate::new_error!("save_archive: failed to serialise config JSON: {}", e) + })?; + check_json_blob_size("config blob", cfg_bytes.len())?; + let artifacts = self.build_layout_artifacts(tag, &cfg, &cfg_bytes)?; + let written_digest = OciDigest::from_oci_spec_digest(artifacts.descriptor.digest()); + + // Resolve the directory that will hold the temp archive, so the + // final rename is a same-filesystem atomic operation. + let parent = match path.parent() { + Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(), + _ => PathBuf::from("."), + }; + let parent_meta = std::fs::metadata(&parent).map_err(|e| { + crate::new_error!( + "save_archive: parent directory {:?} not accessible: {}", + parent, + e + ) + })?; + if !parent_meta.is_dir() { + return Err(crate::new_error!( + "save_archive: parent of {:?} is not a directory", + path + )); + } + + // Stream into a temp file in `parent`, then atomically rename + // over `path`. A reader sees either the old archive or the new. + let tmp = tempfile::Builder::new() + .prefix(".hl-snap-archive-") + .tempfile_in(&parent) + .map_err(|e| crate::new_error!("save_archive: failed to create temp archive: {}", e))?; + let mut out = ArchiveWriter::create(tmp.path(), format)?; + + // Blob paths already in the new archive, so the new snapshot's + // blobs dedup against any copied from an existing archive. + let mut written: HashSet = HashSet::new(); + + // Merge: copy every blob from an existing archive (preserving + // other tags) and recover its index for the manifest list. + let mut manifests: Vec = Vec::new(); + if path + .try_exists() + .map_err(|e| crate::new_error!("save_archive: failed to stat {:?}: {}", path, e))? + { + let existing_fmt = detect_format(path)?; + if let Some(index_bytes) = + copy_existing_blobs(path, existing_fmt, &mut out, &mut written)? + { + let existing: ImageIndex = serde_json::from_slice(&index_bytes).map_err(|e| { + crate::new_error!( + "save_archive: existing index.json is not a valid OCI image index: {}", + e + ) + })?; + manifests = existing.manifests().to_vec(); + } + } + + // Write this snapshot's blobs, deduped against copied blobs. + let snapshot_path = format!("blobs/sha256/{}", artifacts.snapshot_digest.hex); + if written.insert(snapshot_path.clone()) { + let mem = self.memory.as_slice(); + out.append(&snapshot_path, mem.len() as u64, &mut &mem[..])?; + } + let config_path = format!("blobs/sha256/{}", artifacts.config_digest.hex); + if written.insert(config_path.clone()) { + out.append_bytes(&config_path, &cfg_bytes)?; + } + let manifest_path = format!("blobs/sha256/{}", artifacts.manifest_digest.hex); + if written.insert(manifest_path.clone()) { + out.append_bytes(&manifest_path, &artifacts.manifest_bytes)?; + } + + // Replacement is by tag, not by digest (parity with directory + // `save`): drop any existing manifest for this tag, then add the + // new one. + manifests.retain(|d| { + d.annotations() + .as_ref() + .and_then(|a| a.get(ANNOTATION_REF_NAME)) + .map(|s| s.as_str() != tag.as_str()) + .unwrap_or(true) + }); + manifests.push(artifacts.descriptor); + + let index = ImageIndexBuilder::default() + .schema_version(SCHEMA_VERSION) + .media_type(MediaType::ImageIndex) + .manifests(manifests) + .build() + .map_err(|e| crate::new_error!("save_archive: failed to build OCI index: {}", e))?; + let index_bytes = serde_json::to_vec_pretty(&index) + .map_err(|e| crate::new_error!("save_archive: failed to serialise OCI index: {}", e))?; + check_json_blob_size("index.json", index_bytes.len())?; + + let layout_bytes = serde_json::to_vec(&serde_json::json!({ + "imageLayoutVersion": OCI_LAYOUT_VERSION, + })) + .map_err(|e| crate::new_error!("save_archive: failed to serialise oci-layout: {}", e))?; + + out.append_bytes("oci-layout", &layout_bytes)?; + out.append_bytes("index.json", &index_bytes)?; + out.finish()?; + + tmp.persist(path).map_err(|e| { + crate::new_error!("save_archive: failed to commit archive {:?}: {}", path, e) + })?; + + Ok(written_digest) + } + + /// Hash the memory image and build every in-memory artifact of the + /// OCI layout for this snapshot under `tag`: the blob digests, the + /// serialised manifest, and the index manifest descriptor. Writes + /// nothing. Shared by the directory writer + /// ([`write_blobs_and_build_descriptor`]) and the archive writer + /// ([`save_archive`]) so both compute the memory hash exactly once. + /// + /// [`write_blobs_and_build_descriptor`]: Snapshot::write_blobs_and_build_descriptor + /// [`save_archive`]: Snapshot::save_archive + fn build_layout_artifacts( &self, - dir: &Path, tag: &OciTag, cfg: &OciSnapshotConfig, cfg_bytes: &[u8], - ) -> crate::Result { + ) -> crate::Result { let memory_bytes = self.memory.as_slice(); let memory_size = memory_bytes.len(); if memory_size == 0 || !memory_size.is_multiple_of(PAGE_SIZE) { @@ -472,23 +667,12 @@ impl Snapshot { )); } - let blobs_dir = dir.join("blobs").join("sha256"); - std::fs::create_dir_all(&blobs_dir).map_err(|e| { - crate::new_error!("failed to create OCI blobs dir {:?}: {}", blobs_dir, e) - })?; - - // Snapshot blob: the raw memory bytes. let snapshot_digest = Digest256::from_bytes(memory_bytes); - put_blob_if_absent(&blobs_dir, &snapshot_digest, memory_bytes)?; - - // Config blob. - let cfg_digest = Digest256::from_bytes(cfg_bytes); - put_blob(&blobs_dir, &cfg_digest, cfg_bytes)?; + let config_digest = Digest256::from_bytes(cfg_bytes); - // Manifest blob. let config_descriptor = DescriptorBuilder::default() .media_type(MediaType::Other(MT_CONFIG_CURRENT.to_string())) - .digest(oci_digest(&cfg_digest)?) + .digest(oci_digest(&config_digest)?) .size(cfg_bytes.len() as u64) .build() .map_err(|e| crate::new_error!("failed to build config descriptor: {}", e))?; @@ -514,7 +698,6 @@ impl Snapshot { .map_err(|e| crate::new_error!("failed to serialise OCI manifest: {}", e))?; check_json_blob_size("manifest blob", manifest_bytes.len())?; let manifest_digest = Digest256::from_bytes(&manifest_bytes); - put_blob(&blobs_dir, &manifest_digest, &manifest_bytes)?; let mut anns = std::collections::HashMap::new(); anns.insert(ANNOTATION_REF_NAME.to_string(), tag.as_str().to_string()); @@ -523,13 +706,53 @@ impl Snapshot { ANNOTATION_HYPERVISOR.to_string(), cfg.hypervisor.as_str().to_string(), ); - DescriptorBuilder::default() + let descriptor = DescriptorBuilder::default() .media_type(MediaType::ImageManifest) .digest(oci_digest(&manifest_digest)?) .size(manifest_bytes.len() as u64) .annotations(anns) .build() - .map_err(|e| crate::new_error!("failed to build manifest descriptor: {}", e)) + .map_err(|e| crate::new_error!("failed to build manifest descriptor: {}", e))?; + + Ok(LayoutArtifacts { + snapshot_digest, + config_digest, + manifest_bytes, + manifest_digest, + descriptor, + }) + } + + fn write_blobs_and_build_descriptor( + &self, + dir: &Path, + tag: &OciTag, + cfg: &OciSnapshotConfig, + cfg_bytes: &[u8], + ) -> crate::Result { + let artifacts = self.build_layout_artifacts(tag, cfg, cfg_bytes)?; + + let blobs_dir = dir.join("blobs").join("sha256"); + std::fs::create_dir_all(&blobs_dir).map_err(|e| { + crate::new_error!("failed to create OCI blobs dir {:?}: {}", blobs_dir, e) + })?; + + // Snapshot blob: the raw memory bytes. + put_blob_if_absent( + &blobs_dir, + &artifacts.snapshot_digest, + self.memory.as_slice(), + )?; + // Config blob. + put_blob(&blobs_dir, &artifacts.config_digest, cfg_bytes)?; + // Manifest blob. + put_blob( + &blobs_dir, + &artifacts.manifest_digest, + &artifacts.manifest_bytes, + )?; + + Ok(artifacts.descriptor) } fn build_config(&self) -> crate::Result { @@ -568,6 +791,9 @@ impl Snapshot { stack_top_gva: self.stack_top_gva, entrypoint_addr, sregs: *sregs, + regs: self.regs, + fpu: self.fpu, + msrs: self.msrs.clone(), layout: MemoryLayout { input_data_size: l.input_data_size, output_data_size: l.output_data_size, @@ -582,6 +808,7 @@ impl Snapshot { memory_size: self.memory.mem_size() as u64, host_functions, snapshot_generation: self.snapshot_generation, + io_buffers: self.io_buffers.as_ref().map(Into::into), }) } @@ -671,6 +898,85 @@ impl Snapshot { Self::load_inner(path.as_ref(), &reference.into(), true) } + /// Load a snapshot from a `.tar` or `.tar.gz` archive written by + /// [`Snapshot::save_archive`], the archive counterpart of + /// [`Snapshot::load`]. + /// + /// The archive is extracted into a temporary directory and the + /// snapshot is loaded from that directory exactly as [`load`] would. + /// The memory image is mmap'd from the extracted file, so the + /// temporary directory is kept alive for the lifetime of the + /// returned snapshot and removed when it is dropped. + /// + /// [`load`]: Snapshot::load + /// + /// The format is inferred from `path`'s extension (`.tar`, + /// `.tar.gz`, `.tgz`); an unrecognised extension falls back to + /// sniffing the gzip magic bytes. `reference` selects the snapshot + /// within the archive, like [`load`]. + /// + /// Like [`load`], this does not verify blob digests; use + /// [`Snapshot::checked_load_archive`] for that. + pub fn load_archive( + path: impl AsRef, + reference: impl Into, + ) -> crate::Result { + Self::load_archive_inner(path.as_ref(), &reference.into(), false) + } + + /// Load a snapshot from an archive like [`Snapshot::load_archive`], + /// additionally verifying the manifest, config, and memory blobs + /// against their recorded sha256 digests, as + /// [`Snapshot::checked_load`] does for a directory. + /// + /// # Trust + /// + /// A digest check does not prove the bytes are authentic. Anyone + /// who edits a blob can recompute its digest to match, so a hostile + /// archive passes the check. Load only from a source you trust. + pub fn checked_load_archive( + path: impl AsRef, + reference: impl Into, + ) -> crate::Result { + Self::load_archive_inner(path.as_ref(), &reference.into(), true) + } + + fn load_archive_inner( + path: &Path, + reference: &OciReference, + verify_blobs: bool, + ) -> crate::Result { + let meta = std::fs::metadata(path) + .map_err(|e| crate::new_error!("load_archive failed to stat {:?}: {}", path, e))?; + if !meta.is_file() { + return Err(crate::new_error!( + "load_archive path {:?} is not a file", + path + )); + } + + let format = detect_format(path)?; + + // Extract beside the archive so a large memory image does not + // have to land on tmpfs, and so the mmap'd blob shares the + // archive's filesystem. + let parent = match path.parent() { + Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(), + _ => PathBuf::from("."), + }; + let tmp = tempfile::Builder::new() + .prefix(".hl-snap-load-") + .tempdir_in(&parent) + .map_err(|e| crate::new_error!("load_archive: failed to create temp dir: {}", e))?; + unpack_archive_to_dir(path, tmp.path(), format)?; + + let mut snapshot = Self::load_inner(tmp.path(), reference, verify_blobs)?; + // The loaded snapshot mmaps its memory image from `tmp`. Keep + // the directory alive until the snapshot is dropped. + snapshot.extract_guard = Some(Arc::new(tmp)); + Ok(snapshot) + } + fn load_inner( path: &Path, reference: &OciReference, @@ -846,15 +1152,23 @@ impl Snapshot { } }; + // 10. Reconstitute the paused-snapshot IO buffers, if present. + let io_buffers = cfg.io_buffers.map(TryInto::try_into).transpose()?; + Ok(Snapshot { layout, memory, load_info: crate::mem::exe::LoadInfo::dummy(), stack_top_gva: cfg.stack_top_gva, sregs: Some(cfg.sregs), + regs: cfg.regs, + fpu: cfg.fpu, + msrs: cfg.msrs, entrypoint, snapshot_generation, host_functions, + io_buffers, + extract_guard: None, }) } } diff --git a/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs index f849b28d14..19b540e3c0 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs @@ -25,7 +25,7 @@ use serde_json::Value; use sha2::{Digest as _, Sha256}; use crate::func::Registerable; -use crate::sandbox::snapshot::{OciDigest, OciReference, OciTag, Snapshot}; +use crate::sandbox::snapshot::{ArchiveFormat, OciDigest, OciReference, OciTag, Snapshot}; use crate::{GuestBinary, HostFunctions, MultiUseSandbox, UninitializedSandbox}; fn create_test_sandbox() -> MultiUseSandbox { @@ -134,9 +134,187 @@ fn round_trip_save_load_call() { assert_eq!(result, "hello\n"); } -/// A pre-existing snapshot blob with the right length but wrong -/// bytes (corruption, partial copy, foreign tool) must be detected -/// and replaced by `save`, not silently trusted. +// Round-trip via a single-file archive (.tar / .tar.gz). + +#[test] +fn archive_format_from_path() { + assert_eq!( + ArchiveFormat::from_path("a/b.tar"), + Some(ArchiveFormat::Tar) + ); + assert_eq!( + ArchiveFormat::from_path("a/b.tar.gz"), + Some(ArchiveFormat::TarGz) + ); + assert_eq!( + ArchiveFormat::from_path("a/b.TGZ"), + Some(ArchiveFormat::TarGz) + ); + assert_eq!(ArchiveFormat::from_path("a/b.zip"), None); + assert_eq!(ArchiveFormat::from_path("a/b"), None); +} + +#[test] +fn round_trip_save_load_archive_tar() { + let snapshot = create_snapshot(); + + let dir = tempfile::tempdir().unwrap(); + let archive = dir.path().join("snap.tar"); + snapshot + .save_archive( + &archive, + &OciTag::new("latest").unwrap(), + ArchiveFormat::Tar, + ) + .unwrap(); + assert!(archive.is_file()); + + let loaded = Snapshot::checked_load_archive(&archive, OciTag::new("latest").unwrap()).unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + let result: String = sbox2.call("Echo", "hello\n".to_string()).unwrap(); + assert_eq!(result, "hello\n"); +} + +#[test] +fn round_trip_save_load_archive_targz() { + let snapshot = create_snapshot(); + + let dir = tempfile::tempdir().unwrap(); + let archive = dir.path().join("snap.tar.gz"); + snapshot + .save_archive( + &archive, + &OciTag::new("latest").unwrap(), + ArchiveFormat::TarGz, + ) + .unwrap(); + assert!(archive.is_file()); + + // The compressed archive should be far smaller than the raw memory + // image (mostly zero pages), confirming gzip actually ran. + let snap_blob = find_snapshot_blob(&{ + let probe = dir.path().join("probe"); + snapshot + .save(&probe, &OciTag::new("latest").unwrap()) + .unwrap(); + probe + }); + let raw_len = std::fs::metadata(&snap_blob).unwrap().len(); + let archive_len = std::fs::metadata(&archive).unwrap().len(); + assert!( + archive_len < raw_len, + "gzip archive ({archive_len}) should be smaller than raw image ({raw_len})" + ); + + // load_archive infers the format from the extension. + let loaded = Snapshot::load_archive(&archive, OciTag::new("latest").unwrap()).unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + let result: String = sbox2.call("Echo", "world\n".to_string()).unwrap(); + assert_eq!(result, "world\n"); +} + +/// The snapshot keeps working after its source archive and any +/// extraction directory are gone: the mmap'd image is held by the +/// extraction guard, not the original file. +#[test] +fn archive_snapshot_survives_source_deletion() { + let snapshot = create_snapshot(); + + let dir = tempfile::tempdir().unwrap(); + let archive = dir.path().join("snap.tar.gz"); + snapshot + .save_archive( + &archive, + &OciTag::new("latest").unwrap(), + ArchiveFormat::TarGz, + ) + .unwrap(); + + let loaded = Snapshot::load_archive(&archive, OciTag::new("latest").unwrap()).unwrap(); + // Remove the archive on disk; the loaded snapshot must stay valid. + std::fs::remove_file(&archive).unwrap(); + + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + let result: String = sbox2.call("Echo", "kept\n".to_string()).unwrap(); + assert_eq!(result, "kept\n"); +} + +/// Saving two tags into the same archive preserves both, matching the +/// merge behaviour of `save` on a directory. +#[test] +fn archive_merges_multiple_tags() { + let snapshot = create_snapshot(); + + let dir = tempfile::tempdir().unwrap(); + let archive = dir.path().join("multi.tar"); + snapshot + .save_archive(&archive, &OciTag::new("first").unwrap(), ArchiveFormat::Tar) + .unwrap(); + snapshot + .save_archive( + &archive, + &OciTag::new("second").unwrap(), + ArchiveFormat::Tar, + ) + .unwrap(); + + for tag in ["first", "second"] { + let loaded = Snapshot::checked_load_archive(&archive, OciTag::new(tag).unwrap()).unwrap(); + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None) + .unwrap(); + let result: String = sbox.call("Echo", "hi\n".to_string()).unwrap(); + assert_eq!(result, "hi\n"); + } +} + +/// Merging into (and replacing a tag within) a compressed archive +/// streams every existing blob through the gzip decode/encode path. +/// Both the preserved tag and the replaced tag must load and run. +#[test] +fn archive_merge_and_replace_tag_targz() { + let snapshot = create_snapshot(); + + let dir = tempfile::tempdir().unwrap(); + let archive = dir.path().join("multi.tar.gz"); + snapshot + .save_archive( + &archive, + &OciTag::new("first").unwrap(), + ArchiveFormat::TarGz, + ) + .unwrap(); + // Merge a second tag (copies "first"'s blobs through the gzip + // streams). + snapshot + .save_archive( + &archive, + &OciTag::new("second").unwrap(), + ArchiveFormat::TarGz, + ) + .unwrap(); + // Replace "first" by saving it again; "second" must survive. + snapshot + .save_archive( + &archive, + &OciTag::new("first").unwrap(), + ArchiveFormat::TarGz, + ) + .unwrap(); + + for tag in ["first", "second"] { + let loaded = Snapshot::checked_load_archive(&archive, OciTag::new(tag).unwrap()).unwrap(); + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None) + .unwrap(); + let result: String = sbox.call("Echo", "hi\n".to_string()).unwrap(); + assert_eq!(result, "hi\n"); + } +} + #[test] fn save_self_heals_same_length_wrong_content_snapshot_blob() { let snapshot = create_snapshot(); @@ -2792,3 +2970,280 @@ fn read_blob_dir( }) .collect() } + +/// A paused snapshot (with regs/fpu) survives a round-trip through the +/// OCI file format and can be used with `from_paused_snapshot` to resume. +#[test] +fn paused_snapshot_round_trips_through_file() { + use std::thread; + use std::time::Duration; + + use crate::sandbox::pending_call::{CallProgress, PendingCallOwned}; + + let mut sbox = create_test_sandbox(); + + // Start a long-running call and pause it + let mut call = sbox.call_async::("SpinForMs", 2000u32); + + let handle = call.sandbox().interrupt_handle(); + let t = thread::spawn(move || { + thread::sleep(Duration::from_millis(200)); + handle.pause(); + }); + + let progress = call.poll().unwrap(); + assert!(matches!(progress, CallProgress::Paused)); + t.join().unwrap(); + + // Snapshot while paused — this captures regs/fpu + let snapshot = call.snapshot().unwrap(); + assert!(snapshot.regs().is_some()); + assert!(snapshot.fpu().is_some()); + // A paused snapshot captures the live IO data buffers from scratch. + assert!(snapshot.io_buffers().is_some()); + + // Save to disk + let dir = tempfile::tempdir().unwrap(); + let oci = dir.path().join("paused_snap"); + snapshot + .save(&oci, &OciTag::new("paused").unwrap()) + .unwrap(); + + // Load from disk + let loaded = Snapshot::checked_load(&oci, OciTag::new("paused").unwrap()).unwrap(); + assert!(loaded.regs().is_some(), "regs must survive round-trip"); + assert!(loaded.fpu().is_some(), "fpu must survive round-trip"); + assert_eq!( + loaded.io_buffers(), + snapshot.io_buffers(), + "io buffers must survive round-trip" + ); + + // Drop the call (poisons sandbox) and discard it + drop(call); + drop(sbox); + + // Create a fresh sandbox from the loaded snapshot and resume + let loaded = Arc::new(loaded); + let sbox2 = + MultiUseSandbox::from_snapshot(loaded.clone(), HostFunctions::default(), None).unwrap(); + + let mut owned_call = PendingCallOwned::::from_paused_snapshot(sbox2, loaded).unwrap(); + + // Resume to completion + loop { + let handle = owned_call.sandbox().interrupt_handle(); + let t = thread::spawn(move || { + thread::sleep(Duration::from_millis(3000)); + handle.pause(); + }); + + match owned_call.poll().unwrap() { + CallProgress::Completed(_) => { + t.join().unwrap(); + break; + } + CallProgress::Paused => { + t.join().unwrap(); + } + } + } + + // Recover sandbox and verify it's usable + let mut sbox2 = owned_call.into_sandbox(); + assert!(!sbox2.poisoned()); + let echo: String = sbox2.call("Echo", "disk-roundtrip".to_string()).unwrap(); + assert_eq!(echo, "disk-roundtrip"); +} + +/// Timing harness (ignored by default): measure save vs save_archive and +/// load vs load_archive on a production-sized (~512 MiB heap) snapshot, +/// to compare the OCI directory form against the `.tar` / `.tar.gz` +/// archive form. Run with: +/// cargo test -p hyperlight-host --lib snapshot_archive_timings \ +/// -- --ignored --nocapture +#[test] +#[ignore] +fn snapshot_archive_timings() { + use std::time::Instant; + + use crate::sandbox::SandboxConfiguration; + + let mut cfg = SandboxConfiguration::default(); + // Match the ateom demo default heap so the memory image is the same + // ~561 MB size measured in production. Scratch is not part of the + // snapshot; set it comfortably above the minimum so construction + // succeeds. + cfg.set_heap_size(512 * 1024 * 1024); + cfg.set_scratch_size(16 * 1024 * 1024); + let mut sbox = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_string().unwrap()), + Some(cfg), + ) + .unwrap() + .evolve() + .unwrap(); + // Touch the guest so it has actually run (parity with a real + // post-warmup snapshot). + let _: String = sbox.call("Echo", "warmup".to_string()).unwrap(); + let snap = sbox.snapshot().unwrap(); + + let tag = OciTag::new("latest").unwrap(); + let reps = 5; + + // Helper: median of a set of millisecond samples. + fn median(mut v: Vec) -> f64 { + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + v[v.len() / 2] + } + + // --- SAVE: directory --- + let mut save_dir_ms = Vec::new(); + for _ in 0..reps { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + let t = Instant::now(); + snap.save(&path, &tag).unwrap(); + save_dir_ms.push(t.elapsed().as_secs_f64() * 1e3); + } + + // --- SAVE: .tar --- + let mut save_tar_ms = Vec::new(); + let mut tar_size = 0u64; + for _ in 0..reps { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap.tar"); + let t = Instant::now(); + snap.save_archive(&path, &tag, ArchiveFormat::Tar).unwrap(); + save_tar_ms.push(t.elapsed().as_secs_f64() * 1e3); + tar_size = std::fs::metadata(&path).unwrap().len(); + } + + // --- SAVE: .tar.gz --- + let mut save_targz_ms = Vec::new(); + let mut targz_size = 0u64; + for _ in 0..reps { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap.tar.gz"); + let t = Instant::now(); + snap.save_archive(&path, &tag, ArchiveFormat::TarGz) + .unwrap(); + save_targz_ms.push(t.elapsed().as_secs_f64() * 1e3); + targz_size = std::fs::metadata(&path).unwrap().len(); + } + + // Persist one of each form for the load benchmarks. + let dir_keep = tempfile::tempdir().unwrap(); + let dir_path = dir_keep.path().join("layout"); + snap.save(&dir_path, &tag).unwrap(); + let mem_bytes = std::fs::metadata(find_snapshot_blob(&dir_path)) + .unwrap() + .len(); + let tar_keep = tempfile::tempdir().unwrap(); + let tar_path = tar_keep.path().join("snap.tar"); + snap.save_archive(&tar_path, &tag, ArchiveFormat::Tar) + .unwrap(); + let targz_keep = tempfile::tempdir().unwrap(); + let targz_path = targz_keep.path().join("snap.tar.gz"); + snap.save_archive(&targz_path, &tag, ArchiveFormat::TarGz) + .unwrap(); + + // --- LOAD: directory (lazy mmap) --- + let mut load_dir_ms = Vec::new(); + for _ in 0..reps { + let t = Instant::now(); + let loaded = Snapshot::load(&dir_path, tag.clone()).unwrap(); + load_dir_ms.push(t.elapsed().as_secs_f64() * 1e3); + drop(loaded); + } + + // --- LOAD: .tar (extract + mmap) --- + let mut load_tar_ms = Vec::new(); + for _ in 0..reps { + let t = Instant::now(); + let loaded = Snapshot::load_archive(&tar_path, tag.clone()).unwrap(); + load_tar_ms.push(t.elapsed().as_secs_f64() * 1e3); + drop(loaded); + } + + // --- LOAD: .tar.gz (gunzip + extract + mmap) --- + let mut load_targz_ms = Vec::new(); + for _ in 0..reps { + let t = Instant::now(); + let loaded = Snapshot::load_archive(&targz_path, tag.clone()).unwrap(); + load_targz_ms.push(t.elapsed().as_secs_f64() * 1e3); + drop(loaded); + } + + eprintln!( + "\n=== snapshot_archive_timings (memory image {:.2} MB) ===", + mem_bytes as f64 / 1e6 + ); + eprintln!( + "sizes: dir(blob)≈{:.2} MB tar={:.2} MB tar.gz={:.2} MB", + mem_bytes as f64 / 1e6, + tar_size as f64 / 1e6, + targz_size as f64 / 1e6, + ); + eprintln!("SAVE (median of {reps}):"); + eprintln!(" directory : {:8.1} ms", median(save_dir_ms)); + eprintln!(" .tar : {:8.1} ms", median(save_tar_ms)); + eprintln!(" .tar.gz : {:8.1} ms", median(save_targz_ms)); + eprintln!("LOAD (median of {reps}):"); + eprintln!(" directory : {:8.1} ms", median(load_dir_ms)); + eprintln!(" .tar : {:8.1} ms", median(load_tar_ms)); + eprintln!(" .tar.gz : {:8.1} ms", median(load_targz_ms)); + + // --- Synthetic production-representative gzip/gunzip bound --- + // The simple-guest heap is almost entirely zeros, so its tar.gz is + // unrealistically small/cheap (538 MB -> 0.82 MB). A real warmed guest + // (e.g. the Python counter actor) compresses ~561 MB -> ~11 MB (~50x). + // Build a buffer with that same ~50x compressibility and time gzip + + // gunzip directly to bound the CPU the archive path actually pays in + // production. + { + use std::io::{Read, Write}; + + use flate2::Compression; + use flate2::read::GzDecoder; + use flate2::write::GzEncoder; + + let total: usize = mem_bytes as usize; + let mut buf = vec![0u8; total]; + // Scatter pseudo-random bytes over ~2% of the image so it compresses + // ~50x, matching the observed production ratio. + let mut state: u64 = 0x9e3779b97f4a7c15; + let mut i = 0usize; + while i < total { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + buf[i] = (state >> 24) as u8; + i += 50; // 1 in 50 bytes non-zero + } + + let t = Instant::now(); + let mut enc = GzEncoder::new(Vec::new(), Compression::default()); + enc.write_all(&buf).unwrap(); + let gz = enc.finish().unwrap(); + let gz_ms = t.elapsed().as_secs_f64() * 1e3; + + let t = Instant::now(); + let mut dec = GzDecoder::new(&gz[..]); + let mut out = Vec::with_capacity(total); + dec.read_to_end(&mut out).unwrap(); + let gunzip_ms = t.elapsed().as_secs_f64() * 1e3; + + eprintln!( + "SYNTHETIC ~50x-compressible {:.2} MB image (production-like):", + total as f64 / 1e6 + ); + eprintln!( + " gzip : {:8.1} ms -> {:.2} MB", + gz_ms, + gz.len() as f64 / 1e6 + ); + eprintln!(" gunzip : {:8.1} ms", gunzip_ms); + } + eprintln!("=== end timings ===\n"); +} diff --git a/src/hyperlight_host/src/sandbox/snapshot/mod.rs b/src/hyperlight_host/src/sandbox/snapshot/mod.rs index c8d56323b3..c2dd4d29f2 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/mod.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/mod.rs @@ -18,7 +18,9 @@ mod file; mod file_tests; use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; +pub use file::archive::ArchiveFormat; pub use file::reference::{OciDigest, OciReference, OciTag}; use hyperlight_common::flatbuffer_wrappers::host_function_details::HostFunctionDetails; use hyperlight_common::layout::{io_page, scratch_base_gpa, scratch_base_gva}; @@ -29,11 +31,11 @@ use hyperlight_common::vmem::{ use tracing::{Span, instrument}; use crate::Result; -use crate::hypervisor::regs::CommonSpecialRegisters; +use crate::hypervisor::regs::{CommonFpu, CommonRegisters, CommonSpecialRegisters}; use crate::mem::exe::{ExeInfo, LoadInfo}; use crate::mem::layout::SandboxMemoryLayout; use crate::mem::memory_region::{GuestMemoryRegion, MemoryRegion, MemoryRegionFlags}; -use crate::mem::mgr::{GuestPageTableBuffer, SnapshotSharedMemory}; +use crate::mem::mgr::{GuestPageTableBuffer, IoBuffers, SnapshotSharedMemory}; use crate::mem::shared_mem::{ReadonlySharedMemory, SharedMemory}; use crate::sandbox::SandboxConfiguration; use crate::sandbox::uninitialized::{GuestBinary, GuestEnvironment}; @@ -90,6 +92,25 @@ pub struct Snapshot { /// tables are relocated during snapshot. sregs: Option, + /// General-purpose register state captured from the vCPU during snapshot. + /// None for snapshots created directly from a binary or when the guest + /// was not mid-execution. Some for snapshots taken while the guest was + /// paused mid-execution (enables resume after restore). + regs: Option, + + /// FPU/SSE register state captured from the vCPU during snapshot. + /// None for snapshots created directly from a binary or when the guest + /// was not mid-execution. Some for snapshots taken while the guest was + /// paused mid-execution (enables resume after restore). + fpu: Option, + + /// Model-specific register state (index, value pairs) captured from the + /// vCPU during snapshot. None for snapshots created directly from a binary + /// or when the guest was not mid-execution. Some for snapshots taken while + /// the guest was paused mid-execution; restoring these (notably + /// KERNEL_GS_BASE) is required for the guest to resume correctly. + msrs: Option>, + /// The next action that should be performed on this snapshot entrypoint: NextAction, @@ -106,6 +127,22 @@ pub struct Snapshot { /// `HostFunctions` set that is missing required functions or /// has mismatched signatures. host_functions: HostFunctionDetails, + + /// Live guest<->host IO data buffers captured from scratch memory. + /// `Some` only for paused (mid host-call) snapshots, where the + /// buffers hold in-flight data (e.g. the unconsumed response to + /// the host call the guest is parked in). Scratch is not part of + /// the snapshot memory image and is reset on restore, so these are + /// written back explicitly to keep the resumed guest from crashing. + io_buffers: Option, + + /// Keeps a temporary extraction directory alive for the lifetime of + /// this snapshot. `Some` only for a snapshot produced by + /// [`Snapshot::load_archive`] / [`Snapshot::checked_load_archive`], + /// which extract the archive into a temp directory and then mmap the + /// memory blob from it. The mapping is file-backed, so the directory + /// must outlive the snapshot; dropping this guard removes it. + extract_guard: Option>, } impl core::convert::AsRef for Snapshot { fn as_ref(&self) -> &Self { @@ -209,6 +246,44 @@ impl<'a> core::convert::AsRef> for SharedMemoryP self } } + +/// Capture the used bytes of the input and output IO data buffers from +/// scratch memory, for preservation across a paused snapshot/restore. +/// See [`IoBuffers`] for why this is necessary. +fn capture_io_buffers( + scratch_mem: &mut S, + layout: &SandboxMemoryLayout, +) -> Result { + let in_off = layout.get_input_data_buffer_scratch_host_offset(); + let in_size = layout.input_data_size; + let out_off = layout.get_output_data_buffer_scratch_host_offset(); + let out_size = layout.output_data_size; + scratch_mem.with_contents(|scratch| IoBuffers { + input: capture_io_buffer(scratch, in_off, in_size), + output: capture_io_buffer(scratch, out_off, out_size), + }) +} + +/// Capture the used bytes (`[start, start + stack_pointer)`) of a single +/// IO data buffer within the scratch slice. The first 8 bytes hold the +/// relative stack pointer (the offset of the next free byte); an empty +/// buffer has a stack pointer of 8. +fn capture_io_buffer(scratch: &[u8], start: usize, size: usize) -> Vec { + let stack_pointer = scratch + .get(start..start + 8) + .and_then(|b| <[u8; 8]>::try_from(b).ok()) + .map(u64::from_le_bytes) + .unwrap_or(SandboxMemoryLayout::STACK_POINTER_SIZE_BYTES) as usize; + // Clamp defensively: a healthy buffer always has its stack pointer + // in `[8, size]`. If it is somehow out of range, fall back to + // capturing just the (empty) 8-byte header. + let used = stack_pointer.clamp(SandboxMemoryLayout::STACK_POINTER_SIZE_BYTES as usize, size); + scratch + .get(start..start + used) + .map(<[u8]>::to_vec) + .unwrap_or_default() +} + /// Return true if `virt_base` is a VA we must not preserve into the /// rebuilt snapshot page tables: it is either part of the scratch /// region (re-mapped freshly by `map_specials`) or, on amd64, part of @@ -379,11 +454,16 @@ impl Snapshot { load_info, stack_top_gva: exn_stack_top_gva, sregs: None, + regs: None, + fpu: None, + msrs: None, entrypoint: NextAction::Initialise(load_addr + entrypoint_va - base_va), snapshot_generation: 0, host_functions: HostFunctionDetails { host_functions: None, }, + io_buffers: None, + extract_guard: None, }) } @@ -405,12 +485,24 @@ impl Snapshot { root_pt_gpas: &[u64], stack_top_gva: u64, sregs: CommonSpecialRegisters, + regs: Option, + fpu: Option, + msrs: Option>, entrypoint: NextAction, snapshot_generation: u64, host_functions: HostFunctionDetails, ) -> Result { let mut phys_seen = HashMap::::new(); let scratch_gva = scratch_base_gva(layout.get_scratch_size()); + // Capture the live IO data buffers for paused snapshots only. + // A quiescent snapshot (no register state) has no in-flight + // host-call data worth preserving, and its buffers are + // correctly reset to empty on restore. + let io_buffers = if regs.is_some() { + Some(capture_io_buffers(scratch_mem, &layout)?) + } else { + None + }; let memory = shared_mem.with_contents(|snap_c| { scratch_mem.with_contents(|scratch_c| { // Phase 1: walk every PT root together. This detects @@ -558,9 +650,14 @@ impl Snapshot { load_info, stack_top_gva, sregs: Some(sregs), + regs, + fpu, + msrs, entrypoint, snapshot_generation, host_functions, + io_buffers, + extract_guard: None, }) } @@ -569,6 +666,13 @@ impl Snapshot { self.snapshot_generation } + /// Live IO data buffers captured for a paused snapshot, if any. + /// Used on restore to repopulate scratch memory with the in-flight + /// host-call data. + pub(crate) fn io_buffers(&self) -> Option<&IoBuffers> { + self.io_buffers.as_ref() + } + /// Return the main memory contents of the snapshot #[instrument(skip_all, parent = Span::current(), level= "Trace")] pub(crate) fn memory(&self) -> &ReadonlySharedMemory { @@ -601,6 +705,30 @@ impl Snapshot { self.sregs.as_ref() } + /// General-purpose registers saved when the snapshot was taken mid-execution. + /// None for snapshots of quiescent sandboxes. + pub(crate) fn regs(&self) -> Option<&CommonRegisters> { + self.regs.as_ref() + } + + /// Returns `true` if this snapshot was captured mid-execution + /// (i.e. has saved register state for resume). + pub fn is_paused_snapshot(&self) -> bool { + self.regs.is_some() + } + + /// FPU/SSE registers saved when the snapshot was taken mid-execution. + /// None for snapshots of quiescent sandboxes. + pub(crate) fn fpu(&self) -> Option<&CommonFpu> { + self.fpu.as_ref() + } + + /// Model-specific registers saved when the snapshot was taken + /// mid-execution. None for snapshots of quiescent sandboxes. + pub(crate) fn msrs(&self) -> Option<&[(u32, u64)]> { + self.msrs.as_deref() + } + pub(crate) fn entrypoint(&self) -> NextAction { self.entrypoint } @@ -759,6 +887,9 @@ mod tests { &[pt_base], 0, default_sregs(), + None, + None, + None, super::NextAction::None, 1, HostFunctionDetails::default(), @@ -776,6 +907,9 @@ mod tests { &[pt_base], 0, default_sregs(), + None, + None, + None, super::NextAction::None, 2, HostFunctionDetails::default(), @@ -794,4 +928,114 @@ mod tests { .with_contents(|contents| assert_eq!(&contents[0..pattern_b.len()], &pattern_b[..])) .unwrap(); } + + /// Read the used bytes (`[off, off + stack_pointer)`) of an IO data + /// buffer directly from scratch memory. + fn read_used(mem: &HostSharedMemory, off: usize) -> Vec { + let sp = mem.read::(off).unwrap() as usize; + let mut bytes = vec![0u8; sp]; + mem.copy_to_slice(&mut bytes, off).unwrap(); + bytes + } + + #[test] + fn capture_io_buffer_returns_used_bytes_and_clamps() { + let size = 64usize; + let start = 8usize; + let mut scratch = vec![0u8; start + size]; + + // Empty buffer: stack pointer == 8 -> just the header. + scratch[start..start + 8].copy_from_slice(&8u64.to_le_bytes()); + assert_eq!(super::capture_io_buffer(&scratch, start, size).len(), 8); + + // Non-empty buffer: stack pointer points past some data. + scratch[start..start + 8].copy_from_slice(&20u64.to_le_bytes()); + for (i, b) in scratch[start + 8..start + 20].iter_mut().enumerate() { + *b = i as u8; + } + let captured = super::capture_io_buffer(&scratch, start, size); + assert_eq!(captured.len(), 20); + assert_eq!(&captured[..8], &20u64.to_le_bytes()); + + // Out-of-range stack pointer is clamped to the buffer size. + scratch[start..start + 8].copy_from_slice(&9999u64.to_le_bytes()); + assert_eq!(super::capture_io_buffer(&scratch, start, size).len(), size); + } + + #[test] + fn quiescent_snapshot_has_no_io_buffers() { + let (mut mgr, pt_base) = make_simple_pt_mgr(); + let snapshot = super::Snapshot::new( + &mut make_simple_pt_mem(&[0u8; PAGE_SIZE]).build().0, + &mut mgr.scratch_mem, + mgr.layout, + LoadInfo::dummy(), + Vec::new(), + &[pt_base], + 0, + default_sregs(), + None, // no register state => quiescent + None, + None, + super::NextAction::None, + 1, + HostFunctionDetails::default(), + ) + .unwrap(); + assert!(snapshot.io_buffers().is_none()); + } + + #[test] + fn paused_snapshot_captures_and_restores_io_buffers() { + let (mut mgr, pt_base) = make_simple_pt_mgr(); + + // Simulate an in-flight host-call response sitting in the input + // data buffer, as it would be when the guest is paused just + // after a host call completed. + let payload = b"in-flight host call response"; + let in_off = mgr.layout.get_input_data_buffer_scratch_host_offset(); + let in_size = mgr.layout.input_data_size; + mgr.scratch_mem + .push_buffer(in_off, in_size, payload) + .unwrap(); + let expected_input = read_used(&mgr.scratch_mem, in_off); + assert!(expected_input.len() > 8); + + // A paused snapshot carries register state. + let snapshot = super::Snapshot::new( + &mut make_simple_pt_mem(&[0u8; PAGE_SIZE]).build().0, + &mut mgr.scratch_mem, + mgr.layout, + LoadInfo::dummy(), + Vec::new(), + &[pt_base], + 0, + default_sregs(), + Some(Default::default()), // register state => paused + Some(Default::default()), + None, + super::NextAction::None, + 1, + HostFunctionDetails::default(), + ) + .unwrap(); + + let io = snapshot + .io_buffers() + .expect("paused snapshot must capture io buffers"); + assert_eq!(io.input, expected_input); + + // Restoring into a fresh sandbox must repopulate the input + // buffer so the resumed guest can pop the response, even though + // scratch is reset by update_scratch_bookkeeping during build. + let (restored, _) = SandboxMemoryManager::from_snapshot(&snapshot) + .unwrap() + .build() + .unwrap(); + assert_eq!(read_used(&restored.scratch_mem, in_off), expected_input); + + // The same must hold for the in-memory restore path. + mgr.restore_snapshot(&snapshot).unwrap(); + assert_eq!(read_used(&mgr.scratch_mem, in_off), expected_input); + } } diff --git a/src/hyperlight_host/tests/integration_test.rs b/src/hyperlight_host/tests/integration_test.rs index 3b3c6c108e..5860544792 100644 --- a/src/hyperlight_host/tests/integration_test.rs +++ b/src/hyperlight_host/tests/integration_test.rs @@ -13,14 +13,16 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Barrier}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Barrier, OnceLock}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; use hyperlight_common::flatbuffer_wrappers::guest_error::ErrorCode; use hyperlight_common::log_level::GuestLogFilter; +use hyperlight_host::hypervisor::InterruptHandle; use hyperlight_host::sandbox::SandboxConfiguration; +use hyperlight_host::sandbox::pending_call::CallProgress; use hyperlight_host::{HyperlightError, MultiUseSandbox}; use hyperlight_testing::simplelogger::{LOGGER, SimpleLogger}; use serial_test::serial; @@ -1890,3 +1892,705 @@ fn hw_timer_interrupts() { ); }); } + +// ===== Pause/Resume (call_async) tests ===== + +/// Pauses a spinning guest mid-execution and then resumes it to completion. +#[test] +fn pause_and_resume_guest_call() { + with_rust_sandbox(|mut sbox| { + let mut future = sbox.call_async::("Spin", ()); + + let handle = future.sandbox().interrupt_handle(); + + // Pause the VM from another thread after it's had time to enter the spin loop + let thread = thread::spawn(move || { + thread::sleep(Duration::from_millis(200)); + assert!( + handle.pause(), + "pause() should return true while vcpu is running" + ); + }); + + // First wait should yield Paused + let progress = future.poll().unwrap(); + assert!(matches!(progress, CallProgress::Paused)); + assert!(future.is_paused()); + + thread.join().unwrap(); + + // Now kill the VM so it doesn't spin forever after resume + let handle = future.sandbox().interrupt_handle(); + handle.kill(); + + // Resuming after kill should yield ExecutionCanceledByHost + match future.poll() { + Err(HyperlightError::ExecutionCanceledByHost()) => {} // expected + other => panic!("expected ExecutionCanceledByHost, got: {other:?}"), + } + }); +} + +/// Pauses and resumes a bounded guest call (SpinForMs) multiple times. +#[test] +fn pause_and_resume_multiple_times() { + with_rust_sandbox(|mut sbox| { + let mut pause_count = 0; + + { + let mut future = sbox.call_async::("SpinForMs", 2000u32); + + loop { + let handle = future.sandbox().interrupt_handle(); + // Schedule a pause 100ms from now + let t = thread::spawn(move || { + thread::sleep(Duration::from_millis(100)); + handle.pause(); + }); + + match future.poll().unwrap() { + CallProgress::Completed(_result) => { + t.join().unwrap(); + break; + } + CallProgress::Paused => { + pause_count += 1; + t.join().unwrap(); + // Continue — next wait() will resume + } + } + } + } + + assert!( + pause_count >= 1, + "Expected at least 1 pause, got {pause_count}" + ); + + // Sandbox should not be poisoned after successful completion + assert!(!sbox.poisoned()); + + // Confirm the sandbox is still usable + let echo: String = sbox.call("Echo", "after-pause".to_string()).unwrap(); + assert_eq!(echo, "after-pause"); + }); +} + +/// Dropping a paused PendingCall poisons the sandbox. +#[test] +fn drop_paused_future_poisons_sandbox() { + with_rust_sandbox(|mut sbox| { + let snapshot = sbox.snapshot().unwrap(); + + { + let mut future = sbox.call_async::("Spin", ()); + + let handle = future.sandbox().interrupt_handle(); + let thread = thread::spawn(move || { + thread::sleep(Duration::from_millis(200)); + handle.pause(); + }); + + let progress = future.poll().unwrap(); + assert!(matches!(progress, CallProgress::Paused)); + thread.join().unwrap(); + + // Drop future while paused — should poison + } + + assert!( + sbox.poisoned(), + "sandbox should be poisoned after dropping paused future" + ); + + // Restore clears the poison + sbox.restore(snapshot).unwrap(); + assert!(!sbox.poisoned()); + + // Sandbox works again + let echo: String = sbox.call("Echo", "recovered".to_string()).unwrap(); + assert_eq!(echo, "recovered"); + }); +} + +/// call_async completes without pause when no pause signal is sent. +#[test] +fn call_async_completes_without_pause() { + with_rust_sandbox(|mut sbox| { + { + let mut future = sbox.call_async::("Echo", "hello".to_string()); + + match future.poll().unwrap() { + CallProgress::Completed(val) => { + assert_eq!(val, "hello"); + } + CallProgress::Paused => { + panic!("unexpected pause when no pause signal was sent"); + } + } + } + + // Sandbox should not be poisoned + assert!(!sbox.poisoned()); + }); +} + +/// Calling wait() on a completed future returns an error. +#[test] +fn call_async_wait_after_completion_errors() { + with_rust_sandbox(|mut sbox| { + let mut future = sbox.call_async::("Echo", "test".to_string()); + + // First wait completes + let result = future.poll().unwrap(); + assert!(matches!(result, CallProgress::Completed(_))); + + // Second wait should error + match future.poll() { + Err(HyperlightError::Error(msg)) if msg.contains("already completed") => {} // expected + other => panic!("expected 'already completed' error, got: {other:?}"), + } + }); +} + +/// Pause through the normal call() path poisons the sandbox. +#[test] +fn pause_through_call_poisons_sandbox() { + with_rust_sandbox(|mut sbox| { + let snapshot = sbox.snapshot().unwrap(); + let handle = sbox.interrupt_handle(); + + // Pause from another thread during a normal call() + let thread = thread::spawn(move || { + thread::sleep(Duration::from_millis(200)); + handle.pause(); + }); + + let err = sbox.call::("Spin", ()).unwrap_err(); + assert!( + matches!(&err, HyperlightError::ExecutionPaused()), + "unexpected error: {err:?}" + ); + assert!( + sbox.poisoned(), + "sandbox should be poisoned when paused through call()" + ); + + thread.join().unwrap(); + + // Restore recovers + sbox.restore(snapshot).unwrap(); + assert!(!sbox.poisoned()); + + let echo: String = sbox.call("Echo", "ok".to_string()).unwrap(); + assert_eq!(echo, "ok"); + }); +} + +/// Snapshot can be taken while paused via PendingCall. +#[test] +fn snapshot_while_paused() { + with_rust_sandbox(|mut sbox| { + let mut future = sbox.call_async::("Spin", ()); + + let handle = future.sandbox().interrupt_handle(); + let thread = thread::spawn(move || { + thread::sleep(Duration::from_millis(200)); + handle.pause(); + }); + + let progress = future.poll().unwrap(); + assert!(matches!(progress, CallProgress::Paused)); + thread.join().unwrap(); + + // Taking a snapshot while paused should succeed + let _snapshot = future.snapshot().unwrap(); + + // Clean up: kill and let Drop handle it + future.kill(); + }); +} + +/// Snapshot a paused VM, restore it, and resume execution to completion. +#[test] +fn snapshot_restore_and_resume() { + with_rust_sandbox(|mut sbox| { + // Start a bounded call (2 seconds) + let mut future = sbox.call_async::("SpinForMs", 2000u32); + + // Pause it + let handle = future.sandbox().interrupt_handle(); + let thread = thread::spawn(move || { + thread::sleep(Duration::from_millis(200)); + handle.pause(); + }); + + let progress = future.poll().unwrap(); + assert!(matches!(progress, CallProgress::Paused)); + thread.join().unwrap(); + + // Take a snapshot while paused (captures registers) + let snapshot = future.snapshot().unwrap(); + + // Drop the future — this poisons the sandbox + drop(future); + assert!(sbox.poisoned()); + + // restore_paused takes the sandbox by value, recovering it into PendingCallOwned + let mut call = sbox.restore_paused::(snapshot).unwrap(); + + // Resume execution to completion + loop { + let handle = call.sandbox().interrupt_handle(); + let t = thread::spawn(move || { + // Set a generous timeout so the call can complete + thread::sleep(Duration::from_millis(3000)); + handle.pause(); + }); + + match call.poll().unwrap() { + CallProgress::Completed(_result) => { + t.join().unwrap(); + break; + } + CallProgress::Paused => { + t.join().unwrap(); + // If paused, resume on next poll() + } + } + } + + // Recover the sandbox from the completed call + let mut sbox = call.into_sandbox(); + + // Sandbox should be usable after resume completed + assert!(!sbox.poisoned()); + let echo: String = sbox + .call("Echo", "after-restore-resume".to_string()) + .unwrap(); + assert_eq!(echo, "after-restore-resume"); + }); +} + +/// Arbitrary-point pause: snapshot a guest paused mid CPU-spin (not at a host-call +/// boundary) and restore it into a *fresh* sandbox, proving the captured register +/// state is fully self-contained. `SpinForMs` is a pure busy loop and never makes a +/// host call, so `pause()` interrupts the vcpu at an arbitrary RIP. +#[test] +fn arbitrary_pause_snapshot_restores_into_fresh_sandbox() { + with_rust_sandbox(|mut sbox| { + // Start a bounded busy-loop and pause it mid-spin (arbitrary RIP). + let mut future = sbox.call_async::("SpinForMs", 2000u32); + + let handle = future.sandbox().interrupt_handle(); + let thread = thread::spawn(move || { + thread::sleep(Duration::from_millis(200)); + handle.pause(); + }); + + let progress = future.poll().unwrap(); + assert!(matches!(progress, CallProgress::Paused)); + thread.join().unwrap(); + + // Capture register state at the arbitrary pause point. + // (restore_paused below errors if the snapshot lacks register state.) + let snapshot = future.snapshot().unwrap(); + + // Discard the original sandbox entirely. + future.kill(); + drop(sbox); + + // Restore into a brand-new sandbox and resume to completion. + let fresh: MultiUseSandbox = new_rust_sandbox(); + let mut call = fresh.restore_paused::(snapshot).unwrap(); + + loop { + let handle = call.sandbox().interrupt_handle(); + let t = thread::spawn(move || { + thread::sleep(Duration::from_millis(3000)); + handle.pause(); + }); + + match call.poll().unwrap() { + CallProgress::Completed(_result) => { + t.join().unwrap(); + break; + } + CallProgress::Paused => { + t.join().unwrap(); + } + } + } + + // The fresh sandbox is fully usable after resuming the arbitrary-point snapshot. + let mut sbox = call.into_sandbox(); + assert!(!sbox.poisoned()); + let echo: String = sbox.call("Echo", "fresh-resume".to_string()).unwrap(); + assert_eq!(echo, "fresh-resume"); + }); +} + +/// Stronger arbitrary-pause proof: repeatedly pause a single guest computation +/// at many different arbitrary RIPs, snapshotting and restoring into a *fresh* +/// sandbox each time, and assert the final checksum still matches an un-paused +/// reference run. +/// +/// The guest runs one long, fully-deterministic computation +/// (`DeterministicCompute`) that keeps floating-point (SSE/XMM + FPU), +/// general-purpose register, and live-stack state busy throughout. We pause it +/// roughly every few milliseconds of guest progress; each pause is snapshotted, +/// the sandbox is discarded, and the snapshot is restored into a brand-new +/// sandbox which then continues — so the computation is carried across 100+ +/// independent snapshot/restore boundaries before it completes. +/// +/// `DeterministicCompute` interleaves a chaotic floating-point recurrence (a +/// logistic map), an FNV-style integer hash, and periodic recursion. Because +/// the logistic map is chaotic, losing or corrupting even a single bit of live +/// register/stack state at *any* of those boundaries would make the final +/// checksum diverge wildly from the reference (or abort the guest). Getting the +/// *same* checksum back after 100+ arbitrary-RIP round-trips is therefore strong +/// evidence that the snapshot captures the complete live CPU state at an +/// arbitrary instruction boundary — not just at a host-call safepoint. +#[test] +fn arbitrary_pause_preserves_fp_and_stack_state_across_snapshot_restore() { + // Minimum number of mid-flight pause/snapshot/restore round-trips we require + // a single computation to survive. + const TARGET_PAUSES: u32 = 100; + + // Iteration count large enough that the computation takes long enough to be + // paused TARGET_PAUSES+ times with a small per-pause delay. + const ROUNDS: u32 = 200_000_000; + + // Reference value from an un-paused run, plus its wall-clock duration so we + // can scale the pause delay to this machine. + let (reference, dur) = { + let mut sbox = new_rust_sandbox(); + let t0 = Instant::now(); + let r: u64 = sbox.call("DeterministicCompute", ROUNDS).unwrap(); + (r, t0.elapsed()) + }; + + assert!( + dur > Duration::from_millis(500), + "computation too fast ({dur:?}) to pause {TARGET_PAUSES}+ times; increase ROUNDS" + ); + + // The pausing thread sleeps `delay` and then pauses, so the guest advances by + // roughly `delay` of compute per cycle. Aim to overshoot TARGET_PAUSES so we + // comfortably clear the assertion even with timing jitter. Snapshot/restore + // overhead is wall-clock only and does not advance the guest, so it does not + // reduce the pause count. + let delay = dur / (TARGET_PAUSES + 60); + + // Start the computation in a fresh sandbox, then carry it across many + // snapshot/restore boundaries. + let fresh = new_rust_sandbox(); + let mut call = fresh.call_async_owned::("DeterministicCompute", ROUNDS); + + let mut pauses = 0u32; + let result = loop { + let handle = call.sandbox().interrupt_handle(); + let d = delay; + let t = thread::spawn(move || { + thread::sleep(d); + handle.pause(); + }); + + let progress = call.poll().unwrap(); + t.join().unwrap(); + + match progress { + CallProgress::Completed(r) => break r, + CallProgress::Paused => { + pauses += 1; + + // Snapshot the arbitrary-RIP paused state, discard the sandbox + // entirely, and restore into a brand-new one to continue. + let snapshot = call.snapshot().unwrap(); + drop(call); + let restored = new_rust_sandbox(); + call = restored.restore_paused::(snapshot).unwrap(); + } + } + }; + + assert_eq!( + result, reference, + "checksum diverged after {pauses} arbitrary-RIP snapshot/restore round-trips \ + — live FP/GPR/stack state was not fully preserved" + ); + assert!( + pauses >= TARGET_PAUSES, + "only paused {pauses} times (wanted >= {TARGET_PAUSES}); computation finished \ + too quickly to exercise enough arbitrary-RIP round-trips (increase ROUNDS)" + ); + + // The final restored sandbox must remain fully usable afterwards. + let mut sbox = call.into_sandbox(); + assert!(!sbox.poisoned()); + let echo: String = sbox.call("Echo", "fp-state-ok".to_string()).unwrap(); + assert_eq!(echo, "fp-state-ok"); +} + +/// Requests a pause while the guest is blocked inside a host function call. +/// A host call cannot be interrupted, but as soon as it returns the run loop +/// completes the in-flight `OUT` and stops immediately — so the pause is +/// honored right after the triggering host call, without needing a further +/// host call. `HostCallLoop` would keep calling the host function forever, yet +/// the very first call's return is enough to land on `CallProgress::Paused` +/// (no crash, no poison). +#[test] +fn pause_during_host_call() { + with_rust_uninit_sandbox(|mut usbox| { + let barrier = Arc::new(Barrier::new(2)); + let barrier2 = barrier.clone(); + + // Host function signals (once) when it is entered, then sleeps so the pause + // request lands while we are still inside the host call. + let entered = Arc::new(AtomicBool::new(false)); + let entered2 = entered.clone(); + let spin = move || { + if !entered2.swap(true, Ordering::SeqCst) { + barrier2.wait(); + } + thread::sleep(Duration::from_millis(300)); + Ok(()) + }; + usbox.register("Spin", spin).unwrap(); + + let mut sbox: MultiUseSandbox = usbox.evolve().unwrap(); + + let mut future = sbox.call_async::>("HostCallLoop", "Spin".to_string()); + let handle = future.sandbox().interrupt_handle(); + + // Pause as soon as the host call is in progress. The vcpu is not running + // during a host call, so pause() may return false (no signal sent) but still + // sets the sticky pause flag, honored at the next host-call boundary. + let thread = thread::spawn(move || { + barrier.wait(); + handle.pause(); + }); + + // Pause requested mid host-call; honored at the next host-call boundary. + let progress = future.poll().unwrap(); + assert!(matches!(progress, CallProgress::Paused)); + assert!(future.is_paused()); + thread.join().unwrap(); + + // Kill so the host-call loop doesn't run forever after resume. + let handle = future.sandbox().interrupt_handle(); + handle.kill(); + match future.poll() { + Err(HyperlightError::ExecutionCanceledByHost()) => {} + other => panic!("expected ExecutionCanceledByHost, got: {other:?}"), + } + }); +} + +/// A host function may itself request a pause: the guest calls a host function, +/// and that host function calls `interrupt_handle().pause()` so the host can +/// checkpoint at a point the *guest* chose (a guest-initiated "yield"). +/// +/// This answers the question: does that host call run **twice** — once before +/// the pause, and again on resume? It does not. When `pause()` is called from +/// inside a host function the vcpu is not in its run ioctl, so only the sticky +/// pause flag is set (no signal is delivered). The triggering host call runs to +/// completion exactly once; the run loop then completes the in-flight `OUT` and +/// pauses immediately after it, leaving nothing deferred to re-run on resume. +/// `CallHostNTimes` calls the host function exactly `n` times, so if any call +/// were serviced twice the host-side counter would read `n + 1`. We assert it +/// reads exactly `n`, both for the triggering call and overall. +/// +/// This variant resumes the call **in place** (same process / same vcpu). +#[test] +fn yield_host_call_runs_exactly_once_in_place_resume() { + const N: i32 = 4; + with_rust_uninit_sandbox(|mut usbox| { + let calls = Arc::new(AtomicU64::new(0)); + let armed = Arc::new(AtomicBool::new(true)); + let handle_slot: Arc>> = Arc::new(OnceLock::new()); + + let calls2 = calls.clone(); + let armed2 = armed.clone(); + let slot2 = handle_slot.clone(); + let yield_fn = move || { + calls2.fetch_add(1, Ordering::SeqCst); + // On the first invocation only, request a pause from *inside* the + // host call — the guest-initiated "yield". + if armed2.swap(false, Ordering::SeqCst) + && let Some(h) = slot2.get() + { + h.pause(); + } + Ok(()) + }; + usbox.register("Yield", yield_fn).unwrap(); + + let sbox: MultiUseSandbox = usbox.evolve().unwrap(); + let mut call = sbox.call_async_owned::("CallHostNTimes", ("Yield".to_string(), N)); + handle_slot.set(call.sandbox().interrupt_handle()).unwrap(); + + // Drive to completion, resuming in place across the yield-triggered pause. + let mut pauses = 0u32; + let result = loop { + match call.poll().unwrap() { + CallProgress::Completed(r) => break r, + CallProgress::Paused => pauses += 1, + } + }; + + assert_eq!(result, N, "guest should report it made N host calls"); + assert!( + pauses >= 1, + "the yield host call should have triggered at least one pause" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + N as u64, + "the host function must run exactly once per guest-side call (N total), \ + not twice — a yield-triggered pause must not re-execute any host call" + ); + }); +} + +/// Same guarantee as `yield_host_call_runs_exactly_once_in_place_resume`, but the +/// yield-paused state is snapshotted, the original sandbox discarded, and the +/// call resumed in a **fresh sandbox** (the snapshot/restore path, e.g. resuming +/// in another process). The pause lands right after the triggering call with RIP +/// already past the `OUT`, so the fresh vcpu simply continues from there — the +/// triggering call is not re-run. The shared host-side counter must therefore +/// end at exactly `n`, never `n + 1`. +#[test] +fn yield_host_call_runs_exactly_once_across_snapshot_restore() { + const N: i32 = 4; + + let calls = Arc::new(AtomicU64::new(0)); + let armed = Arc::new(AtomicBool::new(true)); + let handle_slot: Arc>> = Arc::new(OnceLock::new()); + + // Builds a fresh sandbox with the yield host function registered against the + // shared counter/armed/handle state. + let build = |calls: &Arc, + armed: &Arc, + slot: &Arc>>| + -> MultiUseSandbox { + let mut usbox = new_rust_uninit_sandbox(); + let calls2 = calls.clone(); + let armed2 = armed.clone(); + let slot2 = slot.clone(); + let yield_fn = move || { + calls2.fetch_add(1, Ordering::SeqCst); + if armed2.swap(false, Ordering::SeqCst) + && let Some(h) = slot2.get() + { + h.pause(); + } + Ok(()) + }; + usbox.register("Yield", yield_fn).unwrap(); + usbox.evolve().unwrap() + }; + + let sbox = build(&calls, &armed, &handle_slot); + let mut call = sbox.call_async_owned::("CallHostNTimes", ("Yield".to_string(), N)); + handle_slot.set(call.sandbox().interrupt_handle()).unwrap(); + + // First poll: the guest's first host call yields, triggering a pause. + match call.poll().unwrap() { + CallProgress::Paused => {} + CallProgress::Completed(_) => panic!("expected the yield to pause before completion"), + } + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "exactly one host call (the yielding one) should have run before the pause" + ); + + // Snapshot the yield-paused state, discard the sandbox, and resume in a fresh one. + let snapshot = call.snapshot().unwrap(); + drop(call); + + let fresh = build(&calls, &armed, &handle_slot); + let mut call = fresh.restore_paused::(snapshot).unwrap(); + + let result = loop { + match call.poll().unwrap() { + CallProgress::Completed(r) => break r, + CallProgress::Paused => {} + } + }; + + assert_eq!(result, N, "guest should report it made N host calls"); + assert_eq!( + calls.load(Ordering::SeqCst), + N as u64, + "across snapshot/restore the host function must run exactly once per \ + guest-side call (N total) — the yielding call must not be dropped nor \ + executed twice" + ); +} + +/// Regression test for the key property of the immediate pause-after-host-call +/// behavior: a pause requested from inside a host call is honored even when the +/// guest makes **no further host call** before it would otherwise return. +/// +/// With the older "pause at the next host-call boundary" behavior this case was +/// a silent drop: `CallHostNTimes(.., 1)` makes a single host call, and the only +/// remaining `OUT` is the guest's own halt/return, which is not a pausable +/// boundary — so the guest ran to completion and the pause was lost. Now the run +/// loop completes the in-flight `OUT` and stops immediately after the call, so a +/// single yielding host call is enough to pause. +#[test] +fn pause_during_single_host_call_pauses_immediately() { + const N: i32 = 1; + with_rust_uninit_sandbox(|mut usbox| { + let calls = Arc::new(AtomicU64::new(0)); + let handle_slot: Arc>> = Arc::new(OnceLock::new()); + + let calls2 = calls.clone(); + let slot2 = handle_slot.clone(); + let yield_fn = move || { + calls2.fetch_add(1, Ordering::SeqCst); + if let Some(h) = slot2.get() { + h.pause(); + } + Ok(()) + }; + usbox.register("Yield", yield_fn).unwrap(); + + let sbox: MultiUseSandbox = usbox.evolve().unwrap(); + let mut call = sbox.call_async_owned::("CallHostNTimes", ("Yield".to_string(), N)); + handle_slot.set(call.sandbox().interrupt_handle()).unwrap(); + + // The single host call requests a pause. Even though the guest would + // immediately return afterwards (no further host call), the pause must + // be honored right after that call returns. + match call.poll().unwrap() { + CallProgress::Paused => {} + CallProgress::Completed(_) => { + panic!( + "pause requested during the only host call was dropped: the guest ran to completion" + ) + } + } + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "the single host call should have run exactly once before pausing" + ); + + // Resuming runs the guest to completion; the call is not re-run. + let result = loop { + match call.poll().unwrap() { + CallProgress::Completed(r) => break r, + CallProgress::Paused => {} + } + }; + assert_eq!(result, N, "guest should report it made N host calls"); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "the host call must not be re-run on resume" + ); + }); +} diff --git a/src/tests/rust_guests/simpleguest/src/main.rs b/src/tests/rust_guests/simpleguest/src/main.rs index 959c43c72e..5199f56151 100644 --- a/src/tests/rust_guests/simpleguest/src/main.rs +++ b/src/tests/rust_guests/simpleguest/src/main.rs @@ -438,6 +438,76 @@ fn spin_for_ms(milliseconds: u32) -> u64 { counter / iterations_per_ms as u64 } +/// Recurse to a fixed depth, threading a value through the stack so that a +/// meaningful amount of live stack must be captured/restored if the guest is +/// paused while inside the recursion. +fn checksum_recurse(depth: u32, seed: u64) -> u64 { + if depth == 0 { + return seed; + } + let local = seed + .wrapping_mul(depth as u64) + .wrapping_add(0x9e37_79b9_7f4a_7c15); + black_box(local); + let deeper = checksum_recurse(depth - 1, local); + black_box(deeper).wrapping_add(local) +} + +/// A long, fully deterministic CPU-bound computation whose result depends on +/// floating-point (SSE/XMM + FPU), general-purpose register, and stack state +/// staying intact throughout. +/// +/// It interleaves a chaotic floating-point recurrence (a logistic map, which +/// amplifies any perturbation of the XMM/FPU state into a wildly different +/// result), an FNV-style integer hash, and periodic recursion (live stack). +/// Because the logistic map is chaotic, losing or corrupting even a single bit +/// of live register/stack state across a pause → snapshot → restore would make +/// the returned checksum diverge from the un-paused reference value — or abort +/// the guest outright. Returning the *same* checksum as an un-paused run is +/// therefore strong evidence that the snapshot captured the complete live state +/// at the arbitrary pause point. +#[guest_function("DeterministicCompute")] +fn deterministic_compute(rounds: u32) -> u64 { + let mut acc: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis + let mut x: f64 = 0.5; + let mut y: f64 = core::f64::consts::FRAC_1_PI; + + for i in 0..rounds { + // Chaotic, bounded floating-point recurrence. Keeps XMM/FPU state live + // and sensitive: any divergence cascades. + x = 3.9 * x * (1.0 - x); + y = (y + x) * 0.5 + 0.25 * (x - y) * (x - y); + + // Fold the floating-point state into a deterministic integer checksum. + let bits = x.to_bits() ^ y.rotate_bits(); + acc ^= bits.wrapping_add(i as u64); + acc = acc.wrapping_mul(0x0000_0100_0000_01b3); // FNV-1a prime + + // Periodically thread the checksum through a recursive call so that a + // pause has a chance to land while live stack is deep. + if i % 97 == 0 { + acc = acc.wrapping_add(checksum_recurse(16, acc)); + } + + black_box(acc); + black_box(x); + black_box(y); + } + + acc ^ x.to_bits() ^ y.to_bits() +} + +/// Helper trait to fold an `f64`'s bit pattern in a slightly non-trivial way, +/// keeping the value flowing through a method call. +trait RotateBits { + fn rotate_bits(self) -> u64; +} +impl RotateBits for f64 { + fn rotate_bits(self) -> u64 { + self.to_bits().rotate_left(17) + } +} + #[guest_function("GuestAbortWithCode")] fn test_abort(code: i32) { abort_with_code(&[code as u8]); @@ -1057,6 +1127,17 @@ fn host_call_loop(host_func_name: String) -> Result> { } } +// Calls the given host function (no param, no return value) exactly `n` times, +// then returns `n`. Used to prove that a host call which itself requests a pause +// is serviced exactly once per guest-side invocation across a pause/resume. +#[guest_function("CallHostNTimes")] +fn call_host_n_times(host_func_name: String, n: i32) -> Result { + for _ in 0..n { + call_host_function::<()>(&host_func_name, None, ReturnType::Void)?; + } + Ok(n) +} + // Calls the given host function (no param, no return value) and then spins indefinitely. #[guest_function("CallHostThenSpin")] fn call_host_then_spin(host_func_name: String) -> Result<()> {