From 5ca2fba475aebfbfbd47e76f35cfa5fb6320df21 Mon Sep 17 00:00:00 2001 From: Nikhil Unni Date: Tue, 9 Jun 2026 11:09:47 -0700 Subject: [PATCH 1/4] feat(snapshot): MAP_SHARED memory + Msync/MsyncAndState off-pause flush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clean-room implementation of the engrams live-migration fork surface on upstream v1.16.0 (engrams ADR 0045 Phase B). Derived from pristine upstream + the engrams spec, NOT from any AGPL-licensed fork. Inert/opt-in — every existing path (boot, Full/Diff create, File/Uffd restore) is byte-identical to stock; never touches src/vmm/src/snapshot/ and never changes SNAPSHOT_VERSION (10.0.0). - vstate/memory.rs: a `msync()` GuestMemoryExtension method; a `shared` flag on the `snapshot_file` region builder flipping MAP_PRIVATE -> MAP_SHARED. - vmm_config/snapshot.rs: SnapshotType::{Msync, MsyncAndState} + a top-level `shared` load param. - vstate/vm.rs: snapshot_memory_to_file dispatches the msync types (in-place flush, no file dump). - persist.rs: create_snapshot skips the state file for Msync; guest_memory_from_file threads `shared` (opens the memfile rw when shared). - api_server + swagger: surface the new snapshot_type values and `shared`. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/firecracker/src/api_server/mod.rs | 2 + .../src/api_server/request/snapshot.rs | 6 ++ src/firecracker/swagger/firecracker.yaml | 13 ++++- src/vmm/src/persist.rs | 32 +++++++--- src/vmm/src/rpc_interface.rs | 10 ++++ src/vmm/src/vmm_config/snapshot.rs | 16 +++++ src/vmm/src/vstate/memory.rs | 58 +++++++++++++++---- src/vmm/src/vstate/vm.rs | 13 +++++ src/vmm/tests/integration_tests.rs | 2 + 9 files changed, 130 insertions(+), 22 deletions(-) diff --git a/src/firecracker/src/api_server/mod.rs b/src/firecracker/src/api_server/mod.rs index ecc6a7217..09c7dadbd 100644 --- a/src/firecracker/src/api_server/mod.rs +++ b/src/firecracker/src/api_server/mod.rs @@ -159,6 +159,8 @@ impl ApiServer { &METRICS.latencies_us.diff_create_snapshot, "create diff snapshot", )), + // ADR 0045 off-pause flush — no dedicated latency metric. + SnapshotType::Msync | SnapshotType::MsyncAndState => None, }, VmmAction::LoadSnapshot(_) => { Some((&METRICS.latencies_us.load_snapshot, "load snapshot")) diff --git a/src/firecracker/src/api_server/request/snapshot.rs b/src/firecracker/src/api_server/request/snapshot.rs index e6640ac33..3fd57682c 100644 --- a/src/firecracker/src/api_server/request/snapshot.rs +++ b/src/firecracker/src/api_server/request/snapshot.rs @@ -112,6 +112,7 @@ fn parse_put_snapshot_load(body: &Body) -> Result { network_overrides: snapshot_config.network_overrides, vsock_override: snapshot_config.vsock_override, clock_realtime: snapshot_config.clock_realtime, + shared: snapshot_config.shared, }; // Construct the `ParsedRequest` object. @@ -191,6 +192,7 @@ mod tests { network_overrides: vec![], vsock_override: None, clock_realtime: false, + shared: false, }; let mut parsed_request = parse_put_snapshot(&Body::new(body), Some("load")).unwrap(); assert!( @@ -223,6 +225,7 @@ mod tests { network_overrides: vec![], vsock_override: None, clock_realtime: false, + shared: false, }; let mut parsed_request = parse_put_snapshot(&Body::new(body), Some("load")).unwrap(); assert!( @@ -255,6 +258,7 @@ mod tests { network_overrides: vec![], vsock_override: None, clock_realtime: false, + shared: false, }; let mut parsed_request = parse_put_snapshot(&Body::new(body), Some("load")).unwrap(); assert!( @@ -296,6 +300,7 @@ mod tests { }], vsock_override: None, clock_realtime: false, + shared: false, }; let mut parsed_request = parse_put_snapshot(&Body::new(body), Some("load")).unwrap(); assert!( @@ -325,6 +330,7 @@ mod tests { network_overrides: vec![], vsock_override: None, clock_realtime: false, + shared: false, }; let parsed_request = parse_put_snapshot(&Body::new(body), Some("load")).unwrap(); assert_eq!( diff --git a/src/firecracker/swagger/firecracker.yaml b/src/firecracker/swagger/firecracker.yaml index d169ed194..4f5c57be2 100644 --- a/src/firecracker/swagger/firecracker.yaml +++ b/src/firecracker/swagger/firecracker.yaml @@ -1621,9 +1621,13 @@ definitions: enum: - Full - Diff + - Msync + - MsyncAndState description: Type of snapshot to create. It is optional and by default, a full - snapshot is created. + snapshot is created. `Msync` flushes a MAP_SHARED file-backed guest + memory to its backing memfile via msync(MS_SYNC) without writing the + machine state; `MsyncAndState` also writes the state file. NetworkOverride: type: object @@ -1710,6 +1714,13 @@ definitions: elapsed since the snapshot was taken. When false (default), kvmclock resumes from where it was at snapshot time. This option may be extended to other clock sources and CPU architectures in the future." + shared: + type: boolean + description: + When set to true and the memory is restored from a file, map the guest + memory MAP_SHARED so guest writes flush back to the backing memfile (the + page-readable source for post-copy live migration). No effect for the UFFD + backend. Optional; defaults to false (MAP_PRIVATE). TokenBucket: diff --git a/src/vmm/src/persist.rs b/src/vmm/src/persist.rs index feb53c0e1..da640b88d 100644 --- a/src/vmm/src/persist.rs +++ b/src/vmm/src/persist.rs @@ -34,7 +34,9 @@ use crate::utils::u64_to_usize; use crate::vmm_config::boot_source::BootSourceConfig; use crate::vmm_config::instance_info::InstanceInfo; use crate::vmm_config::machine_config::{HugePageConfig, MachineConfigError, MachineConfigUpdate}; -use crate::vmm_config::snapshot::{CreateSnapshotParams, LoadSnapshotParams, MemBackendType}; +use crate::vmm_config::snapshot::{ + CreateSnapshotParams, LoadSnapshotParams, MemBackendType, SnapshotType, +}; use crate::vstate::kvm::KvmState; use crate::vstate::memory::{ self, GuestMemoryState, GuestRegionMmap, GuestRegionType, MemoryError, @@ -168,11 +170,16 @@ pub fn create_snapshot( vm_info: &VmInfo, params: &CreateSnapshotParams, ) -> Result<(), CreateSnapshotError> { - let microvm_state = vmm - .save_state(vm_info) - .map_err(CreateSnapshotError::MicrovmState)?; - - snapshot_state_to_file(µvm_state, ¶ms.snapshot_path)?; + // `Msync` is a memory-only off-pause flush: the machine state file is not + // written (the durable state rides a separate `MsyncAndState`/`Full` capture). + // Every other type writes the state file as usual. + if params.snapshot_type != SnapshotType::Msync { + let microvm_state = vmm + .save_state(vm_info) + .map_err(CreateSnapshotError::MicrovmState)?; + + snapshot_state_to_file(µvm_state, ¶ms.snapshot_path)?; + } let kvm_vm = vmm.vm.as_kvm().ok_or_else(|| { CreateSnapshotError::MicrovmState(MicrovmStateError::NotAllowed( @@ -449,7 +456,7 @@ pub fn restore_from_snapshot( .into()); } ( - guest_memory_from_file(mem_backend_path, mem_state, track_dirty_pages) + guest_memory_from_file(mem_backend_path, mem_state, track_dirty_pages, params.shared) .map_err(RestoreFromSnapshotGuestMemoryError::File)?, None, ) @@ -512,9 +519,16 @@ fn guest_memory_from_file( mem_file_path: &Path, mem_state: &GuestMemoryState, track_dirty_pages: bool, + shared: bool, ) -> Result, GuestMemoryFromFileError> { - let mem_file = File::open(mem_file_path)?; - let guest_mem = memory::snapshot_file(mem_file, mem_state.regions(), track_dirty_pages)?; + // A `MAP_SHARED` mapping that flushes guest writes back needs the fd opened + // for writing; the default `MAP_PRIVATE` restore only reads. + let mem_file = OpenOptions::new() + .read(true) + .write(shared) + .open(mem_file_path)?; + let guest_mem = + memory::snapshot_file(mem_file, mem_state.regions(), track_dirty_pages, shared)?; Ok(guest_mem) } diff --git a/src/vmm/src/rpc_interface.rs b/src/vmm/src/rpc_interface.rs index 7693b305f..aac9ccd75 100644 --- a/src/vmm/src/rpc_interface.rs +++ b/src/vmm/src/rpc_interface.rs @@ -952,6 +952,15 @@ impl RuntimeApiController { elapsed_time_us ); } + // ADR 0045 off-pause flush. No dedicated latency metric (reuses the + // monotonic clock for the log line) to keep the fork surface minimal. + SnapshotType::Msync | SnapshotType::MsyncAndState => { + let elapsed_time_us = get_time_us(ClockType::Monotonic) - create_start_us; + info!( + "'create msync snapshot' VMM action took {} us.", + elapsed_time_us + ); + } } Ok(VmmData::Empty) } @@ -1331,6 +1340,7 @@ mod tests { network_overrides: vec![], vsock_override: None, clock_realtime: false, + shared: false, }, ))); check_unsupported(runtime_request(VmmAction::SetEntropyDevice( diff --git a/src/vmm/src/vmm_config/snapshot.rs b/src/vmm/src/vmm_config/snapshot.rs index 4f312f88f..017c20688 100644 --- a/src/vmm/src/vmm_config/snapshot.rs +++ b/src/vmm/src/vmm_config/snapshot.rs @@ -18,6 +18,13 @@ pub enum SnapshotType { /// Full snapshot. #[default] Full, + /// Memory-only flush of a `MAP_SHARED` file-backed guest memory to its backing + /// memfile via `msync(MS_SYNC)`. No machine state is written. The off-pause + /// continuous-flush primitive for ADR 0045 live teleport. + Msync, + /// Like [`SnapshotType::Msync`], but the machine state file is also written — + /// an `msync` memory flush paired with a normal `state` file. + MsyncAndState, } /// Specifies the method through which guest memory will get populated when @@ -85,6 +92,11 @@ pub struct LoadSnapshotParams { /// advancing kvmclock by the wall-clock time elapsed since the snapshot was taken. When false /// (default), kvmclock resumes from where it was at snapshot time. pub clock_realtime: bool, + /// When set and the memory is restored from a file (`MemBackendType::File`), + /// map the guest memory `MAP_SHARED` so guest writes flush back to the backing + /// memfile — the page-readable source for ADR 0045 post-copy. No effect for + /// the UFFD backend. Defaults to false (today's `MAP_PRIVATE` behavior). + pub shared: bool, } /// Stores the configuration for loading a snapshot that is provided by the user. @@ -120,6 +132,10 @@ pub struct LoadSnapshotConfig { /// [x86_64 only] When set to true, passes `KVM_CLOCK_REALTIME` to `KVM_SET_CLOCK` on restore. #[serde(default)] pub clock_realtime: bool, + /// Whether to map file-backed guest memory `MAP_SHARED` (ADR 0045 post-copy + /// source). Top-level (not inside `mem_backend`). Defaults to false. + #[serde(default)] + pub shared: bool, } /// Stores the configuration used for managing snapshot memory. diff --git a/src/vmm/src/vstate/memory.rs b/src/vmm/src/vstate/memory.rs index 7bff6ad07..8de06a1e2 100644 --- a/src/vmm/src/vstate/memory.rs +++ b/src/vmm/src/vstate/memory.rs @@ -76,6 +76,8 @@ pub enum MemoryError { SeekError(std::io::Error), /// Volatile memory error: {0} VolatileMemoryError(vm_memory::VolatileMemoryError), + /// Cannot msync guest memory to its backing file: {0} + Msync(std::io::Error), } impl From for MemoryError { @@ -574,11 +576,15 @@ pub fn anonymous( } /// Creates a GuestMemoryMmap given a `file` containing the data -/// and a `state` containing mapping information. +/// and a `state` containing mapping information. When `shared` is set, the +/// regions are mapped `MAP_SHARED` so guest writes flush back to the backing +/// file (the page-readable post-copy source, ADR 0045); otherwise `MAP_PRIVATE` +/// (the default restore behavior). pub fn snapshot_file( file: File, regions: impl Iterator, track_dirty_pages: bool, + shared: bool, ) -> Result, MemoryError> { let regions: Vec<_> = regions.collect(); let memory_size = regions @@ -593,12 +599,12 @@ pub fn snapshot_file( return Err(MemoryError::OffsetTooLarge); } - create( - regions.into_iter(), - libc::MAP_PRIVATE, - Some(file), - track_dirty_pages, - ) + let flags = if shared { + libc::MAP_SHARED + } else { + libc::MAP_PRIVATE + }; + create(regions.into_iter(), flags, Some(file), track_dirty_pages) } /// Defines the interface for snapshotting memory. @@ -622,6 +628,11 @@ where dirty_bitmap: &DirtyBitmap, ) -> Result<(), MemoryError>; + /// Flushes the regions' dirty pages to their backing file via `msync(MS_SYNC)`. + /// Only meaningful when the memory is mapped `MAP_SHARED` on a file (ADR 0045 + /// off-pause flush); a harmless flush for anonymous mappings. + fn msync(&self) -> Result<(), MemoryError>; + /// Resets all the memory region bitmaps fn reset_dirty(&self); @@ -713,6 +724,28 @@ impl GuestMemoryExtension for GuestMemoryMmap { .map_err(MemoryError::WriteMemory) } + /// Flushes the regions' dirty pages to their backing file via `msync(MS_SYNC)`. + fn msync(&self) -> Result<(), MemoryError> { + self.iter().try_for_each(|region| { + // `region` derefs to its underlying MmapRegion. For a MAP_SHARED + // file-backed region, MS_SYNC blocks until the dirty pages are + // written back to the backing file; for anonymous memory it's a + // harmless flush. + // SAFETY: `as_ptr()`/`size()` describe this region's valid host mmap. + let ret = unsafe { + libc::msync( + region.as_ptr().cast::(), + region.size(), + libc::MS_SYNC, + ) + }; + if ret != 0 { + return Err(MemoryError::Msync(std::io::Error::last_os_error())); + } + Ok(()) + }) + } + /// Dumps all pages of GuestMemoryMmap present in `dirty_bitmap` to a writer. fn dump_dirty( &self, @@ -921,7 +954,7 @@ mod tests { let regions = vec![(GuestAddress(0), page_size)]; let guest_regions = - snapshot_file(file, regions.into_iter(), dirty_page_tracking).unwrap(); + snapshot_file(file, regions.into_iter(), dirty_page_tracking, false).unwrap(); assert_eq!(guest_regions.len(), 1); guest_regions.iter().for_each(|region| { assert_eq!(region.bitmap().is_some(), dirty_page_tracking); @@ -942,7 +975,7 @@ mod tests { (GuestAddress(0x10000), page_size), (GuestAddress(0x20000), page_size), ]; - let guest_regions = snapshot_file(file, regions.into_iter(), false).unwrap(); + let guest_regions = snapshot_file(file, regions.into_iter(), false, false).unwrap(); assert_eq!(guest_regions.len(), 3); } @@ -954,7 +987,7 @@ mod tests { file.write_all(&vec![0x42u8; page_size]).unwrap(); let regions = vec![(GuestAddress(0), 2 * page_size)]; - let result = snapshot_file(file, regions.into_iter(), false); + let result = snapshot_file(file, regions.into_iter(), false, false); assert!(matches!(result.unwrap_err(), MemoryError::OffsetTooLarge)); } @@ -1145,7 +1178,7 @@ mod tests { guest_memory.dump(&mut memory_file).unwrap(); let restored_guest_memory = - into_region_ext(snapshot_file(memory_file, memory_state.regions(), false).unwrap()); + into_region_ext(snapshot_file(memory_file, memory_state.regions(), false, false).unwrap()); // Check that the region contents are the same. let mut restored_region = vec![0u8; page_size * 2]; @@ -1210,7 +1243,7 @@ mod tests { // We can restore from this because this is the first dirty dump. let restored_guest_memory = - into_region_ext(snapshot_file(file, memory_state.regions(), false).unwrap()); + into_region_ext(snapshot_file(file, memory_state.regions(), false, false).unwrap()); // Check that the region contents are the same. let mut restored_region = vec![0u8; region_size]; @@ -1434,6 +1467,7 @@ mod tests { memory_file, std::iter::once((GuestAddress(0), 2 * page_size)), false, + false, ) .unwrap(), ); diff --git a/src/vmm/src/vstate/vm.rs b/src/vmm/src/vstate/vm.rs index 4ba4bdc9c..8a49cdbea 100644 --- a/src/vmm/src/vstate/vm.rs +++ b/src/vmm/src/vstate/vm.rs @@ -575,6 +575,17 @@ impl KvmVm { ) -> Result<(), CreateSnapshotError> { use self::CreateSnapshotError::*; + // `Msync`/`MsyncAndState`: the guest memory is mmap'd `MAP_SHARED` on its + // own backing memfile, so "saving" memory is an in-place `msync(MS_SYNC)` + // of the regions — not a dump to a separate `mem_file_path` (unused here). + if matches!( + snapshot_type, + SnapshotType::Msync | SnapshotType::MsyncAndState + ) { + self.guest_memory().msync()?; + return Ok(()); + } + // Need to check this here, as we create the file in the line below let file_existed = mem_file_path.exists(); @@ -622,6 +633,8 @@ impl KvmVm { self.reset_dirty_bitmap(); self.guest_memory().reset_dirty(); } + // Handled by the early return above (in-place `msync`, no file dump). + SnapshotType::Msync | SnapshotType::MsyncAndState => unreachable!(), }; file.flush() diff --git a/src/vmm/tests/integration_tests.rs b/src/vmm/tests/integration_tests.rs index 45bc3301c..7dac7f095 100644 --- a/src/vmm/tests/integration_tests.rs +++ b/src/vmm/tests/integration_tests.rs @@ -317,6 +317,7 @@ fn verify_load_snapshot(snapshot_file: TempFile, memory_file: TempFile) { network_overrides: vec![], vsock_override: None, clock_realtime: false, + shared: false, })) .unwrap(); @@ -403,6 +404,7 @@ fn verify_load_snap_disallowed_after_boot_resources(res: VmmAction, res_name: &s network_overrides: vec![], vsock_override: None, clock_realtime: false, + shared: false, }); let err = preboot_api_controller.handle_preboot_request(req); assert!( From 4a9636d06620897647e649c6fb6f3df94ebfaa50 Mon Sep 17 00:00:00 2001 From: Nikhil Unni Date: Tue, 9 Jun 2026 16:51:25 -0700 Subject: [PATCH 2/4] feat(uffd): MAP_PRIVATE base-file backing + MISSING|MINOR registration (#4) --- .../src/api_server/request/snapshot.rs | 6 + src/firecracker/swagger/firecracker.yaml | 10 ++ src/vmm/Cargo.toml | 3 +- src/vmm/src/persist.rs | 105 +++++++++++++++++- src/vmm/src/rpc_interface.rs | 1 + src/vmm/src/vmm_config/snapshot.rs | 15 +++ src/vmm/tests/integration_tests.rs | 2 + 7 files changed, 136 insertions(+), 6 deletions(-) diff --git a/src/firecracker/src/api_server/request/snapshot.rs b/src/firecracker/src/api_server/request/snapshot.rs index 3fd57682c..fe0f7d01b 100644 --- a/src/firecracker/src/api_server/request/snapshot.rs +++ b/src/firecracker/src/api_server/request/snapshot.rs @@ -113,6 +113,7 @@ fn parse_put_snapshot_load(body: &Body) -> Result { vsock_override: snapshot_config.vsock_override, clock_realtime: snapshot_config.clock_realtime, shared: snapshot_config.shared, + uffd_base_file: snapshot_config.uffd_base_file, }; // Construct the `ParsedRequest` object. @@ -193,6 +194,7 @@ mod tests { vsock_override: None, clock_realtime: false, shared: false, + uffd_base_file: None, }; let mut parsed_request = parse_put_snapshot(&Body::new(body), Some("load")).unwrap(); assert!( @@ -226,6 +228,7 @@ mod tests { vsock_override: None, clock_realtime: false, shared: false, + uffd_base_file: None, }; let mut parsed_request = parse_put_snapshot(&Body::new(body), Some("load")).unwrap(); assert!( @@ -259,6 +262,7 @@ mod tests { vsock_override: None, clock_realtime: false, shared: false, + uffd_base_file: None, }; let mut parsed_request = parse_put_snapshot(&Body::new(body), Some("load")).unwrap(); assert!( @@ -301,6 +305,7 @@ mod tests { vsock_override: None, clock_realtime: false, shared: false, + uffd_base_file: None, }; let mut parsed_request = parse_put_snapshot(&Body::new(body), Some("load")).unwrap(); assert!( @@ -331,6 +336,7 @@ mod tests { vsock_override: None, clock_realtime: false, shared: false, + uffd_base_file: None, }; let parsed_request = parse_put_snapshot(&Body::new(body), Some("load")).unwrap(); assert_eq!( diff --git a/src/firecracker/swagger/firecracker.yaml b/src/firecracker/swagger/firecracker.yaml index 4f5c57be2..5f78bcdae 100644 --- a/src/firecracker/swagger/firecracker.yaml +++ b/src/firecracker/swagger/firecracker.yaml @@ -1721,6 +1721,16 @@ definitions: memory MAP_SHARED so guest writes flush back to the backing memfile (the page-readable source for post-copy live migration). No effect for the UFFD backend. Optional; defaults to false (MAP_PRIVATE). + uffd_base_file: + type: string + description: + Unified-memory-substrate base backing (engrams ADR 0045 v2b). When set + with the Uffd memory backend, guest memory is created as MAP_PRIVATE of + this shmem file instead of anonymous, and the UFFD registration uses + MISSING|MINOR. The external handler resolves base-identical pages with + UFFDIO_CONTINUE (page-cache sharing across same-template microVMs) and + divergent pages with UFFDIO_COPY; guest writes COW natively. Invalid + with the File backend. Optional. TokenBucket: diff --git a/src/vmm/Cargo.toml b/src/vmm/Cargo.toml index 9486e00ed..e8b7feaed 100644 --- a/src/vmm/Cargo.toml +++ b/src/vmm/Cargo.toml @@ -46,7 +46,8 @@ serde = { version = "1.0.228", features = ["derive", "rc"] } serde_json = "1.0.149" slab = "0.4.12" thiserror = "2.0.18" -userfaultfd = "0.9.0" +# linux5_13: RegisterMode::MINOR for the ADR 0045 substrate (v2b) registration. +userfaultfd = { version = "0.9.0", features = ["linux5_13"] } utils = { path = "../utils" } uuid = "1.23.1" vhost = { version = "0.15.0", features = ["vhost-user-frontend"] } diff --git a/src/vmm/src/persist.rs b/src/vmm/src/persist.rs index da640b88d..042804f96 100644 --- a/src/vmm/src/persist.rs +++ b/src/vmm/src/persist.rs @@ -14,7 +14,7 @@ use std::sync::{Arc, Mutex}; use semver::Version; use serde::{Deserialize, Serialize}; -use userfaultfd::{FeatureFlags, Uffd, UffdBuilder}; +use userfaultfd::{FeatureFlags, RegisterMode, Uffd, UffdBuilder}; use vmm_sys_util::sock_ctrl_msg::ScmSocket; #[cfg(target_arch = "aarch64")] @@ -455,6 +455,12 @@ pub fn restore_from_snapshot( ) .into()); } + if params.uffd_base_file.is_some() { + return Err(RestoreFromSnapshotGuestMemoryError::Uffd( + GuestMemoryFromUffdError::BaseFileRequiresUffdBackend, + ) + .into()); + } ( guest_memory_from_file(mem_backend_path, mem_state, track_dirty_pages, params.shared) .map_err(RestoreFromSnapshotGuestMemoryError::File)?, @@ -466,6 +472,7 @@ pub fn restore_from_snapshot( mem_state, track_dirty_pages, vm_resources.machine_config.huge_pages, + params.uffd_base_file.as_deref(), ) .map_err(RestoreFromSnapshotGuestMemoryError::Uffd)?, }; @@ -545,6 +552,14 @@ pub enum GuestMemoryFromUffdError { Connect(#[from] std::io::Error), /// Failed to sends file descriptor: {0} Send(#[from] vmm_sys_util::errno::Error), + /// `uffd_base_file` is only supported with the Uffd memory backend. + BaseFileRequiresUffdBackend, + /// `uffd_base_file` is not supported with hugetlbfs-backed snapshots (UFFD minor faults are shmem-only here). + BaseFileHugetlbfs, + /// Failed to open uffd_base_file: {0} + OpenBaseFile(std::io::Error), + /// Failed to map guest memory from uffd_base_file: {0} + BaseFileMap(MemoryError), } fn guest_memory_from_uffd( @@ -552,9 +567,10 @@ fn guest_memory_from_uffd( mem_state: &GuestMemoryState, track_dirty_pages: bool, huge_pages: HugePageConfig, + base_file: Option<&Path>, ) -> Result<(Vec, Option), GuestMemoryFromUffdError> { let (guest_memory, backend_mappings) = - create_guest_memory(mem_state, track_dirty_pages, huge_pages)?; + create_guest_memory(mem_state, track_dirty_pages, huge_pages, base_file)?; let mut uffd_builder = UffdBuilder::new(); @@ -571,8 +587,17 @@ fn guest_memory_from_uffd( .create() .map_err(GuestMemoryFromUffdError::Create)?; + // ADR 0045 substrate v2b: with a base file backing, register MISSING|MINOR + // so the handler can resolve base-identical pages with UFFDIO_CONTINUE + // (shared page cache) and divergent pages with UFFDIO_COPY (private). + // Without it, the stock MISSING-only registration is byte-identical. + let register_mode = if base_file.is_some() { + RegisterMode::MISSING | RegisterMode::MINOR + } else { + RegisterMode::MISSING + }; for mem_region in guest_memory.iter() { - uffd.register(mem_region.as_ptr().cast(), mem_region.size() as _) + uffd.register_with_mode(mem_region.as_ptr().cast(), mem_region.size() as _, register_mode) .map_err(GuestMemoryFromUffdError::Register)?; } @@ -585,8 +610,22 @@ fn create_guest_memory( mem_state: &GuestMemoryState, track_dirty_pages: bool, huge_pages: HugePageConfig, + base_file: Option<&Path>, ) -> Result<(Vec, Vec), GuestMemoryFromUffdError> { - let guest_memory = memory::anonymous(mem_state.regions(), track_dirty_pages, huge_pages)?; + let guest_memory = match base_file { + Some(path) => { + // ADR 0045 substrate v2b: MAP_PRIVATE of the shared base shm file. + // Read-only fd is enough — guest writes COW to private anon pages; + // only the external handler ever writes the base (by path). + if huge_pages.is_hugetlbfs() { + return Err(GuestMemoryFromUffdError::BaseFileHugetlbfs); + } + let file = std::fs::File::open(path).map_err(GuestMemoryFromUffdError::OpenBaseFile)?; + memory::snapshot_file(file, mem_state.regions(), track_dirty_pages, /* shared */ false) + .map_err(GuestMemoryFromUffdError::BaseFileMap)? + } + None => memory::anonymous(mem_state.regions(), track_dirty_pages, huge_pages)?, + }; let mut backend_mappings = Vec::with_capacity(guest_memory.len()); let mut offset = 0; for mem_region in guest_memory.iter() { @@ -791,7 +830,7 @@ mod tests { }; let (_, uffd_regions) = - create_guest_memory(&mem_state, false, HugePageConfig::None).unwrap(); + create_guest_memory(&mem_state, false, HugePageConfig::None, None).unwrap(); assert_eq!(uffd_regions.len(), 1); assert_eq!(uffd_regions[0].size, 0x20000); @@ -799,6 +838,62 @@ mod tests { assert_eq!(uffd_regions[0].page_size, HugePageConfig::None.page_size()); } + /// ADR 0045 substrate v2b: guest memory created over a base file maps it + /// MAP_PRIVATE — content reads through from the file (page-cache shared), + /// guest-side writes COW privately and never reach the file, and the + /// backend mappings carry the same sequential file offsets as the + /// anonymous path. + #[test] + fn test_create_guest_memory_with_base_file() { + use std::io::Write; + + let region_size: usize = 0x20000; + let base = TempFile::new().unwrap(); + let pattern: Vec = (0..region_size).map(|i| (i % 251) as u8).collect(); + base.as_file().write_all(&pattern).unwrap(); + + let mem_state = GuestMemoryState { + regions: vec![GuestMemoryRegionState { + base_address: 0, + size: region_size, + region_type: GuestRegionType::Dram, + plugged: vec![true], + }], + }; + + let (guest_memory, uffd_regions) = + create_guest_memory(&mem_state, false, HugePageConfig::None, Some(base.as_path())) + .unwrap(); + assert_eq!(uffd_regions.len(), 1); + assert_eq!(uffd_regions[0].size, region_size); + assert_eq!(uffd_regions[0].offset, 0); + + // Content reads through from the base file (no uffd registered yet, so + // plain page-cache faults serve these). + let ptr = guest_memory[0].as_ptr(); + // SAFETY: ptr covers region_size bytes of the mapping we just created. + let view = unsafe { std::slice::from_raw_parts_mut(ptr, region_size) }; + assert_eq!(&view[..64], &pattern[..64]); + assert_eq!(view[0x10000], pattern[0x10000]); + + // A write COWs privately: visible through the mapping, absent from the file. + view[0x10000] = 0xEE; + assert_eq!(view[0x10000], 0xEE); + let mut on_disk = vec![0u8; 4]; + std::os::unix::fs::FileExt::read_exact_at(base.as_file(), &mut on_disk, 0x10000).unwrap(); + assert_eq!(on_disk[0], pattern[0x10000], "guest write leaked into the base file"); + + // Hugetlbfs + base file is rejected. + let err = create_guest_memory( + &mem_state, + false, + HugePageConfig::Hugetlbfs2M, + Some(base.as_path()), + ) + .unwrap_err(); + assert!(matches!(err, GuestMemoryFromUffdError::BaseFileHugetlbfs)); + } + #[test] fn test_send_uffd_handshake() { #[allow(deprecated)] diff --git a/src/vmm/src/rpc_interface.rs b/src/vmm/src/rpc_interface.rs index aac9ccd75..304083fe8 100644 --- a/src/vmm/src/rpc_interface.rs +++ b/src/vmm/src/rpc_interface.rs @@ -1341,6 +1341,7 @@ mod tests { vsock_override: None, clock_realtime: false, shared: false, + uffd_base_file: None, }, ))); check_unsupported(runtime_request(VmmAction::SetEntropyDevice( diff --git a/src/vmm/src/vmm_config/snapshot.rs b/src/vmm/src/vmm_config/snapshot.rs index 017c20688..ca10b4d68 100644 --- a/src/vmm/src/vmm_config/snapshot.rs +++ b/src/vmm/src/vmm_config/snapshot.rs @@ -97,6 +97,16 @@ pub struct LoadSnapshotParams { /// memfile — the page-readable source for ADR 0045 post-copy. No effect for /// the UFFD backend. Defaults to false (today's `MAP_PRIVATE` behavior). pub shared: bool, + /// ADR 0045 unified memory substrate (v2b). When set with the Uffd memory + /// backend, guest memory is created as `MAP_PRIVATE` of this file (a + /// tmpfs/shmem per-template base) instead of anonymous, and the UFFD + /// registration uses `MISSING | MINOR` instead of MISSING-only. The + /// external handler then resolves base-identical pages with + /// `UFFDIO_CONTINUE` (shared page cache across same-template microVMs) + /// and session-divergent pages with `UFFDIO_COPY` (private); guest writes + /// COW natively. Invalid with the File backend. Requires a kernel with + /// UFFD minor faults on shmem (>= 5.13). + pub uffd_base_file: Option, } /// Stores the configuration for loading a snapshot that is provided by the user. @@ -136,6 +146,11 @@ pub struct LoadSnapshotConfig { /// source). Top-level (not inside `mem_backend`). Defaults to false. #[serde(default)] pub shared: bool, + /// ADR 0045 substrate v2b: back Uffd-restored guest memory `MAP_PRIVATE` + /// by this shmem base file and register UFFD `MISSING | MINOR`. Top-level + /// (not inside `mem_backend`, which is `deny_unknown_fields`). + #[serde(skip_serializing_if = "Option::is_none")] + pub uffd_base_file: Option, } /// Stores the configuration used for managing snapshot memory. diff --git a/src/vmm/tests/integration_tests.rs b/src/vmm/tests/integration_tests.rs index 7dac7f095..c67d2f7e7 100644 --- a/src/vmm/tests/integration_tests.rs +++ b/src/vmm/tests/integration_tests.rs @@ -318,6 +318,7 @@ fn verify_load_snapshot(snapshot_file: TempFile, memory_file: TempFile) { vsock_override: None, clock_realtime: false, shared: false, + uffd_base_file: None, })) .unwrap(); @@ -405,6 +406,7 @@ fn verify_load_snap_disallowed_after_boot_resources(res: VmmAction, res_name: &s vsock_override: None, clock_realtime: false, shared: false, + uffd_base_file: None, }); let err = preboot_api_controller.handle_preboot_request(req); assert!( From d78c1dc11e284bdc8497f3332449fef214fe5f76 Mon Sep 17 00:00:00 2001 From: Nikhil Unni Date: Thu, 11 Jun 2026 08:02:54 -0700 Subject: [PATCH 3/4] =?UTF-8?q?feat(snapshot):=20vmstate=5Fonly=20create?= =?UTF-8?q?=20param=20=E2=80=94=20skip=20the=20guest-memory=20leg=20(v3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-copy live-migration blackout primitive for engrams ADR 0045 C2: a top-level #[serde(default)] vmstate_only flag on CreateSnapshotParams (the v2 uffd_base_file top-level-param precedent). When set, create writes only the microVM state file — the memory leg (and its dirty-queue correction, which only compensates for the memory dump) is skipped entirely and mem_file_path is ignored. The destination demand-faults the dirty memory from the paused source's address space instead of reading a file. Incompatible with the Msync/MsyncAndState types (those ARE memory flushes) — the combination is rejected. Inert/opt-in: a create body without the field is byte-identical to stock behavior; stock FC rejects the unknown field (deny_unknown_fields), which is the capability gate. Never touches src/vmm/src/snapshot/, no SNAPSHOT_VERSION change (10.0.0) — R2 holds. Verified on KVM: test_create_snapshot_vmstate_only (state file deserializes, memfile stays empty, msync combo rejected); full integration_tests snapshot suite green (the test_build* failures are pre-existing on the pristine tip in this environment). Co-Authored-By: Claude Fable 5 --- src/firecracker/src/api_server/mod.rs | 2 + .../src/api_server/request/snapshot.rs | 18 +++++++ src/firecracker/swagger/firecracker.yaml | 6 +++ src/vmm/src/persist.rs | 44 ++++++++++----- src/vmm/src/rpc_interface.rs | 1 + src/vmm/src/vmm_config/snapshot.rs | 8 +++ src/vmm/tests/integration_tests.rs | 53 +++++++++++++++++++ 7 files changed, 120 insertions(+), 12 deletions(-) diff --git a/src/firecracker/src/api_server/mod.rs b/src/firecracker/src/api_server/mod.rs index 09c7dadbd..141f1cd87 100644 --- a/src/firecracker/src/api_server/mod.rs +++ b/src/firecracker/src/api_server/mod.rs @@ -279,6 +279,7 @@ mod tests { snapshot_type: SnapshotType::Diff, snapshot_path: PathBuf::new(), mem_file_path: PathBuf::new(), + vmstate_only: false, })), start_time_us, ); @@ -292,6 +293,7 @@ mod tests { snapshot_type: SnapshotType::Diff, snapshot_path: PathBuf::new(), mem_file_path: PathBuf::new(), + vmstate_only: false, })), start_time_us, ); diff --git a/src/firecracker/src/api_server/request/snapshot.rs b/src/firecracker/src/api_server/request/snapshot.rs index fe0f7d01b..d84d99eac 100644 --- a/src/firecracker/src/api_server/request/snapshot.rs +++ b/src/firecracker/src/api_server/request/snapshot.rs @@ -149,6 +149,7 @@ mod tests { snapshot_type: SnapshotType::Diff, snapshot_path: PathBuf::from("foo"), mem_file_path: PathBuf::from("bar"), + vmstate_only: false, }; assert_eq!( vmm_action_from_request(parse_put_snapshot(&Body::new(body), Some("create")).unwrap()), @@ -163,6 +164,23 @@ mod tests { snapshot_type: SnapshotType::Full, snapshot_path: PathBuf::from("foo"), mem_file_path: PathBuf::from("bar"), + vmstate_only: false, + }; + assert_eq!( + vmm_action_from_request(parse_put_snapshot(&Body::new(body), Some("create")).unwrap()), + VmmAction::CreateSnapshot(expected_config) + ); + + let body = r#"{ + "snapshot_path": "foo", + "mem_file_path": "bar", + "vmstate_only": true + }"#; + let expected_config = CreateSnapshotParams { + snapshot_type: SnapshotType::Full, + snapshot_path: PathBuf::from("foo"), + mem_file_path: PathBuf::from("bar"), + vmstate_only: true, }; assert_eq!( vmm_action_from_request(parse_put_snapshot(&Body::new(body), Some("create")).unwrap()), diff --git a/src/firecracker/swagger/firecracker.yaml b/src/firecracker/swagger/firecracker.yaml index 5f78bcdae..f3bdaec83 100644 --- a/src/firecracker/swagger/firecracker.yaml +++ b/src/firecracker/swagger/firecracker.yaml @@ -1628,6 +1628,12 @@ definitions: snapshot is created. `Msync` flushes a MAP_SHARED file-backed guest memory to its backing memfile via msync(MS_SYNC) without writing the machine state; `MsyncAndState` also writes the state file. + vmstate_only: + type: boolean + description: + Write only the microVM state file and skip the guest-memory leg + entirely (mem_file_path is ignored). Incompatible with the + Msync/MsyncAndState snapshot types. Optional and defaults to false. NetworkOverride: type: object diff --git a/src/vmm/src/persist.rs b/src/vmm/src/persist.rs index 042804f96..0413f4198 100644 --- a/src/vmm/src/persist.rs +++ b/src/vmm/src/persist.rs @@ -170,6 +170,21 @@ pub fn create_snapshot( vm_info: &VmInfo, params: &CreateSnapshotParams, ) -> Result<(), CreateSnapshotError> { + // `vmstate_only` skips the memory leg; the msync types ARE memory + // flushes, so the combination would write nothing at all — reject it. + if params.vmstate_only + && matches!( + params.snapshot_type, + SnapshotType::Msync | SnapshotType::MsyncAndState + ) + { + return Err(CreateSnapshotError::MicrovmState( + MicrovmStateError::NotAllowed( + "vmstate_only cannot combine with the msync snapshot types".into(), + ), + )); + } + // `Msync` is a memory-only off-pause flush: the machine state file is not // written (the durable state rides a separate `MsyncAndState`/`Full` capture). // Every other type writes the state file as usual. @@ -181,18 +196,23 @@ pub fn create_snapshot( snapshot_state_to_file(µvm_state, ¶ms.snapshot_path)?; } - let kvm_vm = vmm.vm.as_kvm().ok_or_else(|| { - CreateSnapshotError::MicrovmState(MicrovmStateError::NotAllowed( - "snapshot requires KVM".into(), - )) - })?; - kvm_vm.snapshot_memory_to_file(¶ms.mem_file_path, params.snapshot_type)?; - - // We need to mark queues as dirty again for all activated devices. The reason we - // do it here is that we don't mark pages as dirty during runtime - // for queue objects. - vmm.device_manager - .mark_virtio_queue_memory_dirty(kvm_vm.guest_memory()); + // engram v3 (ADR 0045 C2): vmstate-only capture for post-copy live + // migration — skip the memory leg (and its dirty-queue correction, which + // only compensates for the memory dump) entirely. + if !params.vmstate_only { + let kvm_vm = vmm.vm.as_kvm().ok_or_else(|| { + CreateSnapshotError::MicrovmState(MicrovmStateError::NotAllowed( + "snapshot requires KVM".into(), + )) + })?; + kvm_vm.snapshot_memory_to_file(¶ms.mem_file_path, params.snapshot_type)?; + + // We need to mark queues as dirty again for all activated devices. The reason we + // do it here is that we don't mark pages as dirty during runtime + // for queue objects. + vmm.device_manager + .mark_virtio_queue_memory_dirty(kvm_vm.guest_memory()); + } Ok(()) } diff --git a/src/vmm/src/rpc_interface.rs b/src/vmm/src/rpc_interface.rs index 304083fe8..a1a310672 100644 --- a/src/vmm/src/rpc_interface.rs +++ b/src/vmm/src/rpc_interface.rs @@ -1249,6 +1249,7 @@ mod tests { snapshot_type: SnapshotType::Full, snapshot_path: PathBuf::new(), mem_file_path: PathBuf::new(), + vmstate_only: false, }, ))); #[cfg(target_arch = "x86_64")] diff --git a/src/vmm/src/vmm_config/snapshot.rs b/src/vmm/src/vmm_config/snapshot.rs index ca10b4d68..7d9e4a8b6 100644 --- a/src/vmm/src/vmm_config/snapshot.rs +++ b/src/vmm/src/vmm_config/snapshot.rs @@ -52,6 +52,14 @@ pub struct CreateSnapshotParams { pub snapshot_path: PathBuf, /// Path to the file that will contain the guest memory. pub mem_file_path: PathBuf, + /// Write only the microVM state file and skip the guest-memory leg + /// entirely (`mem_file_path` is ignored). The post-copy live-migration + /// blackout primitive for ADR 0045: the dirty memory never materializes + /// as a file — the destination demand-faults it from the paused source's + /// address space. Incompatible with the `Msync`/`MsyncAndState` types + /// (those ARE memory flushes). + #[serde(default)] + pub vmstate_only: bool, } /// Allows for changing the mapping between tap devices and host devices diff --git a/src/vmm/tests/integration_tests.rs b/src/vmm/tests/integration_tests.rs index c67d2f7e7..37db1db2b 100644 --- a/src/vmm/tests/integration_tests.rs +++ b/src/vmm/tests/integration_tests.rs @@ -246,6 +246,7 @@ fn verify_create_snapshot( snapshot_type, snapshot_path: snapshot_file.as_path().to_path_buf(), mem_file_path: memory_file.as_path().to_path_buf(), + vmstate_only: false, }; controller @@ -346,6 +347,58 @@ fn test_create_and_load_snapshot() { } } +#[test] +fn test_create_snapshot_vmstate_only() { + let snapshot_file = TempFile::new().unwrap(); + let memory_file = TempFile::new().unwrap(); + + let (vmm, _) = create_vmm(Some(NOISY_KERNEL_IMAGE), false, true, false, false); + let vm_info = VmInfo::from(&*vmm.lock().unwrap()); + let mut controller = RuntimeApiController::new(vmm.clone()); + let mut event_manager = EventManager::new().unwrap(); + + thread::sleep(Duration::from_millis(200)); + + controller + .handle_request(VmmAction::Pause, &mut event_manager) + .unwrap(); + + // The msync types ARE memory flushes — combining them with `vmstate_only` + // would write nothing at all and is rejected outright. + controller + .handle_request( + VmmAction::CreateSnapshot(CreateSnapshotParams { + snapshot_type: SnapshotType::Msync, + snapshot_path: snapshot_file.as_path().to_path_buf(), + mem_file_path: memory_file.as_path().to_path_buf(), + vmstate_only: true, + }), + &mut event_manager, + ) + .unwrap_err(); + + // vmstate-only writes the state file and never touches the memory path. + controller + .handle_request( + VmmAction::CreateSnapshot(CreateSnapshotParams { + snapshot_type: SnapshotType::Full, + snapshot_path: snapshot_file.as_path().to_path_buf(), + mem_file_path: memory_file.as_path().to_path_buf(), + vmstate_only: true, + }), + &mut event_manager, + ) + .unwrap(); + + vmm.lock().unwrap().stop(FcExitCode::Ok); + + let restored_microvm_state: MicrovmState = + Snapshot::load(&mut snapshot_file.as_file()).unwrap().data; + assert_eq!(restored_microvm_state.vm_info, vm_info); + // The memory leg was skipped: the memfile is exactly as we created it. + assert_eq!(memory_file.as_file().metadata().unwrap().len(), 0); +} + #[test] fn test_snapshot_load_sanity_checks() { let microvm_state = get_microvm_state_from_snapshot(false); From 98a1070ab438132832564aa706d927de7c31566f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:45:36 +0000 Subject: [PATCH 4/4] chore(deps): Bump rust-toolchain from 1.95.0 to 1.96.0 Bumps [rust-toolchain](https://github.com/rust-lang/rust) from 1.95.0 to 1.96.0. - [Release notes](https://github.com/rust-lang/rust/releases) - [Changelog](https://github.com/rust-lang/rust/blob/main/RELEASES.md) - [Commits](https://github.com/rust-lang/rust/compare/1.95.0...1.96.0) --- updated-dependencies: - dependency-name: rust-toolchain dependency-version: 1.96.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- rust-toolchain.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 7213efcda..ee0ec7e7c 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -11,7 +11,7 @@ # allowlisted using a toolchain that requires it, causing the A/B-test to # always fail. [toolchain] -channel = "1.95.0" +channel = "1.96.0" targets = ["x86_64-unknown-linux-musl", "aarch64-unknown-linux-musl"] profile = "minimal"