From e0ac0f24738cdb3665bb41d9a478544ea8c5e52c Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Mon, 27 Apr 2026 13:00:28 +0200 Subject: [PATCH 01/40] block: add mirror module skeleton Blockdev-mirroring for virtio-blk needs a home for its new types. Add the mirror module with the lifecycle state, ahead of the logic that fills it in. MirrorPhase the lifecycle: Running, Ready, Completing, Completed, Cancelling, Failed MirrorState the phase behind a Mutex, shared via Arc, with a guarded transition_to_phase that applies only the documented edges Follow-up commits add the range lock for copy/write exclusion, the AsyncIo wrapper that fans writes to both backends, the background copy worker, and the virtio-blk and REST integration. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/lib.rs | 1 + block/src/mirror.rs | 97 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 block/src/mirror.rs diff --git a/block/src/lib.rs b/block/src/lib.rs index 9d688f5ff..5e58f69cf 100644 --- a/block/src/lib.rs +++ b/block/src/lib.rs @@ -20,6 +20,7 @@ pub mod fixed_vhd; pub mod fixed_vhd_async; pub mod fixed_vhd_disk; pub mod fixed_vhd_sync; +pub mod mirror; pub mod qcow; #[cfg(feature = "io_uring")] pub(crate) mod qcow_async; diff --git a/block/src/mirror.rs b/block/src/mirror.rs new file mode 100644 index 000000000..c524bdcb1 --- /dev/null +++ b/block/src/mirror.rs @@ -0,0 +1,97 @@ +// Copyright © 2026 Cyberus Technology GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +//! Blockdev-mirroring for virtio-blk devices. +//! +//! Mirrors guest writes to a destination disk while a background +//! worker copies existing data from source to destination. Once +//! both sides are in sync the device manager can complete the mirror, +//! switching the device to serve I/O from the destination. + +use std::mem; +use std::sync::{Arc, Mutex}; + +use log::warn; + +/// Phase of a mirror. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MirrorPhase { + /// Background copy is in progress. + Running, + /// All blocks copied. Source and destination are in sync. + Ready, + /// Switch-over to the destination is in progress. + Completing, + /// All virtqueues switched to the destination. + Completed, + /// Mirror cancellation is in progress. + Cancelling, + /// The mirror has failed. + Failed, +} + +/// State shared by the copy worker and the per-queue mirroring +/// `AsyncIo` handles. +pub struct MirrorState { + /// Current phase of the mirror. + phase: Mutex, +} + +impl MirrorState { + pub fn new() -> Arc { + Arc::new(Self { + phase: Mutex::new(MirrorPhase::Running), + }) + } + + /// Returns a snapshot of the current phase. + pub fn phase(&self) -> MirrorPhase { + self.phase.lock().unwrap().clone() + } + + /// Attempts a phase transition. Only the documented transitions are + /// applied. Any other attempt is ignored and logged. + /// + /// Allowed transitions: + /// ```text + /// Running -> Ready | Cancelling | Failed + /// Ready -> Completing | Cancelling | Failed + /// Completing -> Completed + /// Failed -> Cancelling + /// ``` + /// Plus idempotent self-transitions. `Completed` and `Cancelling` are + /// terminal: the mirror handle is dropped out of them, after which + /// `Block::mirror_status` reports no active mirror. + pub fn transition_to_phase(&self, target: MirrorPhase) { + use MirrorPhase::*; + let mut current = self.phase.lock().unwrap(); + + // Ignore idempotent transitions to the current state + if mem::discriminant(&*current) == mem::discriminant(&target) { + return; + } + + let transition_allowed = matches!( + (&*current, &target), + (Running, Ready) + | (Running, Cancelling) + | (Running, Failed) + | (Ready, Completing) + | (Ready, Cancelling) + | (Ready, Failed) + | (Completing, Completed) + | (Failed, Cancelling) + ); + + if !transition_allowed { + warn!( + "Invalid mirror phase transition attempted: {:?} -> {:?}", + *current, target + ); + return; + } + + *current = target; + } +} From 5d51ed73a07a265611065a4ac34b37a525460118 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Mon, 27 Apr 2026 16:12:50 +0200 Subject: [PATCH 02/40] block: add MirroringAsyncIo skeleton Mirroring needs a per-queue AsyncIo that the virtio device can install in place of the plain backend. Add the type now so later commits introducing the shard locks and the write fan-out have something to reference. Every method delegates to source. alignment() is the exception and returns max of source and dest. The request handler reads alignment per request to choose bounce-buffer placement, and the same iovec is later submitted to both backends, so the stricter requirement has to win even before fan-out lands. submit_batch_requests is left unimplemented and batch_requests_enabled returns false. Follow-ups add Shard-based mutual exclusion between the copy worker and mirror writes, then rewrite write_vectored, punch_hole, write_zeroes, fsync, and next_completed_request to fan out to destination and pair completions via a synthetic dest-side user_data. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/mirror.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/block/src/mirror.rs b/block/src/mirror.rs index c524bdcb1..bf4138d65 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -12,7 +12,12 @@ use std::mem; use std::sync::{Arc, Mutex}; +use libc::{iovec, off_t}; use log::warn; +use vmm_sys_util::eventfd::EventFd; + +use crate::BatchRequest; +use crate::async_io::{AsyncIo, AsyncIoResult}; /// Phase of a mirror. #[derive(Debug, Clone, PartialEq, Eq)] @@ -95,3 +100,64 @@ impl MirrorState { *current = target; } } + +/// Per-queue `AsyncIo` handle for a mirror. +#[expect(dead_code)] +pub struct MirroringAsyncIo { + source: Box, + destination: Box, + state: Arc, +} + +impl AsyncIo for MirroringAsyncIo { + fn notifier(&self) -> &EventFd { + self.source.notifier() + } + + fn read_vectored( + &mut self, + offset: off_t, + iovecs: &[iovec], + user_data: u64, + ) -> AsyncIoResult<()> { + self.source.read_vectored(offset, iovecs, user_data) + } + + fn write_vectored( + &mut self, + offset: off_t, + iovecs: &[iovec], + user_data: u64, + ) -> AsyncIoResult<()> { + self.source.write_vectored(offset, iovecs, user_data) + } + + fn fsync(&mut self, user_data: Option) -> AsyncIoResult<()> { + self.source.fsync(user_data) + } + + fn punch_hole(&mut self, offset: u64, length: u64, user_data: u64) -> AsyncIoResult<()> { + self.source.punch_hole(offset, length, user_data) + } + + fn write_zeroes(&mut self, offset: u64, length: u64, user_data: u64) -> AsyncIoResult<()> { + self.source.write_zeroes(offset, length, user_data) + } + + fn next_completed_request(&mut self) -> Option<(u64, i32)> { + self.source.next_completed_request() + } + + fn batch_requests_enabled(&self) -> bool { + false + } + + fn submit_batch_requests(&mut self, _batch_request: &[BatchRequest]) -> AsyncIoResult<()> { + unimplemented!("Batch requests are not supported in MirroringAsyncIo") + } + + fn alignment(&self) -> u64 { + // Stricter alignment wins. Same iovec goes to both backends. + self.source.alignment().max(self.destination.alignment()) + } +} From 9536e55b70f261d19208279235609cd053a54de6 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Tue, 28 Apr 2026 10:45:02 +0200 Subject: [PATCH 03/40] block: add range lock primitive for mirror The copy worker and the virtqueue workers can both target the same destination bytes during a mirror. Without coordination a destination block can mix bytes from both. Add a primitive both will use to serialise overlapping ranges. RangeLockManager wraps a Mutex> and a Condvar. lock_range blocks while any held range overlaps. lock_iovecs locks the contiguous span of iovecs using lock_range Wired into the `AsyncIo` impl of `MirroringAsyncIo` and the copy worker in follow-up commits. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/mirror.rs | 128 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/block/src/mirror.rs b/block/src/mirror.rs index bf4138d65..d304eaca1 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -9,8 +9,9 @@ //! both sides are in sync the device manager can complete the mirror, //! switching the device to serve I/O from the destination. -use std::mem; -use std::sync::{Arc, Mutex}; +use std::collections::BTreeMap; +use std::sync::{Arc, Condvar, Mutex}; +use std::{io, mem}; use libc::{iovec, off_t}; use log::warn; @@ -19,6 +20,95 @@ use vmm_sys_util::eventfd::EventFd; use crate::BatchRequest; use crate::async_io::{AsyncIo, AsyncIoResult}; +/// Serializes overlapping byte ranges between the copy worker and the +/// per-queue mirror writes. +/// +/// Each party calls [`Self::lock_range`] before submitting I/O and +/// holds the returned [`RangeGuard`] until completion. A conflicting +/// request blocks on a `Condvar` until the held guard is dropped. +/// Lookups are O(log n) on the number of held ranges. +struct RangeLockManager { + /// Held ranges as `start -> end_exclusive`. The mutex makes the + /// overlap check and insert in [`Self::lock_range`] atomic with + /// respect to releases in [`RangeGuard::drop`]. + ranges: Mutex>, + /// Notified on guard drop. Waiters re-check their range. + cv: Condvar, +} + +#[expect(dead_code)] +impl RangeLockManager { + pub fn new() -> Arc { + Arc::new(Self { + ranges: Mutex::new(BTreeMap::new()), + cv: Condvar::new(), + }) + } + + /// Returns true if `[start, end)` overlaps any range in `ranges`. + fn overlaps_any(ranges: &BTreeMap, start: u64, end: u64) -> bool { + ranges + .range(..end) + .next_back() + .is_some_and(|(_, &e)| e > start) + } + + /// Acquires an exclusive lock on `[offset, offset + length)`. + /// Blocks while any held range overlaps. + fn lock_range(self: Arc, offset: u64, length: u64) -> io::Result { + if length == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Range length is zero", + )); + } + + let end = offset + .checked_add(length) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Range overflow"))?; + // Wait until no held range overlaps, then claim it. + { + let mut ranges = self + .cv + .wait_while(self.ranges.lock().unwrap(), |ranges| { + RangeLockManager::overlaps_any(ranges, offset, end) + }) + .unwrap(); + ranges.insert(offset, end); + } + + Ok(RangeGuard { + mgr: self, + start: offset, + }) + } + + /// Acquires a [`RangeGuard`] covering the contiguous bytes from + /// `offset` through the end of `iovecs`. + fn lock_iovecs(self: Arc, offset: off_t, iovecs: &[iovec]) -> io::Result { + let total_len = iovecs + .iter() + .try_fold(0u64, |acc, v| acc.checked_add(v.iov_len as u64)) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "iovec length overflow"))?; + + self.lock_range(offset as u64, total_len) + } +} + +/// RAII handle for a range held in a [`RangeLockManager`]. Drop +/// releases the range and wakes all waiters. +struct RangeGuard { + mgr: Arc, + start: u64, +} +impl Drop for RangeGuard { + fn drop(&mut self) { + let mut ranges = self.mgr.ranges.lock().unwrap(); + ranges.remove(&self.start); + self.mgr.cv.notify_all(); + } +} + /// Phase of a mirror. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MirrorPhase { @@ -161,3 +251,37 @@ impl AsyncIo for MirroringAsyncIo { self.source.alignment().max(self.destination.alignment()) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Overlap is detected whether the held range precedes the query or starts + /// inside it. + #[test] + fn overlaps_detects_overlap() { + let mut preceding = BTreeMap::new(); + preceding.insert(10u64, 25u64); + assert!(RangeLockManager::overlaps_any(&preceding, 20, 30)); + + let mut starts_inside = BTreeMap::new(); + starts_inside.insert(10u64, 20u64); + starts_inside.insert(25u64, 30u64); + assert!(RangeLockManager::overlaps_any(&starts_inside, 21, 26)); + } + + #[test] + fn overlaps_disjoint_returns_false() { + let mut locked = BTreeMap::new(); + locked.insert(10u64, 20u64); + locked.insert(30u64, 40u64); + assert!(!RangeLockManager::overlaps_any(&locked, 22, 28)); + } + + #[test] + fn overlaps_touching_boundary_is_not_overlap() { + let mut locked = BTreeMap::new(); + locked.insert(10u64, 20u64); + assert!(!RangeLockManager::overlaps_any(&locked, 20, 30)); + } +} From 94934787f86db7e01a23fe5da075076abe950c0c Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Wed, 29 Apr 2026 12:25:33 +0200 Subject: [PATCH 04/40] block: add CompletionWaiter for single-fd waits The copy worker and the per-queue mirror writes need to block until an AsyncIo backend's notifier eventfd has a completion to read. Reads on the eventfd never block because every backend creates it with EFD_NONBLOCK. The virtio-block seccomp filter allows epoll_* but not poll/ppoll. Add CompletionWaiter, a wrapper around vmm_sys_util::poll::PollContext that registers one fd for readability at construction. PollContext uses epoll and retries on EINTR. next_completion() loops over a backend's completions, waiting on the fd and draining the eventfd between rounds. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/mirror.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/block/src/mirror.rs b/block/src/mirror.rs index d304eaca1..73aa9c2dd 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -16,6 +16,7 @@ use std::{io, mem}; use libc::{iovec, off_t}; use log::warn; use vmm_sys_util::eventfd::EventFd; +use vmm_sys_util::poll::PollContext; use crate::BatchRequest; use crate::async_io::{AsyncIo, AsyncIoResult}; @@ -252,6 +253,45 @@ impl AsyncIo for MirroringAsyncIo { } } +/// Owns an [`AsyncIo`] backend and waits for its completions. +/// +/// Keeping the backend and poll context together ensures the poll context +/// always watches the notifier belonging to the backend it drains. +struct CompletionIo { + poll: PollContext<()>, + io: Box, +} + +#[expect(dead_code)] +impl CompletionIo { + fn new(io: Box) -> io::Result { + let poll = PollContext::new()?; + poll.add(io.notifier(), ())?; + Ok(Self { poll, io }) + } + + fn io(&self) -> &dyn AsyncIo { + self.io.as_ref() + } + + fn io_mut(&mut self) -> &mut (dyn AsyncIo + '_) { + self.io.as_mut() + } + + /// Blocks until the owned backend reports a completion, then returns it. + fn next_completion(&mut self) -> io::Result<(u64, i32)> { + loop { + if let Some(completion) = self.io.next_completed_request() { + return Ok(completion); + } + // EINTR is retried inside `wait`. + self.poll.wait()?; + // Drain the eventfd so the next wait does not fire on a stale signal. + self.io.notifier().read()?; + } + } +} + #[cfg(test)] mod tests { use super::*; From a470c6e3e63d9e05e84244004962dfac70097f09 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Tue, 28 Apr 2026 15:01:56 +0200 Subject: [PATCH 05/40] block: mirror mutating I/O to destination Mirroring moves a virtio-blk disk to a new backend path while the guest keeps running. Guest writes during the move have to land on both disks. A background copy worker streams the existing data across. The guest write path and the copy worker can target the same byte range at the same time. Mirror mutating guest requests to both backends via virtqueues, and hold a range lock for each request so neither side touches the same bytes at once. Read requests are not mirrored and wired to the source disk. The guest sees source's result, a destination failure moves MirrorPhase to Failed. Follow-ups: the copy worker that takes the same lock, and the coordinator that handles start, cancel, complete, and rollback on Failed. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/mirror.rs | 223 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 201 insertions(+), 22 deletions(-) diff --git a/block/src/mirror.rs b/block/src/mirror.rs index 73aa9c2dd..7735d12df 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -9,17 +9,18 @@ //! both sides are in sync the device manager can complete the mirror, //! switching the device to serve I/O from the destination. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, VecDeque}; use std::sync::{Arc, Condvar, Mutex}; use std::{io, mem}; use libc::{iovec, off_t}; use log::warn; +use thiserror::Error; use vmm_sys_util::eventfd::EventFd; use vmm_sys_util::poll::PollContext; use crate::BatchRequest; -use crate::async_io::{AsyncIo, AsyncIoResult}; +use crate::async_io::{AsyncIo, AsyncIoError, AsyncIoResult}; /// Serializes overlapping byte ranges between the copy worker and the /// per-queue mirror writes. @@ -37,7 +38,6 @@ struct RangeLockManager { cv: Condvar, } -#[expect(dead_code)] impl RangeLockManager { pub fn new() -> Arc { Arc::new(Self { @@ -110,8 +110,37 @@ impl Drop for RangeGuard { } } +/// Failure that stopped an active block mirror. +#[derive(Debug, Error)] +pub enum MirrorFailure { + /// A destination completion returned an unexpected result. + #[error("Destination completion was {actual}, expected {expected}: user_data={user_data}")] + DestinationCompletion { + user_data: u64, + actual: i32, + expected: i32, + }, + /// Submitting an operation to the destination failed. + #[error("Destination request submission failed: {0}")] + DestinationSubmit(#[source] AsyncIoError), + /// Waiting for a destination completion failed. + #[error("Destination wait failed for user_data={user_data}: {source}")] + DestinationWait { + user_data: u64, + #[source] + source: io::Error, + }, + /// A source completion returned an unexpected result. + #[error("Source completion was {actual}, expected {expected}: user_data={user_data}")] + SourceCompletion { + user_data: u64, + actual: i32, + expected: i32, + }, +} + /// Phase of a mirror. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] pub enum MirrorPhase { /// Background copy is in progress. Running, @@ -124,7 +153,7 @@ pub enum MirrorPhase { /// Mirror cancellation is in progress. Cancelling, /// The mirror has failed. - Failed, + Failed(Arc), } /// State shared by the copy worker and the per-queue mirroring @@ -132,12 +161,14 @@ pub enum MirrorPhase { pub struct MirrorState { /// Current phase of the mirror. phase: Mutex, + range_locks: Arc, } impl MirrorState { pub fn new() -> Arc { Arc::new(Self { phase: Mutex::new(MirrorPhase::Running), + range_locks: RangeLockManager::new(), }) } @@ -151,10 +182,10 @@ impl MirrorState { /// /// Allowed transitions: /// ```text - /// Running -> Ready | Cancelling | Failed - /// Ready -> Completing | Cancelling | Failed + /// Running -> Ready | Cancelling | Failed(_) + /// Ready -> Completing | Cancelling | Failed(_) /// Completing -> Completed - /// Failed -> Cancelling + /// Failed(_) -> Cancelling /// ``` /// Plus idempotent self-transitions. `Completed` and `Cancelling` are /// terminal: the mirror handle is dropped out of them, after which @@ -172,12 +203,12 @@ impl MirrorState { (&*current, &target), (Running, Ready) | (Running, Cancelling) - | (Running, Failed) + | (Running, Failed(_)) | (Ready, Completing) | (Ready, Cancelling) - | (Ready, Failed) + | (Ready, Failed(_)) | (Completing, Completed) - | (Failed, Cancelling) + | (Failed(_), Cancelling) ); if !transition_allowed { @@ -193,16 +224,101 @@ impl MirrorState { } /// Per-queue `AsyncIo` handle for a mirror. -#[expect(dead_code)] pub struct MirroringAsyncIo { - source: Box, - destination: Box, + source: CompletionIo, + destination: CompletionIo, state: Arc, + /// Queued completions `(user_data, result)` for + /// [`AsyncIo::next_completed_request`]. The user_data identifies + /// the request it was submitted with, the result is bytes + /// transferred or a negative errno. + inflight_completions: VecDeque<(u64, i32)>, +} +impl MirroringAsyncIo { + /// Flip the mirror to the `Failed` phase. The operator must cancel to + /// clean up the destination and the copy worker. + fn fail(&mut self, failure: MirrorFailure) { + self.state + .transition_to_phase(MirrorPhase::Failed(Arc::new(failure))); + } + + /// Calls source and destination submissions with mirror-specific error handling. + /// + /// A source submission error is returned to the guest. A destination submission + /// error fails the mirror but is not returned, because `source` is the disk + /// visible to the guest. + fn mirror_request(&mut self, submit_source: S, submit_destination: D) -> AsyncIoResult<()> + where + S: FnOnce(&mut dyn AsyncIo) -> AsyncIoResult<()>, + D: FnOnce(&mut dyn AsyncIo) -> AsyncIoResult<()>, + { + submit_source(self.source.io_mut())?; + if let Err(e) = submit_destination(self.destination.io_mut()) { + self.fail(MirrorFailure::DestinationSubmit(e)); + } + Ok(()) + } + + /// Block until `user_data`'s source and destination completion arrive, then + /// queue the single guest-visible `(user_data, src_result)`. Other + /// completions seen while waiting (e.g. an async read finishing) are stashed + /// for later delivery. + fn wait_for_completions(&mut self, user_data: u64, expected_result: i32) -> io::Result<()> { + let src_result = + Self::await_completion(&mut self.source, &mut self.inflight_completions, user_data)?; + + match Self::await_completion( + &mut self.destination, + &mut self.inflight_completions, + user_data, + ) { + // Destination reported an I/O error or incomplete operation. + Ok(dest_result) if dest_result != expected_result => { + self.fail(MirrorFailure::DestinationCompletion { + user_data, + actual: dest_result, + expected: expected_result, + }); + } + Ok(_) => {} + // The destination wait itself failed (broken notifier or epoll). + // Hide it from the guest like any other destination failure. + Err(source) => self.fail(MirrorFailure::DestinationWait { user_data, source }), + } + + if src_result != expected_result { + self.fail(MirrorFailure::SourceCompletion { + user_data, + actual: src_result, + expected: expected_result, + }); + } + + self.inflight_completions.push_back((user_data, src_result)); + let _ = self.source.io().notifier().write(1); + Ok(()) + } + + /// Drain `completion_io` until `user_data`'s own completion appears and push + /// additional ones to `inflight_completions`. + fn await_completion( + completion_io: &mut CompletionIo, + inflight_completions: &mut VecDeque<(u64, i32)>, + user_data: u64, + ) -> io::Result { + loop { + let (id, res) = completion_io.next_completion()?; + if id == user_data { + return Ok(res); + } + inflight_completions.push_back((id, res)); + } + } } impl AsyncIo for MirroringAsyncIo { fn notifier(&self) -> &EventFd { - self.source.notifier() + self.source.io().notifier() } fn read_vectored( @@ -211,7 +327,9 @@ impl AsyncIo for MirroringAsyncIo { iovecs: &[iovec], user_data: u64, ) -> AsyncIoResult<()> { - self.source.read_vectored(offset, iovecs, user_data) + self.source + .io_mut() + .read_vectored(offset, iovecs, user_data) } fn write_vectored( @@ -220,23 +338,81 @@ impl AsyncIo for MirroringAsyncIo { iovecs: &[iovec], user_data: u64, ) -> AsyncIoResult<()> { - self.source.write_vectored(offset, iovecs, user_data) + let expected_result = iovecs + .iter() + .map(|iov| iov.iov_len) + .sum::() + .try_into() + .map_err(|_| AsyncIoError::WriteVectored(io::Error::other("write is too large")))?; + + let _guard = self + .state + .range_locks + .clone() + .lock_iovecs(offset, iovecs) + .map_err(AsyncIoError::WriteVectored)?; + + self.mirror_request( + |src| src.write_vectored(offset, iovecs, user_data), + |dst| dst.write_vectored(offset, iovecs, user_data), + )?; + + self.wait_for_completions(user_data, expected_result) + .map_err(AsyncIoError::WriteVectored)?; + Ok(()) } fn fsync(&mut self, user_data: Option) -> AsyncIoResult<()> { - self.source.fsync(user_data) + self.mirror_request(|src| src.fsync(user_data), |dst| dst.fsync(user_data))?; + + // A tracked fsync (Some) waits for its completion. A barrier fsync (None) does not. + if let Some(user_data) = user_data { + self.wait_for_completions(user_data, 0) + .map_err(AsyncIoError::Fsync)?; + } + Ok(()) } fn punch_hole(&mut self, offset: u64, length: u64, user_data: u64) -> AsyncIoResult<()> { - self.source.punch_hole(offset, length, user_data) + let _guard = self + .state + .range_locks + .clone() + .lock_range(offset, length) + .map_err(AsyncIoError::PunchHole)?; + self.mirror_request( + |src| src.punch_hole(offset, length, user_data), + |dst| dst.punch_hole(offset, length, user_data), + )?; + + self.wait_for_completions(user_data, 0) + .map_err(AsyncIoError::PunchHole)?; + Ok(()) } fn write_zeroes(&mut self, offset: u64, length: u64, user_data: u64) -> AsyncIoResult<()> { - self.source.write_zeroes(offset, length, user_data) + let _guard = self + .state + .range_locks + .clone() + .lock_range(offset, length) + .map_err(AsyncIoError::WriteZeroes)?; + self.mirror_request( + |src| src.write_zeroes(offset, length, user_data), + |dst| dst.write_zeroes(offset, length, user_data), + )?; + + self.wait_for_completions(user_data, 0) + .map_err(AsyncIoError::WriteZeroes)?; + Ok(()) } fn next_completed_request(&mut self) -> Option<(u64, i32)> { - self.source.next_completed_request() + // Mirrored writes are awaited synchronously. Only async source reads complete here. + while let Some((id, res)) = self.source.io_mut().next_completed_request() { + self.inflight_completions.push_back((id, res)); + } + self.inflight_completions.pop_front() } fn batch_requests_enabled(&self) -> bool { @@ -249,7 +425,10 @@ impl AsyncIo for MirroringAsyncIo { fn alignment(&self) -> u64 { // Stricter alignment wins. Same iovec goes to both backends. - self.source.alignment().max(self.destination.alignment()) + self.source + .io() + .alignment() + .max(self.destination.io().alignment()) } } From 6960766cb9119acd9475412592c635d8be4e15d5 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Wed, 29 Apr 2026 12:34:45 +0200 Subject: [PATCH 06/40] block: add background copy worker for mirror Block device mirroring replicates new guest writes to the destination. This does not copy data that was already present on the source when mirroring started. We introduce CopyWorker as a background worker that copies those existing bytes while the guest keeps running. A guest write (per-queue writers) can overlap the range CopyWorker is currently copying. Without synchronization, the background write could overwrite newer guest data on the destination. Both paths therefore use the same range lock, which CopyWorker holds across the source read and destination write. CopyWorker submits reads and writes through AsyncIo so it works with any backend that implements the trait. It performs I/O sequentially and uses CompletionIo to wait for completions from each non-blocking notifier eventfd. After copying all blocks, CopyWorker flushes the destination before transitioning the mirror from Running to Ready. An I/O error transitions the mirror to Failed, while a thread spawn error is returned to the caller. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/mirror.rs | 195 ++++++++++++++++++++++++++++++++++++++- block/src/qcow_common.rs | 3 + 2 files changed, 195 insertions(+), 3 deletions(-) diff --git a/block/src/mirror.rs b/block/src/mirror.rs index 7735d12df..4e5b0a958 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -10,8 +10,10 @@ //! switching the device to serve I/O from the destination. use std::collections::{BTreeMap, VecDeque}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex}; -use std::{io, mem}; +use std::thread::JoinHandle; +use std::{io, mem, thread}; use libc::{iovec, off_t}; use log::warn; @@ -21,6 +23,13 @@ use vmm_sys_util::poll::PollContext; use crate::BatchRequest; use crate::async_io::{AsyncIo, AsyncIoError, AsyncIoResult}; +use crate::disk_file::AsyncFullDiskFile; +use crate::error::BlockResult; +use crate::qcow_common::AlignedBuf; + +/// Block size for the copy worker, in which it copies data from +/// source to destination and holds the range lock. +pub const MIRROR_BLOCK_SIZE: usize = 512 * 1024; // 512 KiB /// Serializes overlapping byte ranges between the copy worker and the /// per-queue mirror writes. @@ -113,6 +122,9 @@ impl Drop for RangeGuard { /// Failure that stopped an active block mirror. #[derive(Debug, Error)] pub enum MirrorFailure { + /// The background copy worker failed. + #[error("Copy worker failed: {0}")] + CopyWorker(#[source] io::Error), /// A destination completion returned an unexpected result. #[error("Destination completion was {actual}, expected {expected}: user_data={user_data}")] DestinationCompletion { @@ -162,13 +174,17 @@ pub struct MirrorState { /// Current phase of the mirror. phase: Mutex, range_locks: Arc, + copied_bytes: AtomicU64, + total_bytes: u64, } impl MirrorState { - pub fn new() -> Arc { + pub fn new(logical_disk_size: u64) -> Arc { Arc::new(Self { phase: Mutex::new(MirrorPhase::Running), range_locks: RangeLockManager::new(), + copied_bytes: AtomicU64::new(0), + total_bytes: logical_disk_size, }) } @@ -432,6 +448,180 @@ impl AsyncIo for MirroringAsyncIo { } } +/// Owns the copy worker thread's [`JoinHandle`]. +pub struct CopyWorkerHandle { + join: JoinHandle<()>, +} + +impl CopyWorkerHandle { + /// Waits for the copy worker thread to finish. + pub fn join(self) -> thread::Result<()> { + self.join.join() + } +} + +/// Submits a tracked flush and waits for its matching successful completion. +/// +/// The completion must carry `user_data` and report zero, as required by +/// [`AsyncIo::fsync`]. +fn flush_async_io(user_data: u64, completion_io: &mut CompletionIo) -> io::Result<()> { + completion_io + .io_mut() + .fsync(Some(user_data)) + .map_err(|e| io::Error::other(format!("async io fsync failed: {e}")))?; + + let (completed_user_data, result) = completion_io.next_completion()?; + if completed_user_data != user_data { + return Err(io::Error::other(format!( + "fsync completed with unexpected user data {completed_user_data}, expected {user_data}" + ))); + } + if result != 0 { + return Err(if result < 0 { + io::Error::from_raw_os_error(-result) + } else { + io::Error::other(format!("fsync completed with unexpected result {result}")) + }); + } + + Ok(()) +} + +/// Background thread that copies existing source bytes to destination +/// in fixed-size blocks. Holds a [`RangeGuard`] across each block so +/// the virtqueue mirror writes cannot race the copy. +pub struct CopyWorker { + source_io: CompletionIo, + dest_io: CompletionIo, + state: Arc, + /// Once allocated, the buffer is reused for all blocks to avoid repeated allocations. + buf: AlignedBuf, + block_size_bytes: usize, + /// Tracks the next user_data for request and completion notifications. + next_user_data: u64, +} +impl CopyWorker { + /// Builds a worker on top of two async I/O handles. Queue depth 1 + /// is enough, as the worker is sequential. The caller must initialize the + /// destination disk. + /// + /// Start the worker thread with [`Self::spawn`]. + pub fn new( + source_disk: &dyn AsyncFullDiskFile, + destination_disk: &dyn AsyncFullDiskFile, + state: Arc, + block_size_bytes: usize, + ) -> BlockResult { + let source_io = CompletionIo::new(source_disk.create_async_io(1)?)?; + let dest_io = CompletionIo::new(destination_disk.create_async_io(1)?)?; + let alignment = source_io.io().alignment().max(dest_io.io().alignment()); + + Ok(Self { + source_io, + dest_io, + state, + buf: AlignedBuf::new(block_size_bytes, alignment as usize)?, + block_size_bytes, + next_user_data: 0, + }) + } + + /// Spawns the worker on a named thread and returns its handle. + /// On error inside the thread, the migration phase transitions + /// to [`MirrorPhase::Failed`]. + pub fn spawn(self) -> io::Result { + let state = self.state.clone(); + let join = thread::Builder::new() + .name("blockdev-mirror-copy-worker".into()) + .spawn(move || { + let mut worker = self; + if let Err(e) = worker.run() { + state.transition_to_phase(MirrorPhase::Failed(Arc::new( + MirrorFailure::CopyWorker(e), + ))); + } + })?; + + Ok(CopyWorkerHandle { join }) + } + + /// Drives the block-by-block copy for predefined [`MirrorState::total_bytes`], + /// then transitions the migration phase to [`MirrorPhase::Ready`]. + fn run(&mut self) -> io::Result<()> { + let total_size = self.state.total_bytes; + let max_length = self.block_size_bytes as u64; + let mut offset = 0; + + while offset < total_size { + let length = max_length.min(total_size - offset) as usize; + self.copy_block(offset, length)?; + offset += length as u64; + } + + let user_data = self.generate_user_data(); + flush_async_io(user_data, &mut self.dest_io)?; + self.state.transition_to_phase(MirrorPhase::Ready); + Ok(()) + } + + /// Copies `length` bytes at `offset` from source to destination. + /// + /// Holds a range lock for the duration so virtqueue mirror writes cannot race + /// the copy. + fn copy_block(&mut self, offset: u64, length: usize) -> io::Result<()> { + let _guard = self + .state + .range_locks + .clone() + .lock_range(offset, length as u64)?; + + // Create a single iovec for the requested block. + let iovecs = [iovec { + iov_base: self.buf.as_mut_slice(length).as_mut_ptr().cast(), + iov_len: length, + }]; + + // Read from source into buf. + self.buf.as_mut_slice(length).fill(0); + let read_id = self.generate_user_data(); + self.source_io + .io_mut() + .read_vectored(offset as off_t, &iovecs, read_id) + .map_err(|e| io::Error::other(format!("async io read_vectored failed: {e}")))?; + let (user_data, result) = self.source_io.next_completion()?; + if result < 0 { + return Err(io::Error::from_raw_os_error(-result)); + } + debug_assert_eq!(user_data, read_id); + + // Write buf to destination. + let write_id = self.generate_user_data(); + self.dest_io + .io_mut() + .write_vectored(offset as off_t, &iovecs, write_id) + .map_err(|e| io::Error::other(format!("async io write_vectored failed: {e}")))?; + let (user_data, result) = self.dest_io.next_completion()?; + if result < 0 { + return Err(io::Error::from_raw_os_error(-result)); + } + debug_assert_eq!(user_data, write_id); + + self.state + .copied_bytes + .fetch_add(length as u64, Ordering::Relaxed); + + Ok(()) + } + + /// Returns the current [`Self::next_user_data`] and increments it, wrapping on overflow. + fn generate_user_data(&mut self) -> u64 { + let user_data = self.next_user_data; + self.next_user_data = self.next_user_data.wrapping_add(1); + + user_data + } +} + /// Owns an [`AsyncIo`] backend and waits for its completions. /// /// Keeping the backend and poll context together ensures the poll context @@ -441,7 +631,6 @@ struct CompletionIo { io: Box, } -#[expect(dead_code)] impl CompletionIo { fn new(io: Box) -> io::Result { let poll = PollContext::new()?; diff --git a/block/src/qcow_common.rs b/block/src/qcow_common.rs index 49cae1f79..4f483444b 100644 --- a/block/src/qcow_common.rs +++ b/block/src/qcow_common.rs @@ -149,6 +149,9 @@ impl Drop for AlignedBuf { } } +// SAFETY: AlignedBuf solely owns its plain-byte heap allocation (no Clone/Copy). +unsafe impl Send for AlignedBuf {} + /// Read into `buf` via an aligned bounce buffer when O_DIRECT requires it. pub fn aligned_pread(fd: RawFd, buf: &mut [u8], offset: u64, alignment: usize) -> io::Result<()> { if alignment == 0 From ac77f75bd4ad1caee7d5f525d853a415386cb032 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Wed, 29 Apr 2026 15:45:45 +0200 Subject: [PATCH 07/40] virtio-devices: swap disk_image via queue commands Blockdev-mirroring needs to install a MirroringAsyncIo on each virtqueue worker without restarting the threads. Restarting would be guest-visible (a device reset) and would need to drain in-flight I/O first. Per-queue worker state cannot be mutated from another thread, so the swap has to happen on the worker's own thread in response to a signal. Add the receiving side. Each virtqueue gets a BlockQueueCommandReceiver holding a single-command slot and an eventfd. The API thread fills the slot (from a future Block::start_mirror) and writes the eventfd. The worker wakes on BLOCK_COMMAND_EVENT, takes the command, and applies it via apply_block_queue_command: swap disk_image and re-register the completion notifier on the worker's epoll set. A BlockQueueCommand carries its kind (InstallMirror, CompleteToDestination, CancelToSource), the replacement AsyncIo, and an acknowledgement channel. The acknowledgement stays unused here and is wired up when Block::start_mirror lands. cmd_receiver is Option on BlockEpollHandler, None at construction, so the non-mirror path is unchanged: no event is registered and no new branches fire. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- virtio-devices/src/block.rs | 130 +++++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 2 deletions(-) diff --git a/virtio-devices/src/block.rs b/virtio-devices/src/block.rs index edb3650ab..09ffb13e0 100644 --- a/virtio-devices/src/block.rs +++ b/virtio-devices/src/block.rs @@ -15,7 +15,8 @@ use std::ops::Deref; use std::os::unix::io::AsRawFd; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering}; -use std::sync::{Arc, Barrier}; +use std::sync::mpsc::Sender; +use std::sync::{Arc, Barrier, Mutex}; use std::time::{Duration, Instant}; use std::{io, result, thread}; @@ -63,6 +64,9 @@ const COMPLETION_EVENT: u16 = EPOLL_HELPER_EVENT_LAST + 2; // New 'wake up' event from the rate limiter const RATE_LIMITER_EVENT: u16 = EPOLL_HELPER_EVENT_LAST + 3; +// A `BlockQueueCommand` has been queued for this worker to apply (e.g. swap disk_image). +const BLOCK_COMMAND_EVENT: u16 = EPOLL_HELPER_EVENT_LAST + 4; + // latency scale, for reduce precision loss in calculate. const LATENCY_SCALE: u64 = 10000; @@ -116,6 +120,68 @@ pub enum Error { pub type Result = result::Result; +/// Errors encountered while replacing a virtqueue worker's block I/O backend. +#[derive(Error, Debug)] +pub enum MirrorSwapError { + #[error("Failed to register new disk notifier")] + RegisterNotifier(#[source] EpollHelperError), + #[error("Failed to deregister old disk notifier")] + DeregisterNotifier(#[source] EpollHelperError), +} + +/// Result of replacing a virtqueue worker's block I/O backend. +type MirrorSwapResult = result::Result; + +/// Lifecycle command kind for a virtqueue worker. +#[derive(Debug, Clone, Copy)] +pub enum BlockQueueCommandKind { + /// Replace the plain source backend with a mirroring backend. + InstallMirror, + /// Replace the mirroring backend with a plain destination backend. + CompleteToDestination, + /// Replace the mirroring backend with a plain source backend. + CancelToSource, +} + +/// Acknowledgement sent by one virtqueue worker after handling a command. +pub struct BlockQueueAck { + /// Result of applying the command inside the worker. + pub result: MirrorSwapResult<()>, +} + +/// Command sent from `Block` to one virtqueue worker to change the worker's +/// active block I/O backend. +pub struct BlockQueueCommand { + /// Lifecycle action the worker should apply. + pub kind: BlockQueueCommandKind, + /// New async I/O backend that will replace the worker's current + /// `disk_image` after the old backend has drained. + /// + /// For start this is a `MirroringAsyncIo`. For cancel this is a plain + /// source `AsyncIo`. For completion this is a plain destination `AsyncIo`. + pub async_io: Box, + + /// Channel used by the worker to report that the command was applied or + /// failed. + pub ack: Sender, +} + +/// Per-virtqueue plumbing for swapping the worker's `disk_image` at +/// runtime. +/// +/// `cmd` and `evt` are shared with the API thread, which puts a +/// [`BlockQueueCommand`] into `cmd` (from [`Block::start_mirror`], +/// `complete_mirror`, or `cancel_mirror`) and writes to `evt` to wake the +/// worker. The worker takes the command and applies it. +pub struct BlockQueueCommandReceiver { + /// Next [`BlockQueueCommand`] to apply. Written by the API thread, + /// taken by the worker on `BLOCK_COMMAND_EVENT`. + pub cmd: Arc>>, + /// Wakes the worker after `cmd` is filled. Fires `BLOCK_COMMAND_EVENT` + /// on the worker's epoll set. + pub evt: EventFd, +} + // latency will be records as microseconds, average latency // will be save as scaled value. #[derive(Clone)] @@ -193,6 +259,8 @@ struct BlockEpollHandler { host_cpus: Option>, acked_features: u64, disable_sector0_writes: bool, + /// Receives mirror lifecycle commands for this virtqueue worker. + cmd_receiver: Option, } fn has_feature(features: u64, feature_flag: u64) -> bool { @@ -466,6 +534,42 @@ impl BlockEpollHandler { self.try_signal_used_queue() } + /// Replaces the active [`AsyncIo`] backend and updates its completion-event + /// registration. + fn apply_block_queue_command( + disk_image: &mut Box, + command: BlockQueueCommand, + helper: &mut EpollHelper, + ) -> MirrorSwapResult<()> { + let BlockQueueCommand { + kind: _, + async_io: new_disk_image, + ack: _, + } = command; + + let new_disk_fd = new_disk_image.notifier().as_raw_fd(); + let old_disk_fd = disk_image.notifier().as_raw_fd(); + + // Register the new backend's completion eventFd. + helper + .add_event(new_disk_fd, COMPLETION_EVENT) + .map_err(MirrorSwapError::RegisterNotifier)?; + + // Deregister the old backend's completion eventFd. + if let Err(e) = + helper.del_event_custom(old_disk_fd, COMPLETION_EVENT, epoll::Events::EPOLLIN) + { + // Rollback the new disk_image registration. + let _ = helper.del_event_custom(new_disk_fd, COMPLETION_EVENT, epoll::Events::EPOLLIN); + return Err(MirrorSwapError::DeregisterNotifier(e)); + } + + // Commit the swap. + *disk_image = new_disk_image; + + Ok(()) + } + #[inline] fn find_inflight_request(&mut self, completed_head: u16) -> Result { // This loop neatly handles the fast path where the completions are @@ -682,6 +786,9 @@ impl BlockEpollHandler { if let Some(rate_limiter) = &self.rate_limiter { helper.add_event(rate_limiter.as_raw_fd(), RATE_LIMITER_EVENT)?; } + if let Some(cmd_receiver) = &self.cmd_receiver { + helper.add_event(cmd_receiver.evt.as_raw_fd(), BLOCK_COMMAND_EVENT)?; + } self.set_queue_thread_affinity(); helper.run(paused, paused_sync, self)?; @@ -692,7 +799,7 @@ impl BlockEpollHandler { impl EpollHelperHandler for BlockEpollHandler { fn handle_event( &mut self, - _helper: &mut EpollHelper, + helper: &mut EpollHelper, event: &epoll::Event, ) -> result::Result<(), EpollHelperError> { let ev_type = event.data as u16; @@ -744,6 +851,24 @@ impl EpollHelperHandler for BlockEpollHandler { ))); } } + BLOCK_COMMAND_EVENT => { + // Apply a staged command: swap disk_image and re-register notifiers. + if let Some(m) = self.cmd_receiver.as_mut() { + m.evt.read().map_err(|e| { + EpollHelperError::HandleEvent(anyhow!( + "Failed to read block command event: {e:?}" + )) + })?; + if let Some(command) = m.cmd.lock().unwrap().take() { + Self::apply_block_queue_command(&mut self.disk_image, command, helper) + .map_err(|e| { + EpollHelperError::HandleEvent(anyhow!( + "Failed to apply block queue command: {e}" + )) + })?; + } + } + } _ => { return Err(EpollHelperError::HandleEvent(anyhow!( "Unexpected event: {ev_type}" @@ -1243,6 +1368,7 @@ impl VirtioDevice for Block { disable_sector0_writes: self.disable_sector0_writes, active_request_count: self.active_request_count.clone(), draining_active_requests: self.draining_active_requests.clone(), + cmd_receiver: None, }; let paused = self.common.paused.clone(); From 4ed18dcd5869b8cf4c91a9348878a15af1272b1e Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Wed, 29 Apr 2026 16:25:16 +0200 Subject: [PATCH 08/40] virtio-devices: pre-allocate per-queue command slots The previous commit added the receiving side of the queue command channel to BlockEpollHandler. For Block::start_mirror to fill a slot and write an eventfd, those handles have to exist at activation time and be reachable from both the virtqueue worker and Block itself. Build a BlockQueueCommandReceiver per virtqueue when the device is activated. A clone of the slot Arc and a clone of the eventfd are stored on the new Block.queue_cmd_senders field. The receiver with its eventfd clone is handed to BlockEpollHandler. The slot starts empty and the eventfd is silent, so BLOCK_COMMAND_EVENT does not fire and behaviour is unchanged. queue_cmd_senders is a Vec indexed by virtqueue. It is cleared at the start of every activation and re-populated for that activation's virtqueues. Follow-up: Block::start_mirror that fills each slot with an InstallMirror command carrying a MirroringAsyncIo, writes each evt, and spawns the copy worker. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- virtio-devices/src/block.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/virtio-devices/src/block.rs b/virtio-devices/src/block.rs index 09ffb13e0..495d1a2f3 100644 --- a/virtio-devices/src/block.rs +++ b/virtio-devices/src/block.rs @@ -902,6 +902,10 @@ pub struct Block { device_status: Arc, active_request_count: Arc, draining_active_requests: Arc, + /// Per-virtqueue mirror writer-side handles, populated at + /// activation. `Block::start_mirror` fills each slot with a + /// [`BlockQueueCommand`] and writes the corresponding evt. + queue_cmd_senders: Vec<(Arc>>, EventFd)>, } #[derive(Serialize, Deserialize)] @@ -1069,6 +1073,7 @@ impl Block { device_status: Arc::new(AtomicU8::new(0)), active_request_count: Arc::new(AtomicUsize::new(0)), draining_active_requests: Arc::new(AtomicBool::new(false)), + queue_cmd_senders: Vec::new(), }) } @@ -1325,6 +1330,12 @@ impl VirtioDevice for Block { let mut epoll_threads = Vec::new(); let event_idx = self.common.feature_acked(VIRTIO_RING_F_EVENT_IDX.into()); + // Reset and pre-allocate per-virtqueue mirror handoffs. The + // writer-side (slot + evt) is kept on `Block`. The receiver-side + // is handed to the BlockEpollHandler. + self.queue_cmd_senders.clear(); + self.queue_cmd_senders.reserve(queues.len()); + for i in 0..queues.len() { let (_, mut queue, queue_evt) = queues.remove(0); queue.set_event_idx(event_idx); @@ -1333,6 +1344,22 @@ impl VirtioDevice for Block { let (kill_evt, pause_evt) = self.common.dup_eventfds(); let queue_idx = i as u16; + let queue_command: Arc>> = Arc::new(Mutex::new(None)); + let queue_command_evt = EventFd::new(libc::EFD_NONBLOCK).map_err(|e| { + error!("failed to create mirror eventfd: {e}"); + ActivateError::BadActivate + })?; + let mirror_handler_evt = queue_command_evt.try_clone().map_err(|e| { + error!("failed to clone mirror eventfd: {e}"); + ActivateError::BadActivate + })?; + let cmd_receiver = BlockQueueCommandReceiver { + cmd: Arc::clone(&queue_command), + evt: mirror_handler_evt, + }; + self.queue_cmd_senders + .push((queue_command, queue_command_evt)); + let mut handler = BlockEpollHandler { queue_index: queue_idx, queue, @@ -1368,7 +1395,7 @@ impl VirtioDevice for Block { disable_sector0_writes: self.disable_sector0_writes, active_request_count: self.active_request_count.clone(), draining_active_requests: self.draining_active_requests.clone(), - cmd_receiver: None, + cmd_receiver: Some(cmd_receiver), }; let paused = self.common.paused.clone(); From 824922e261e7b27e7407dc6dccd821b8962cb648 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Wed, 29 Apr 2026 16:48:06 +0200 Subject: [PATCH 09/40] block: add BlockMirrorHandle The device side needs something to retain while a blockdev-mirror is active: the shared mirror state for status queries and the copy worker handle so the thread is joined on drop. Add BlockMirrorHandle bundling Arc and CopyWorkerHandle. Follow-up: Block::start_mirror that wires these up. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/mirror.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/block/src/mirror.rs b/block/src/mirror.rs index 4e5b0a958..708134752 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -622,6 +622,17 @@ impl CopyWorker { } } +/// Handle returned by `Block::start_mirror`. The owner (typically the +/// device manager) keeps it alive for the duration of the mirror to +/// observe `MirrorState` and to retain the [`CopyWorker`] thread. +#[allow(dead_code)] +pub struct BlockMirrorHandle { + /// Shared lifecycle state and copy progress. + pub state: Arc, + /// Handle for joining the background copy worker. + pub copy_worker: CopyWorkerHandle, +} + /// Owns an [`AsyncIo`] backend and waits for its completions. /// /// Keeping the backend and poll context together ensures the poll context From 15816341435937f8f2096ddd32e28f73bda0fc2f Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Thu, 30 Apr 2026 08:23:57 +0200 Subject: [PATCH 10/40] block: add MirroringAsyncIo::create Building a MirroringAsyncIo for a virtqueue takes more than the AsyncFullDiskFile trait offers: it needs the shared MirrorState to pair with the copy worker, and it sets up its own waiters on the source and destination notifiers so a mirrored write can wait for both completions inside the write call. The destination notifier is read only inside MirroringAsyncIo, so the virtqueue worker keeps watching just the source notifier. BlockMirrorHandle gains a destination field: Block.disk_image stays the source for the lifetime of the mirror, so the destination disk is owned by the handle. Follow-up: Block::start_mirror that wires up the new API. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/mirror.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/block/src/mirror.rs b/block/src/mirror.rs index 708134752..6b6163af6 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -10,6 +10,7 @@ //! switching the device to serve I/O from the destination. use std::collections::{BTreeMap, VecDeque}; +use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::thread::JoinHandle; @@ -251,6 +252,30 @@ pub struct MirroringAsyncIo { inflight_completions: VecDeque<(u64, i32)>, } impl MirroringAsyncIo { + /// Builds a [`MirroringAsyncIo`] for one virtqueue, wrapped in + /// `Box`. + /// + /// A mirrored write waits for both the source and destination completions + /// inside the write call, so this struct is the only reader of the + /// destination notifier. The virtqueue worker watches only the source + /// notifier, which it still needs to pick up read completions. + pub fn create( + source_disk: &dyn AsyncFullDiskFile, + destination_disk: &dyn AsyncFullDiskFile, + state: Arc, + ring_depth: u32, + ) -> BlockResult> { + let source = CompletionIo::new(source_disk.create_async_io(ring_depth)?)?; + let destination = CompletionIo::new(destination_disk.create_async_io(ring_depth)?)?; + + Ok(Box::new(MirroringAsyncIo { + source, + destination, + state, + inflight_completions: VecDeque::new(), + })) + } + /// Flip the mirror to the `Failed` phase. The operator must cancel to /// clean up the destination and the copy worker. fn fail(&mut self, failure: MirrorFailure) { @@ -631,6 +656,10 @@ pub struct BlockMirrorHandle { pub state: Arc, /// Handle for joining the background copy worker. pub copy_worker: CopyWorkerHandle, + /// Destination backend of the mirror. + pub destination: Box, + /// Host path backing the destination. + pub destination_path: PathBuf, } /// Owns an [`AsyncIo`] backend and waits for its completions. From b664c14806e520270cb4df8717e45f0490d356be Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Thu, 30 Apr 2026 09:25:35 +0200 Subject: [PATCH 11/40] virtio-devices: add Block::start_mirror Each virtqueue has its own AsyncIo, so mirroring needs one MirroringAsyncIo per virtqueue, installed through the per-queue command channel added earlier. start_mirror drives that handover and returns a BlockMirrorHandle for the device manager to own. It rejects a destination whose size differes from the source's, then builds all per-virtqueue InstallMirror commands before sending any, so a construction failure leaves the device unchanged. It sends the commands, waits for all acknowledgements with a timeout, and only then spawns the copy worker, so an earlier failure cannot leak a worker thread. The worker side that acknowledges each swap after draining its old backend lands in a follow-up commit. On any failure after the first command was sent, the queues are reverted to plain AsyncIo on the source disk via CancelToSource commands. A failed revert is logged and does not mask the install error. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/error.rs | 16 +++ block/src/mirror.rs | 6 +- virtio-devices/src/block.rs | 220 +++++++++++++++++++++++++++++++++++- 3 files changed, 234 insertions(+), 8 deletions(-) diff --git a/block/src/error.rs b/block/src/error.rs index 645057005..439929445 100644 --- a/block/src/error.rs +++ b/block/src/error.rs @@ -21,6 +21,19 @@ use std::fmt::{self, Display, Formatter}; use std::io; use std::path::PathBuf; +use thiserror::Error; + +/// Errors reported by block mirroring operations. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Error)] +pub enum MirrorError { + /// A mirror operation was requested before the device was activated. + #[error("Mirror operation rejected: the device is not active")] + DeviceNotActive, + /// A mirror swap was requested but was unsuccessful. + #[error("Failed to swap AsyncIO in virtqueue worker for mirror")] + Swap, +} + /// Small, stable classification of block errors. /// /// Callers match on this for control flow. Adding new format specific @@ -42,6 +55,8 @@ pub enum BlockErrorKind { NotFound, /// An internal counter or limit was exceeded. Overflow, + /// A block mirroring operation failed. + Mirror(MirrorError), } impl Display for BlockErrorKind { @@ -54,6 +69,7 @@ impl Display for BlockErrorKind { Self::OutOfBounds => write!(f, "Out of bounds"), Self::NotFound => write!(f, "Not found"), Self::Overflow => write!(f, "Overflow"), + Self::Mirror(error) => Display::fmt(error, f), } } } diff --git a/block/src/mirror.rs b/block/src/mirror.rs index 6b6163af6..ec195c3da 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -25,7 +25,7 @@ use vmm_sys_util::poll::PollContext; use crate::BatchRequest; use crate::async_io::{AsyncIo, AsyncIoError, AsyncIoResult}; use crate::disk_file::AsyncFullDiskFile; -use crate::error::BlockResult; +use crate::error::{BlockErrorKind, BlockResult}; use crate::qcow_common::AlignedBuf; /// Block size for the copy worker, in which it copies data from @@ -143,6 +143,9 @@ pub enum MirrorFailure { #[source] source: io::Error, }, + /// Installing the mirror backend failed. + #[error("Mirror installation failed: {0}")] + Installation(BlockErrorKind), /// A source completion returned an unexpected result. #[error("Source completion was {actual}, expected {expected}: user_data={user_data}")] SourceCompletion { @@ -650,7 +653,6 @@ impl CopyWorker { /// Handle returned by `Block::start_mirror`. The owner (typically the /// device manager) keeps it alive for the duration of the mirror to /// observe `MirrorState` and to retain the [`CopyWorker`] thread. -#[allow(dead_code)] pub struct BlockMirrorHandle { /// Shared lifecycle state and copy progress. pub state: Arc, diff --git a/virtio-devices/src/block.rs b/virtio-devices/src/block.rs index 495d1a2f3..10b7876b9 100644 --- a/virtio-devices/src/block.rs +++ b/virtio-devices/src/block.rs @@ -15,16 +15,20 @@ use std::ops::Deref; use std::os::unix::io::AsRawFd; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering}; -use std::sync::mpsc::Sender; -use std::sync::{Arc, Barrier, Mutex}; +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::{Arc, Barrier, Mutex, mpsc}; use std::time::{Duration, Instant}; use std::{io, result, thread}; use anyhow::anyhow; use block::async_io::{AsyncIo, AsyncIoError}; use block::disk_file::AsyncFullDiskFile; -use block::error::BlockError; +use block::error::{BlockError, BlockErrorKind, BlockResult, MirrorError}; use block::fcntl::{LockError, LockGranularity, LockGranularityChoice, LockType}; +use block::mirror::{ + BlockMirrorHandle, CopyWorker, CopyWorkerHandle, MIRROR_BLOCK_SIZE, MirrorFailure, MirrorPhase, + MirrorState, MirroringAsyncIo, +}; use block::{ ExecuteAsync, ExecuteError, MAX_DISCARD_WRITE_ZEROES_SEG, Request, RequestType, VirtioBlockConfig, build_serial, @@ -67,6 +71,9 @@ const RATE_LIMITER_EVENT: u16 = EPOLL_HELPER_EVENT_LAST + 3; // A `BlockQueueCommand` has been queued for this worker to apply (e.g. swap disk_image). const BLOCK_COMMAND_EVENT: u16 = EPOLL_HELPER_EVENT_LAST + 4; +// Maximum duration to wait for a command to be acknowledged by the virtqueue worker. +const MIRROR_COMMAND_ACK_TIMEOUT: Duration = Duration::from_secs(5); + // latency scale, for reduce precision loss in calculate. const LATENCY_SCALE: u64 = 10000; @@ -127,6 +134,14 @@ pub enum MirrorSwapError { RegisterNotifier(#[source] EpollHelperError), #[error("Failed to deregister old disk notifier")] DeregisterNotifier(#[source] EpollHelperError), + #[error("Mirror command slot is occupied")] + CommandSlotOccupied, + #[error("Failed to notify mirror queue worker")] + NotifyWorker(#[source] io::Error), + #[error("Failed waiting for mirror command acknowledgement")] + Ack(#[source] mpsc::RecvTimeoutError), + #[error("Mirror command failed in queue worker")] + QueueWorker(#[source] Box), } /// Result of replacing a virtqueue worker's block I/O backend. @@ -166,6 +181,9 @@ pub struct BlockQueueCommand { pub ack: Sender, } +/// One command per virtqueue, each paired with the sender of its queue. +type QueueCommands<'a> = Vec<(&'a BlockQueueCommandSender, BlockQueueCommand)>; + /// Per-virtqueue plumbing for swapping the worker's `disk_image` at /// runtime. /// @@ -182,6 +200,16 @@ pub struct BlockQueueCommandReceiver { pub evt: EventFd, } +/// API-thread handles used to stage and signal commands for one virtqueue. +struct BlockQueueCommandSender { + /// Single command slot shared with the virtqueue worker. + cmd: Arc>>, + /// Eventfd used to wake the virtqueue worker. + evt: EventFd, + /// Virtqueue size used as the replacement backend's ring depth. + queue_size: u16, +} + // latency will be records as microseconds, average latency // will be save as scaled value. #[derive(Clone)] @@ -905,7 +933,7 @@ pub struct Block { /// Per-virtqueue mirror writer-side handles, populated at /// activation. `Block::start_mirror` fills each slot with a /// [`BlockQueueCommand`] and writes the corresponding evt. - queue_cmd_senders: Vec<(Arc>>, EventFd)>, + queue_cmd_senders: Vec, } #[derive(Serialize, Deserialize)] @@ -1249,6 +1277,183 @@ impl Block { .map_err(Error::ConfigChange) } + /// Start mirroring the device's disk to `destination`. + /// + /// `destination` is an already-opened disk backend whose file lives in + /// the host filesystem, typically on a different mount than the source + /// (e.g. another host mounted NFS share). + /// `destination_path` is the host path backing it. + /// + /// Each virtqueue worker swaps its `disk_image` to a new + /// [`MirroringAsyncIo`] that fans every mutating request out to both + /// backends. A background [`CopyWorker`] copies existing source bytes + /// to destination until all initial bytes are copied. + /// The [`MirroringAsyncIo`] stays in place until completion, keeping the device's + /// disk and `destination` in sync. + /// + /// Returns an error if the destination size differs from the source, on + /// `logical_size()` failure, [`MirroringAsyncIo`] construction failure, or + /// copy worker spawn failure. + pub fn start_mirror( + &mut self, + destination: Box, + destination_path: PathBuf, + ) -> BlockResult { + // Mirroring requires activation to have installed at least one live queue worker. + if self.common.epoll_threads.is_none() || self.queue_cmd_senders.is_empty() { + return Err(BlockError::from_kind(BlockErrorKind::Mirror( + MirrorError::DeviceNotActive, + ))); + } + let source_size = self.disk_image.logical_size()?; + let dest_size = destination.logical_size()?; + if dest_size != source_size { + return Err(BlockError::new( + BlockErrorKind::Io, + io::Error::other(format!( + "mirror destination size ({dest_size} bytes) differs from source size ({source_size} bytes)" + )), + )); + } + + let state = MirrorState::new(source_size); + let (commands, ack_rx) = self.create_mirror_queue_commands( + BlockQueueCommandKind::InstallMirror, + |ring_depth| { + MirroringAsyncIo::create( + self.disk_image.as_ref(), + destination.as_ref(), + state.clone(), + ring_depth, + ) + }, + )?; + + let install_result: BlockResult = (|| { + Self::send_mirror_queue_commands(commands)?; + Self::wait_for_mirror_queue_command_acks(&ack_rx, self.queue_cmd_senders.len())?; + CopyWorker::new( + self.disk_image.as_ref(), + destination.as_ref(), + state.clone(), + MIRROR_BLOCK_SIZE, + )? + .spawn() + .map_err(|e| BlockError::new(BlockErrorKind::Io, e)) + })(); + + let copy_worker = install_result.inspect_err(|e| { + state.transition_to_phase(MirrorPhase::Failed(Arc::new( + MirrorFailure::Installation(e.kind()), + ))); + + // Don't mask the install error on revert err. + if let Err(revert_err) = self.revert_queues_to_source() { + error!( + "failed to revert virtqueues to source after mirror install failure: {revert_err}" + ); + } + })?; + + Ok(BlockMirrorHandle { + state, + copy_worker, + destination, + destination_path, + }) + } + + fn mirror_swap_error(error: MirrorSwapError) -> BlockError { + BlockError::new(BlockErrorKind::Mirror(MirrorError::Swap), error) + } + + /// Creates one command per virtqueue, all sharing one ack channel. + /// Returns each command paired with the sender of its queue, plus + /// the receiving end of the channel. + /// + /// `new_async_io` is called once per queue with the ring depth of + /// that queue and returns the backend the worker swaps to. + /// + /// The ack sender lives only inside the returned commands. Once + /// every worker has consumed or dropped its command, a lost ack + /// shows up as `Disconnected` on the receiver instead of costing + /// the full ack timeout, and only the workers can ack this op. + fn create_mirror_queue_commands( + &self, + kind: BlockQueueCommandKind, + mut new_async_io: impl FnMut(u32) -> BlockResult>, + ) -> BlockResult<(QueueCommands<'_>, Receiver)> { + let (ack_tx, ack_rx) = mpsc::channel(); + let commands = self + .queue_cmd_senders + .iter() + .map(|sender| { + Ok(( + sender, + BlockQueueCommand { + kind, + async_io: new_async_io(u32::from(sender.queue_size))?, + ack: ack_tx.clone(), + }, + )) + }) + .collect::>()?; + Ok((commands, ack_rx)) + } + + /// Sends one staged mirror command to each virtqueue worker. + fn send_mirror_queue_commands(commands: QueueCommands<'_>) -> BlockResult<()> { + for (sender, command) in commands { + let mut slot = sender.cmd.lock().unwrap(); + + if slot.is_some() { + return Err(Self::mirror_swap_error( + MirrorSwapError::CommandSlotOccupied, + )); + } + + *slot = Some(command); + sender + .evt + .write(1) + .map_err(MirrorSwapError::NotifyWorker) + .map_err(Self::mirror_swap_error)?; + } + + Ok(()) + } + + /// Wait for n acknowledgments of a mirror command on ack_rx, returning + /// an error if a timeout occurs or if any ack reports an error. + fn wait_for_mirror_queue_command_acks( + ack_rx: &Receiver, + expected_acks: usize, + ) -> BlockResult<()> { + for _ in 0..expected_acks { + let ack = ack_rx + .recv_timeout(MIRROR_COMMAND_ACK_TIMEOUT) + .map_err(MirrorSwapError::Ack) + .map_err(Self::mirror_swap_error)?; + + ack.result + .map_err(Box::new) + .map_err(MirrorSwapError::QueueWorker) + .map_err(Self::mirror_swap_error)?; + } + + Ok(()) + } + + /// Swap every virtqueue worker back to a plain AsyncIo on the source disk. + fn revert_queues_to_source(&mut self) -> BlockResult<()> { + let (commands, ack_rx) = self + .create_mirror_queue_commands(BlockQueueCommandKind::CancelToSource, |ring_depth| { + self.disk_image.create_async_io(ring_depth) + })?; + Self::send_mirror_queue_commands(commands)?; + Self::wait_for_mirror_queue_command_acks(&ack_rx, self.queue_cmd_senders.len()) + } + #[cfg(fuzzing)] pub fn wait_for_epoll_threads(&mut self) { self.common.wait_for_epoll_threads(); @@ -1357,8 +1562,11 @@ impl VirtioDevice for Block { cmd: Arc::clone(&queue_command), evt: mirror_handler_evt, }; - self.queue_cmd_senders - .push((queue_command, queue_command_evt)); + self.queue_cmd_senders.push(BlockQueueCommandSender { + cmd: queue_command, + evt: queue_command_evt, + queue_size, + }); let mut handler = BlockEpollHandler { queue_index: queue_idx, From caf647c3ad08333d0a2f8dc66e926ba7cb3173c0 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Thu, 30 Apr 2026 09:54:20 +0200 Subject: [PATCH 12/40] virtio-devices, block: add mirror status helper After starting the blockdev-mirror, the operator must be able to observe the current progress of the copy worker to decide whether to complete to the destination disk or to react to a failure. Introduce a Block::mirror_handle field and a dedicated MirrorStatus struct, to be used in the upcoming API endpoint. Block now owns the worker handle and explicitly joins it when dropped. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/mirror.rs | 19 +++++++++++++++++++ virtio-devices/src/block.rs | 22 ++++++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/block/src/mirror.rs b/block/src/mirror.rs index ec195c3da..c81dd7530 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -241,6 +241,25 @@ impl MirrorState { *current = target; } + + /// Returns a snapshot of the mirror phase and copy progress. + pub fn status(&self) -> MirrorStatus { + MirrorStatus { + phase: self.phase(), + copied_bytes: self.copied_bytes.load(Ordering::Relaxed), + total_bytes: self.total_bytes, + } + } +} + +/// Snapshot of an active block mirror's phase and copy progress. +pub struct MirrorStatus { + /// Current lifecycle phase. + pub phase: MirrorPhase, + /// Number of source bytes copied by the background worker. + pub copied_bytes: u64, + /// Total logical number of bytes to copy. + pub total_bytes: u64, } /// Per-queue `AsyncIo` handle for a mirror. diff --git a/virtio-devices/src/block.rs b/virtio-devices/src/block.rs index 10b7876b9..a97d97ec4 100644 --- a/virtio-devices/src/block.rs +++ b/virtio-devices/src/block.rs @@ -27,7 +27,7 @@ use block::error::{BlockError, BlockErrorKind, BlockResult, MirrorError}; use block::fcntl::{LockError, LockGranularity, LockGranularityChoice, LockType}; use block::mirror::{ BlockMirrorHandle, CopyWorker, CopyWorkerHandle, MIRROR_BLOCK_SIZE, MirrorFailure, MirrorPhase, - MirrorState, MirroringAsyncIo, + MirrorState, MirrorStatus, MirroringAsyncIo, }; use block::{ ExecuteAsync, ExecuteError, MAX_DISCARD_WRITE_ZEROES_SEG, Request, RequestType, @@ -934,6 +934,7 @@ pub struct Block { /// activation. `Block::start_mirror` fills each slot with a /// [`BlockQueueCommand`] and writes the corresponding evt. queue_cmd_senders: Vec, + mirror_handle: Option, } #[derive(Serialize, Deserialize)] @@ -1102,6 +1103,7 @@ impl Block { active_request_count: Arc::new(AtomicUsize::new(0)), draining_active_requests: Arc::new(AtomicBool::new(false)), queue_cmd_senders: Vec::new(), + mirror_handle: None, }) } @@ -1298,7 +1300,7 @@ impl Block { &mut self, destination: Box, destination_path: PathBuf, - ) -> BlockResult { + ) -> BlockResult<()> { // Mirroring requires activation to have installed at least one live queue worker. if self.common.epoll_threads.is_none() || self.queue_cmd_senders.is_empty() { return Err(BlockError::from_kind(BlockErrorKind::Mirror( @@ -1355,12 +1357,13 @@ impl Block { } })?; - Ok(BlockMirrorHandle { + self.mirror_handle = Some(BlockMirrorHandle { state, copy_worker, destination, destination_path, - }) + }); + Ok(()) } fn mirror_swap_error(error: MirrorSwapError) -> BlockError { @@ -1454,6 +1457,11 @@ impl Block { Self::wait_for_mirror_queue_command_acks(&ack_rx, self.queue_cmd_senders.len()) } + /// Returns a snapshot of the current mirror progress. + pub fn mirror_status(&self) -> Option { + self.mirror_handle.as_ref().map(|h| h.state.status()) + } + #[cfg(fuzzing)] pub fn wait_for_epoll_threads(&mut self) { self.common.wait_for_epoll_threads(); @@ -1467,6 +1475,12 @@ impl Drop for Block { let _ = kill_evt.write(1); } self.common.wait_for_epoll_threads(); + + if let Some(handle) = self.mirror_handle.take() + && let Err(e) = handle.copy_worker.join() + { + error!("copy worker thread panicked: {e:?}"); + } } } From 9e686732380211c56b032894116197022afc287c Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Thu, 30 Apr 2026 13:11:07 +0200 Subject: [PATCH 13/40] vmm: add device manager block mirror start and status Start exposes the lifecycle entrypoint that the upcoming REST endpoint will route to. The operator must provide an existing destination image with the same format and logical size as the source. The destination is opened with the source disk's backend options before mirroring starts. Status surfaces the shared mirror state through the device manager so operators can poll and observe progress. Adds three DeviceManagerError variants for the new failure modes and a BlockErrorKind::AlreadyExists for the create_disk pre-condition. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- vmm/src/device_manager.rs | 84 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/vmm/src/device_manager.rs b/vmm/src/device_manager.rs index c160728be..e696d8a21 100644 --- a/vmm/src/device_manager.rs +++ b/vmm/src/device_manager.rs @@ -35,7 +35,8 @@ use arch::{DeviceType, MmioDeviceInfo}; use arch::{NumaNodes, layout}; use block::ImageType; use block::error::BlockError; -use block::factory::{DiskOpenOptions, open_disk}; +use block::factory::{DiskOpenOptions, OpenedDisk, open_disk}; +use block::mirror::MirrorStatus; #[cfg(target_arch = "riscv64")] use devices::aia; #[cfg(target_arch = "x86_64")] @@ -679,6 +680,10 @@ pub enum DeviceManagerError { specified: ImageType, detected: ImageType, }, + + /// No block mirroring is active for the current device. + #[error("No block mirroring is active for the current disk with identifier: {0}")] + BlockMirrorNotActive(String), } pub type DeviceManagerResult = result::Result; @@ -5320,6 +5325,83 @@ impl DeviceManager { } } + /// Start mirroring the disk identified by `device_id` to `dest_path`. + /// + /// The destination file must already exist and use the same image + /// format as the source disk. It is handed to the virtio block device, + /// which mirrors later guest writes out to both backends while a + /// background worker copies the existing source contents. + /// + /// Returns an error if no disk with the given identifier is attached + /// to the VM, or the destination cannot be opened. + pub fn mirror_disk(&self, device_id: &str, dest_path: &Path) -> DeviceManagerResult<()> { + let mut disk = self + .block_devices + .iter() + .map(|dev| dev.lock().unwrap()) + .find(|disk| disk.id() == device_id) + .ok_or_else(|| DeviceManagerError::UnknownDeviceId(device_id.to_string()))?; + + let (options, image_type) = { + let cfg = self.config.lock().unwrap(); + let src = cfg + .disks + .iter() + .flatten() + .find(|d| d.pci_common.id.as_deref() == Some(device_id)) + .ok_or_else(|| DeviceManagerError::UnknownDeviceId(device_id.to_string()))?; + + ( + &DiskOpenOptions { + path: dest_path, + readonly: false, // ignore source's readonly, mirroring needs write access. + direct: src.direct, + sparse: src.sparse, + backing_files: src.backing_files, + disable_io_uring: src.disable_io_uring, + disable_aio: src.disable_aio, + }, + src.image_type, + ) + }; + + let destination = match open_disk(options).map_err(DeviceManagerError::Disk)? { + OpenedDisk { + image_type: detected, + disk, + } if detected == image_type => disk, + OpenedDisk { + image_type: detected, + .. + } => { + return Err(DeviceManagerError::DiskImageTypeMismatch { + specified: image_type, + detected, + }); + } + }; + + disk.start_mirror(destination, dest_path.to_path_buf()) + .map_err(DeviceManagerError::Disk)?; + + Ok(()) + } + + /// Return the current state of the active mirror for the disk + /// identified by `device_id`. + /// + /// Returns an error if no disk with the given identifier is + /// attached to the VM, or if the disk has no active mirror. + pub fn mirror_disk_status(&self, device_id: &str) -> DeviceManagerResult { + self.block_devices + .iter() + .map(|dev| dev.lock().unwrap()) + .find(|disk| disk.id() == device_id) + .ok_or_else(|| DeviceManagerError::UnknownDeviceId(device_id.to_string()))? + .mirror_status() + .ok_or_else(|| DeviceManagerError::BlockMirrorNotActive(device_id.to_string())) + } + /// Helps the environment converge quickly after a live migration by /// prompting devices to advertise the VM from its new host. /// From 417abab10f5b9c891e0631bc296230209260e244 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Thu, 30 Apr 2026 15:37:16 +0200 Subject: [PATCH 14/40] vmm: add vm.disk-mirror-start REST endpoint Wire disk mirroring through to the HTTP API so a running VM can be told to start mirroring a disk onto a destination path. Add the /vm.disk-mirror-start endpoint and its request handler, the vm_disk_mirror_start dispatch on the VMM, and the Vm::mirror_disk wrapper that locks the device manager and maps its error into the vmm error type. A new DiskMirrorStart error covers the case where no VM owns the device manager. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- fuzz/fuzz_targets/http_api.rs | 4 +++ vmm/src/api/http/http_endpoint.rs | 25 ++++++++++--- vmm/src/api/http/mod.rs | 11 ++++-- vmm/src/api/mod.rs | 44 +++++++++++++++++++++++ vmm/src/api/openapi/cloud-hypervisor.yaml | 31 ++++++++++++++++ vmm/src/device_manager.rs | 13 +++++++ vmm/src/lib.rs | 15 +++++++- vmm/src/vm.rs | 24 +++++++++++++ 8 files changed, 158 insertions(+), 9 deletions(-) diff --git a/fuzz/fuzz_targets/http_api.rs b/fuzz/fuzz_targets/http_api.rs index aa3841243..033aff80f 100644 --- a/fuzz/fuzz_targets/http_api.rs +++ b/fuzz/fuzz_targets/http_api.rs @@ -113,6 +113,10 @@ impl RequestHandler for StubApiRequestHandler { Ok(()) } + fn vm_disk_mirror_start(&mut self, _: String, _: PathBuf) -> Result<(), VmError> { + Ok(()) + } + #[cfg(target_arch = "x86_64")] fn vm_coredump(&mut self, _: &str) -> Result<(), VmError> { Ok(()) diff --git a/vmm/src/api/http/http_endpoint.rs b/vmm/src/api/http/http_endpoint.rs index 57aa6c446..ef07a17ab 100644 --- a/vmm/src/api/http/http_endpoint.rs +++ b/vmm/src/api/http/http_endpoint.rs @@ -48,13 +48,14 @@ use crate::api::http::{EndpointHandler, HttpError, error_response}; use crate::api::{ AddDisk, ApiAction, ApiError, ApiRequest, NetConfig, VmAddDevice, VmAddFs, VmAddGenericVhostUser, VmAddNet, VmAddPmem, VmAddUserDevice, VmAddVdpa, VmAddVsock, VmBoot, - VmCancelMigration, VmConfig, VmCounters, VmDelete, VmMigrationProgress, VmNmi, VmPause, - VmPostMigrationAnnounce, VmPowerButton, VmReboot, VmReceiveMigration, VmReceiveMigrationData, - VmRemoveDevice, VmResize, VmResizeDisk, VmResizeZone, VmRestore, VmResume, VmSendMigration, - VmShutdown, VmSnapshot, + VmCancelMigration, VmConfig, VmCounters, VmDelete, VmDiskMirrorStart, VmMigrationProgress, + VmNmi, VmPause, VmPostMigrationAnnounce, VmPowerButton, VmReboot, VmReceiveMigration, + VmReceiveMigrationData, VmRemoveDevice, VmResize, VmResizeDisk, VmResizeZone, VmRestore, + VmResume, VmSendMigration, VmShutdown, VmSnapshot, }; use crate::config::RestoreConfig; use crate::cpu::Error as CpuError; +use crate::device_manager::DeviceManagerError; use crate::vm::Error as VmError; /// Helper module for attaching externally opened FDs to config objects. @@ -383,6 +384,11 @@ macro_rules! vm_action_put_handler { macro_rules! vm_action_put_handler_body { ($action:ty) => { + vm_action_put_handler_body!($action, HttpError::ApiError); + }; + // The two-argument form takes an error mapper for actions that + // want their own `ApiError` to HTTP status translation. + ($action:ty, $map_err:expr) => { impl PutHandler for $action { fn handle_request( &'static self, @@ -397,7 +403,7 @@ macro_rules! vm_action_put_handler_body { api_sender, serde_json::from_slice(body.raw())?, ) - .map_err(HttpError::ApiError) + .map_err($map_err) } else { Err(HttpError::BadRequest) } @@ -518,6 +524,15 @@ impl PutHandler for VmSendMigration { impl GetHandler for VmSendMigration {} +vm_action_put_handler_body!(VmDiskMirrorStart, |e| match &e { + ApiError::VmDiskMirrorStart(VmError::DeviceManager(DeviceManagerError::UnknownDeviceId(_))) => + HttpError::NotFound, + ApiError::VmDiskMirrorStart(VmError::DeviceManager( + DeviceManagerError::BlockMirrorAlreadyActive(_), + )) => HttpError::BadRequest, + _ => HttpError::ApiError(e), +}); + impl PutHandler for VmResize { fn handle_request( &'static self, diff --git a/vmm/src/api/http/mod.rs b/vmm/src/api/http/mod.rs index 5464ca87a..e1ed29ea5 100644 --- a/vmm/src/api/http/mod.rs +++ b/vmm/src/api/http/mod.rs @@ -30,9 +30,9 @@ use crate::api::VmCoredump; use crate::api::{ AddDisk, ApiError, ApiRequest, VmAddDevice, VmAddFs, VmAddGenericVhostUser, VmAddNet, VmAddPmem, VmAddUserDevice, VmAddVdpa, VmAddVsock, VmBoot, VmCancelMigration, VmCounters, - VmDelete, VmMigrationProgress, VmNmi, VmPause, VmPostMigrationAnnounce, VmPowerButton, - VmReboot, VmReceiveMigration, VmRemoveDevice, VmResize, VmResizeDisk, VmResizeZone, VmRestore, - VmResume, VmSendMigration, VmShutdown, VmSnapshot, + VmDelete, VmDiskMirrorStart, VmMigrationProgress, VmNmi, VmPause, VmPostMigrationAnnounce, + VmPowerButton, VmReboot, VmReceiveMigration, VmRemoveDevice, VmResize, VmResizeDisk, + VmResizeZone, VmRestore, VmResume, VmSendMigration, VmShutdown, VmSnapshot, }; use crate::landlock::Landlock; use crate::seccomp_filters::{Thread, get_seccomp_filter}; @@ -141,6 +141,7 @@ pub trait EndpointHandler { error_response(e, StatusCode::BadRequest) } Err(e @ HttpError::TooManyRequests) => error_response(e, StatusCode::TooManyRequests), + Err(e @ HttpError::NotFound) => error_response(e, StatusCode::NotFound), Err(e) => error_response(e, StatusCode::InternalServerError), } } @@ -233,6 +234,10 @@ pub static HTTP_ROUTES: LazyLock = LazyLock::new(|| { endpoint!("/vm.delete"), Box::new(VmActionHandler::new(&VmDelete)), ); + r.routes.insert( + endpoint!("/vm.disk-mirror-start"), + Box::new(VmActionHandler::new(&VmDiskMirrorStart)), + ); r.routes.insert(endpoint!("/vm.info"), Box::new(VmInfo {})); r.routes.insert( endpoint!("/vm.pause"), diff --git a/vmm/src/api/mod.rs b/vmm/src/api/mod.rs index 6d1f7c6d7..471cfbb34 100644 --- a/vmm/src/api/mod.rs +++ b/vmm/src/api/mod.rs @@ -149,6 +149,10 @@ pub enum ApiError { #[error("The disk could not be resized")] VmResizeDisk(#[source] VmError), + /// Error starting disk mirror + #[error("Error starting disk mirror")] + VmDiskMirrorStart(#[source] VmError), + /// The memory zone could not be resized. #[error("The memory zone could not be resized")] VmResizeZone(#[source] VmError), @@ -235,6 +239,12 @@ pub struct VmInfoResponse { pub device_tree: Option, } +#[derive(Clone, Deserialize, Serialize, Default, Debug)] +pub struct VmDiskMirrorStartData { + pub id: String, + pub destination_path: PathBuf, +} + #[derive(Clone, Deserialize, Serialize)] pub struct VmmPingResponse { pub build_version: String, @@ -752,6 +762,11 @@ pub trait RequestHandler { fn vm_resize_zone(&mut self, id: String, desired_ram: u64) -> Result<(), VmError>; fn vm_resize_disk(&mut self, id: String, desired_size: u64) -> Result<(), VmError>; + fn vm_disk_mirror_start( + &mut self, + id: String, + destination_path: PathBuf, + ) -> Result<(), VmError>; fn vm_add_device(&mut self, device_cfg: DeviceConfig) -> Result>, VmError>; @@ -1379,6 +1394,35 @@ impl ApiAction for VmDelete { get_response_body(self, api_evt, api_sender, data) } } +pub struct VmDiskMirrorStart; +impl ApiAction for VmDiskMirrorStart { + type RequestBody = VmDiskMirrorStartData; + type ResponseBody = Option; + + fn request(&self, data: Self::RequestBody, response_sender: Sender) -> ApiRequest { + Box::new(move |vmm| { + let response = vmm + .vm_disk_mirror_start(data.id, data.destination_path) + .map_err(ApiError::VmDiskMirrorStart) + .map(|_| ApiResponsePayload::Empty); + + response_sender + .send(response) + .map_err(VmmError::ApiResponseSend)?; + + Ok(false) + }) + } + + fn send( + &self, + api_evt: EventFd, + api_sender: Sender, + data: Self::RequestBody, + ) -> ApiResult { + get_response_body(self, api_evt, api_sender, data) + } +} pub struct VmInfo; diff --git a/vmm/src/api/openapi/cloud-hypervisor.yaml b/vmm/src/api/openapi/cloud-hypervisor.yaml index ad612b951..8fa586289 100644 --- a/vmm/src/api/openapi/cloud-hypervisor.yaml +++ b/vmm/src/api/openapi/cloud-hypervisor.yaml @@ -510,6 +510,26 @@ paths: 500: description: The VM migration could not be sent. + /vm.disk-mirror-start: + put: + summary: Start mirroring a disk to a destination + requestBody: + description: The disk to mirror and the destination path + content: + application/json: + schema: + $ref: "#/components/schemas/VmDiskMirrorStartData" + required: true + responses: + 204: + description: Disk mirroring was successfully started. + 400: + description: A mirror is already active for the disk, or the destination is not usable. + 404: + description: No disk with the given identifier was found. + 500: + description: Disk mirroring could not be started. + components: schemas: VmmPingResponse: @@ -1564,3 +1584,14 @@ components: type: string access: type: string + + VmDiskMirrorStartData: + required: + - id + - destination_path + type: object + properties: + id: + type: string + destination_path: + type: string diff --git a/vmm/src/device_manager.rs b/vmm/src/device_manager.rs index e696d8a21..5d554c3da 100644 --- a/vmm/src/device_manager.rs +++ b/vmm/src/device_manager.rs @@ -684,6 +684,12 @@ pub enum DeviceManagerError { /// No block mirroring is active for the current device. #[error("No block mirroring is active for the current disk with identifier: {0}")] BlockMirrorNotActive(String), + + /// Mirroring is already active for the current device. + #[error( + "Failed to start block mirroring for the disk with identifier: {0} as mirroring is already active" + )] + BlockMirrorAlreadyActive(String), } pub type DeviceManagerResult = result::Result; @@ -5342,6 +5348,13 @@ impl DeviceManager { .find(|disk| disk.id() == device_id) .ok_or_else(|| DeviceManagerError::UnknownDeviceId(device_id.to_string()))?; + if let Some(status) = disk.mirror_status() { + return Err(DeviceManagerError::BlockMirrorAlreadyActive(format!( + "{device_id} is in phase {:?}, cancel the mirror before starting a new one", + status.phase + ))); + } + let (options, image_type) = { let cfg = self.config.lock().unwrap(); let src = cfg diff --git a/vmm/src/lib.rs b/vmm/src/lib.rs index aa0df7808..4d2a10815 100644 --- a/vmm/src/lib.rs +++ b/vmm/src/lib.rs @@ -17,7 +17,6 @@ use std::fs::File; use std::io::{Read, Write, stdout}; use std::os::unix::io::{AsRawFd, FromRawFd, RawFd}; use std::panic::AssertUnwindSafe; -#[cfg(feature = "guest_debug")] use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, RecvError, SendError, Sender}; @@ -3490,6 +3489,20 @@ impl RequestHandler for Vmm { let lock = MIGRATION_PROGRESS_SNAPSHOT.lock().unwrap(); lock.clone() } + + fn vm_disk_mirror_start( + &mut self, + id: String, + destination_path: PathBuf, + ) -> result::Result<(), VmError> { + self.vm_config.as_ref().ok_or(VmError::VmNotCreated)?; + + match self.vm { + MaybeVmOwnership::Vmm(ref mut vm) => vm.mirror_disk(&id, &destination_path), + MaybeVmOwnership::Migration(_) => Err(VmError::VmMigrating), + MaybeVmOwnership::None => Err(VmError::DiskMirrorStart), + } + } } const CPU_MANAGER_SNAPSHOT_ID: &str = "cpu-manager"; diff --git a/vmm/src/vm.rs b/vmm/src/vm.rs index 2f73adacc..8a560409f 100644 --- a/vmm/src/vm.rs +++ b/vmm/src/vm.rs @@ -19,6 +19,7 @@ use std::mem::size_of; use std::num::Wrapping; use std::ops::Deref; use std::os::unix::net::UnixStream; +use std::path::Path; use std::sync::{Arc, Mutex}; #[cfg(not(target_arch = "riscv64"))] use std::time::Instant; @@ -34,6 +35,7 @@ use arch::x86_64::MAX_SUPPORTED_CPUS_LEGACY; #[cfg(feature = "tdx")] use arch::x86_64::tdx::TdvfSection; use arch::{EntryPoint, NumaNode, NumaNodes, get_host_cpu_phys_bits}; +use block::mirror::MirrorStatus; use devices::AcpiNotificationFlags; #[cfg(target_arch = "aarch64")] use devices::interrupt_controller; @@ -269,6 +271,9 @@ pub enum Error { #[error("Failed resizing a disk image")] ResizeDisk, + #[error("Failed to start disk mirror")] + DiskMirrorStart, + #[error("Cannot activate virtio devices")] ActivateVirtioDevices(#[source] DeviceManagerError), @@ -3303,6 +3308,25 @@ impl Vm { .map_err(Error::ErrorNmi); } + pub fn mirror_disk(&self, id: &str, dest_path: &Path) -> Result<()> { + self.device_manager + .lock() + .unwrap() + .mirror_disk(id, dest_path) + .map_err(Error::DeviceManager)?; + + Ok(()) + } + + /// Returns the current mirror status for `id`. + pub fn mirror_disk_status(&self, id: &str) -> Result { + self.device_manager + .lock() + .unwrap() + .mirror_disk_status(id) + .map_err(Error::DeviceManager) + } + /// Calls [`DeviceManager::post_migration_announce`]. pub fn post_migration_announce(&self) { self.device_manager From 043f6a3f6766e7b5dffbccd5238f2611443a11bc Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Thu, 30 Apr 2026 16:08:43 +0200 Subject: [PATCH 15/40] vmm: add vm.disk-mirror-status REST endpoint Expose the disk mirror status operation as a REST entrypoint so operators can poll progress and detect terminal phases. The endpoint returns the current phase, copied bytes, total bytes, and a failure reason when the mirror is in the failed phase. The PutHandler maps unknown disk id and inactive mirror to 404 so management layers can distinguish operator errors from server faults. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- fuzz/fuzz_targets/http_api.rs | 4 ++ vmm/src/api/http/http_endpoint.rs | 18 +++-- vmm/src/api/http/mod.rs | 10 ++- vmm/src/api/mod.rs | 81 +++++++++++++++++++++++ vmm/src/api/openapi/cloud-hypervisor.yaml | 55 +++++++++++++++ vmm/src/lib.rs | 19 +++++- vmm/src/vm.rs | 3 + 7 files changed, 181 insertions(+), 9 deletions(-) diff --git a/fuzz/fuzz_targets/http_api.rs b/fuzz/fuzz_targets/http_api.rs index 033aff80f..96129f48f 100644 --- a/fuzz/fuzz_targets/http_api.rs +++ b/fuzz/fuzz_targets/http_api.rs @@ -117,6 +117,10 @@ impl RequestHandler for StubApiRequestHandler { Ok(()) } + fn vm_disk_mirror_status(&mut self, _: String) -> Result>, VmError> { + Ok(None) + } + #[cfg(target_arch = "x86_64")] fn vm_coredump(&mut self, _: &str) -> Result<(), VmError> { Ok(()) diff --git a/vmm/src/api/http/http_endpoint.rs b/vmm/src/api/http/http_endpoint.rs index ef07a17ab..91ef6337f 100644 --- a/vmm/src/api/http/http_endpoint.rs +++ b/vmm/src/api/http/http_endpoint.rs @@ -48,10 +48,10 @@ use crate::api::http::{EndpointHandler, HttpError, error_response}; use crate::api::{ AddDisk, ApiAction, ApiError, ApiRequest, NetConfig, VmAddDevice, VmAddFs, VmAddGenericVhostUser, VmAddNet, VmAddPmem, VmAddUserDevice, VmAddVdpa, VmAddVsock, VmBoot, - VmCancelMigration, VmConfig, VmCounters, VmDelete, VmDiskMirrorStart, VmMigrationProgress, - VmNmi, VmPause, VmPostMigrationAnnounce, VmPowerButton, VmReboot, VmReceiveMigration, - VmReceiveMigrationData, VmRemoveDevice, VmResize, VmResizeDisk, VmResizeZone, VmRestore, - VmResume, VmSendMigration, VmShutdown, VmSnapshot, + VmCancelMigration, VmConfig, VmCounters, VmDelete, VmDiskMirrorStart, VmDiskMirrorStatus, + VmMigrationProgress, VmNmi, VmPause, VmPostMigrationAnnounce, VmPowerButton, VmReboot, + VmReceiveMigration, VmReceiveMigrationData, VmRemoveDevice, VmResize, VmResizeDisk, + VmResizeZone, VmRestore, VmResume, VmSendMigration, VmShutdown, VmSnapshot, }; use crate::config::RestoreConfig; use crate::cpu::Error as CpuError; @@ -533,6 +533,16 @@ vm_action_put_handler_body!(VmDiskMirrorStart, |e| match &e { _ => HttpError::ApiError(e), }); +vm_action_put_handler_body!(VmDiskMirrorStatus, |e| match &e { + ApiError::VmDiskMirrorStatus(VmError::DeviceManager(DeviceManagerError::UnknownDeviceId( + _, + ))) => HttpError::NotFound, + ApiError::VmDiskMirrorStatus(VmError::DeviceManager( + DeviceManagerError::BlockMirrorNotActive(_), + )) => HttpError::NotFound, + _ => HttpError::ApiError(e), +}); + impl PutHandler for VmResize { fn handle_request( &'static self, diff --git a/vmm/src/api/http/mod.rs b/vmm/src/api/http/mod.rs index e1ed29ea5..e21f630d4 100644 --- a/vmm/src/api/http/mod.rs +++ b/vmm/src/api/http/mod.rs @@ -30,9 +30,9 @@ use crate::api::VmCoredump; use crate::api::{ AddDisk, ApiError, ApiRequest, VmAddDevice, VmAddFs, VmAddGenericVhostUser, VmAddNet, VmAddPmem, VmAddUserDevice, VmAddVdpa, VmAddVsock, VmBoot, VmCancelMigration, VmCounters, - VmDelete, VmDiskMirrorStart, VmMigrationProgress, VmNmi, VmPause, VmPostMigrationAnnounce, - VmPowerButton, VmReboot, VmReceiveMigration, VmRemoveDevice, VmResize, VmResizeDisk, - VmResizeZone, VmRestore, VmResume, VmSendMigration, VmShutdown, VmSnapshot, + VmDelete, VmDiskMirrorStart, VmDiskMirrorStatus, VmMigrationProgress, VmNmi, VmPause, + VmPostMigrationAnnounce, VmPowerButton, VmReboot, VmReceiveMigration, VmRemoveDevice, VmResize, + VmResizeDisk, VmResizeZone, VmRestore, VmResume, VmSendMigration, VmShutdown, VmSnapshot, }; use crate::landlock::Landlock; use crate::seccomp_filters::{Thread, get_seccomp_filter}; @@ -238,6 +238,10 @@ pub static HTTP_ROUTES: LazyLock = LazyLock::new(|| { endpoint!("/vm.disk-mirror-start"), Box::new(VmActionHandler::new(&VmDiskMirrorStart)), ); + r.routes.insert( + endpoint!("/vm.disk-mirror-status"), + Box::new(VmActionHandler::new(&VmDiskMirrorStatus)), + ); r.routes.insert(endpoint!("/vm.info"), Box::new(VmInfo {})); r.routes.insert( endpoint!("/vm.pause"), diff --git a/vmm/src/api/mod.rs b/vmm/src/api/mod.rs index 471cfbb34..4628b8fd1 100644 --- a/vmm/src/api/mod.rs +++ b/vmm/src/api/mod.rs @@ -40,6 +40,7 @@ use std::str::FromStr; use std::sync::mpsc::{RecvError, SendError, Sender, channel}; use std::time::Duration; +use block::mirror::{MirrorPhase, MirrorStatus}; use log::{info, trace}; use micro_http::Body; use option_parser::{OptionParser, OptionParserError, Toggle}; @@ -153,6 +154,9 @@ pub enum ApiError { #[error("Error starting disk mirror")] VmDiskMirrorStart(#[source] VmError), + #[error("Error reading disk mirror state")] + VmDiskMirrorStatus(#[source] VmError), + /// The memory zone could not be resized. #[error("The memory zone could not be resized")] VmResizeZone(#[source] VmError), @@ -245,6 +249,52 @@ pub struct VmDiskMirrorStartData { pub destination_path: PathBuf, } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct VmDiskMirrorStatusData { + pub id: String, +} + +/// Wire form of [`MirrorPhase`], without the failure reason payload, +/// which the response carries separately in `failure`. +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum VmDiskMirrorPhase { + Running, + Ready, + Completing, + Completed, + Cancelling, + Failed, +} + +#[derive(Clone, Debug, Serialize)] +pub struct VmDiskMirrorStatusResponse { + pub phase: VmDiskMirrorPhase, + pub copied_bytes: u64, + pub total_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub failure: Option, +} + +impl From for VmDiskMirrorStatusResponse { + fn from(status: MirrorStatus) -> Self { + let (phase, failure) = match status.phase { + MirrorPhase::Running => (VmDiskMirrorPhase::Running, None), + MirrorPhase::Ready => (VmDiskMirrorPhase::Ready, None), + MirrorPhase::Cancelling => (VmDiskMirrorPhase::Cancelling, None), + MirrorPhase::Failed(reason) => (VmDiskMirrorPhase::Failed, Some(reason.to_string())), + MirrorPhase::Completing => (VmDiskMirrorPhase::Completing, None), + MirrorPhase::Completed => (VmDiskMirrorPhase::Completed, None), + }; + Self { + phase, + copied_bytes: status.copied_bytes, + total_bytes: status.total_bytes, + failure, + } + } +} + #[derive(Clone, Deserialize, Serialize)] pub struct VmmPingResponse { pub build_version: String, @@ -768,6 +818,8 @@ pub trait RequestHandler { destination_path: PathBuf, ) -> Result<(), VmError>; + fn vm_disk_mirror_status(&mut self, id: String) -> Result>, VmError>; + fn vm_add_device(&mut self, device_cfg: DeviceConfig) -> Result>, VmError>; fn vm_add_user_device( @@ -1424,6 +1476,35 @@ impl ApiAction for VmDiskMirrorStart { } } +pub struct VmDiskMirrorStatus; +impl ApiAction for VmDiskMirrorStatus { + type RequestBody = VmDiskMirrorStatusData; + type ResponseBody = Option; + + fn request(&self, data: Self::RequestBody, response_sender: Sender) -> ApiRequest { + Box::new(move |vmm| { + let response = vmm + .vm_disk_mirror_status(data.id) + .map_err(ApiError::VmDiskMirrorStatus) + .map(ApiResponsePayload::VmAction); + + response_sender + .send(response) + .map_err(VmmError::ApiResponseSend)?; + Ok(false) + }) + } + + fn send( + &self, + api_evt: EventFd, + api_sender: Sender, + data: Self::RequestBody, + ) -> ApiResult { + get_response_body(self, api_evt, api_sender, data) + } +} + pub struct VmInfo; impl ApiAction for VmInfo { diff --git a/vmm/src/api/openapi/cloud-hypervisor.yaml b/vmm/src/api/openapi/cloud-hypervisor.yaml index 8fa586289..a4280eda9 100644 --- a/vmm/src/api/openapi/cloud-hypervisor.yaml +++ b/vmm/src/api/openapi/cloud-hypervisor.yaml @@ -530,6 +530,28 @@ paths: 500: description: Disk mirroring could not be started. + /vm.disk-mirror-status: + put: + summary: Query the status of a disk mirror + requestBody: + description: The identifier of the mirrored disk + content: + application/json: + schema: + $ref: "#/components/schemas/VmDiskMirrorStatusData" + required: true + responses: + 200: + description: The current status of the disk mirror. + content: + application/json: + schema: + $ref: "#/components/schemas/VmDiskMirrorStatusResponse" + 404: + description: No disk with the given identifier was found, or no mirror is active for it. + 500: + description: The disk mirror status could not be retrieved. + components: schemas: VmmPingResponse: @@ -1595,3 +1617,36 @@ components: type: string destination_path: type: string + + VmDiskMirrorStatusData: + required: + - id + type: object + properties: + id: + type: string + + VmDiskMirrorStatusResponse: + required: + - phase + - copied_bytes + - total_bytes + type: object + properties: + phase: + type: string + enum: + - running + - ready + - completing + - completed + - cancelling + - failed + copied_bytes: + type: integer + format: int64 + total_bytes: + type: integer + format: int64 + failure: + type: string diff --git a/vmm/src/lib.rs b/vmm/src/lib.rs index 4d2a10815..9024f01f8 100644 --- a/vmm/src/lib.rs +++ b/vmm/src/lib.rs @@ -62,8 +62,8 @@ use vmm_sys_util::signal::unblock_signal; use vmm_sys_util::sock_ctrl_msg::ScmSocket; use crate::api::{ - ApiRequest, ApiResponse, RequestHandler, TimeoutStrategy, VmInfoResponse, - VmReceiveMigrationData, VmSendMigrationData, VmmPingResponse, + ApiRequest, ApiResponse, RequestHandler, TimeoutStrategy, VmDiskMirrorStatusResponse, + VmInfoResponse, VmReceiveMigrationData, VmSendMigrationData, VmmPingResponse, }; use crate::config::{MemoryRestoreMode, RestoreConfig, add_to_config}; #[cfg(all(target_arch = "x86_64", feature = "guest_debug"))] @@ -3503,6 +3503,21 @@ impl RequestHandler for Vmm { MaybeVmOwnership::None => Err(VmError::DiskMirrorStart), } } + + fn vm_disk_mirror_status(&mut self, id: String) -> result::Result>, VmError> { + self.vm_config.as_ref().ok_or(VmError::VmNotCreated)?; + + match self.vm { + MaybeVmOwnership::Vmm(ref vm) => { + let status = vm.mirror_disk_status(&id)?; + let response: VmDiskMirrorStatusResponse = status.into(); + let json = serde_json::to_vec(&response).map_err(|_| VmError::DiskMirrorStatus)?; + Ok(Some(json)) + } + MaybeVmOwnership::Migration(_) => Err(VmError::VmMigrating), + MaybeVmOwnership::None => Err(VmError::DiskMirrorStatus), + } + } } const CPU_MANAGER_SNAPSHOT_ID: &str = "cpu-manager"; diff --git a/vmm/src/vm.rs b/vmm/src/vm.rs index 8a560409f..83144bf05 100644 --- a/vmm/src/vm.rs +++ b/vmm/src/vm.rs @@ -274,6 +274,9 @@ pub enum Error { #[error("Failed to start disk mirror")] DiskMirrorStart, + #[error("Failed to read disk mirror state")] + DiskMirrorStatus, + #[error("Cannot activate virtio devices")] ActivateVirtioDevices(#[source] DeviceManagerError), From 6de6058f13ce4b5ddb4c6b4370d7a02bc150ba44 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Thu, 30 Apr 2026 16:52:21 +0200 Subject: [PATCH 16/40] block: implement MirroringAsyncIo::submit_batch_requests The virtio-blk queue worker calls submit_batch_requests unconditionally on the disk image, ignoring batch_requests_enabled. The previous stub panicked, which crashed the VM as soon as a mirrored disk processed a batched read or write. Dispatch In and Out to the existing read_vectored and write_vectored methods, which already fan out to source and destination. Other request types do not reach this path under the current request pipeline. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/mirror.rs | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/block/src/mirror.rs b/block/src/mirror.rs index c81dd7530..db28d340d 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -22,11 +22,11 @@ use thiserror::Error; use vmm_sys_util::eventfd::EventFd; use vmm_sys_util::poll::PollContext; -use crate::BatchRequest; use crate::async_io::{AsyncIo, AsyncIoError, AsyncIoResult}; use crate::disk_file::AsyncFullDiskFile; use crate::error::{BlockErrorKind, BlockResult}; use crate::qcow_common::AlignedBuf; +use crate::{BatchRequest, RequestType}; /// Block size for the copy worker, in which it copies data from /// source to destination and holds the range lock. @@ -479,11 +479,27 @@ impl AsyncIo for MirroringAsyncIo { } fn batch_requests_enabled(&self) -> bool { - false - } - - fn submit_batch_requests(&mut self, _batch_request: &[BatchRequest]) -> AsyncIoResult<()> { - unimplemented!("Batch requests are not supported in MirroringAsyncIo") + true + } + + fn submit_batch_requests(&mut self, batch_request: &[BatchRequest]) -> AsyncIoResult<()> { + for req in batch_request { + let result = match req.request_type { + RequestType::In => self.read_vectored(req.offset, &req.iovecs, req.user_data), + RequestType::Out => self.write_vectored(req.offset, &req.iovecs, req.user_data), + // Only In and Out are batched, see request.rs. + _ => unreachable!("Unexpected batch request type: {:?}", req.request_type), + }; + + // Push partial batch error to completions, vectored op has not + // pushed it to the inflight_completions queue. + if result.is_err() { + self.inflight_completions + .push_back((req.user_data, -libc::EIO)); + let _ = self.source.io().notifier().write(1); + } + } + Ok(()) } fn alignment(&self) -> u64 { From 1dc4d39e78aaaf230d15b707bbd3ee8036115d14 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Sun, 3 May 2026 14:02:35 +0200 Subject: [PATCH 17/40] virtio-devices: drain mirror wrapper before disk_image swap A virtqueue worker may still hold source and destination write-pairs when complete or cancel arrives. Swapping the wrapper out at that moment would orphan the pending completions, leaving the guest waiting on writes that will never be acked. Stage the incoming BlockQueueCommand in pending_block_queue_command and apply it only once the handler reports no in-flight requests. The submit path is gated for the duration of the drain, otherwise sustained guest writes would keep the in-flight count from ever reaching zero. After applying the command the worker sends the acknowledgement and processes the avail ring directly: while the command was pending, QUEUE_AVAIL_EVENT handling consumed the guest's kicks without submitting, and the guest will not kick again for descriptors it already queued. The same protocol is reused by the upcoming complete and cancel endpoints. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- virtio-devices/src/block.rs | 76 +++++++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/virtio-devices/src/block.rs b/virtio-devices/src/block.rs index a97d97ec4..b7a1e544e 100644 --- a/virtio-devices/src/block.rs +++ b/virtio-devices/src/block.rs @@ -198,6 +198,9 @@ pub struct BlockQueueCommandReceiver { /// Wakes the worker after `cmd` is filled. Fires `BLOCK_COMMAND_EVENT` /// on the worker's epoll set. pub evt: EventFd, + /// Command taken from `cmd` and held until `disk_image` reports no + /// in-flight requests. Owned and accessed only by the worker. + pending_block_queue_command: Option, } /// API-thread handles used to stage and signal commands for one virtqueue. @@ -345,7 +348,15 @@ impl BlockEpollHandler { if draining_active_requests.load(Ordering::SeqCst) { return Ok(()); } - + // Defer submitting new descriptors while a mirror swap is draining. + // The queue_evt is kicked at the end of the swap. + if self + .cmd_receiver + .as_ref() + .is_some_and(|m| m.pending_block_queue_command.is_some()) + { + return Ok(()); + } let queue = &mut self.queue; let queue_size = queue.size(); let mut batch_requests = Vec::new(); @@ -598,6 +609,46 @@ impl BlockEpollHandler { Ok(()) } + /// Applies a pending mirror update if one is staged and the current + /// `disk_image` has no in-flight requests. Returns `Ok(())` without + /// changes when either condition is not met. The next completion + /// event will trigger another attempt. + fn try_apply_pending_block_queue_command( + &mut self, + helper: &mut EpollHelper, + ) -> result::Result<(), EpollHelperError> { + // If any disk requests are in flight, we can't apply the pending command. + if !self.inflight_requests.is_empty() { + return Ok(()); + } + + let Some(cmd_receiver) = self.cmd_receiver.as_mut() else { + return Ok(()); + }; + + let Some(command) = cmd_receiver.pending_block_queue_command.take() else { + return Ok(()); + }; + + let ack = command.ack.clone(); + + let result = Self::apply_block_queue_command(&mut self.disk_image, command, helper); + + let _ = ack.send(BlockQueueAck { result }); + + // While the command was pending, QUEUE_AVAIL_EVENT handling consumed the + // guest's kicks without submitting (see the guard in process_queue_submit). + // The guest won't kick again for descriptors it already queued, so process + // the avail ring now, whether the command succeeded or failed, or those + // requests stall until unrelated guest I/O arrives. + let rate_limit_reached = self.rate_limiter.as_ref().is_some_and(|r| r.is_blocked()); + if !rate_limit_reached { + self.process_queue_submit_and_signal()?; + } + + Ok(()) + } + #[inline] fn find_inflight_request(&mut self, completed_head: u16) -> Result { // This loop neatly handles the fast path where the completions are @@ -861,6 +912,7 @@ impl EpollHelperHandler for BlockEpollHandler { if !rate_limit_reached { self.process_queue_submit_and_signal()?; } + self.try_apply_pending_block_queue_command(helper)?; } RATE_LIMITER_EVENT => { if let Some(rate_limiter) = &mut self.rate_limiter { @@ -880,22 +932,23 @@ impl EpollHelperHandler for BlockEpollHandler { } } BLOCK_COMMAND_EVENT => { - // Apply a staged command: swap disk_image and re-register notifiers. - if let Some(m) = self.cmd_receiver.as_mut() { - m.evt.read().map_err(|e| { + if let Some(cmd_receiver) = self.cmd_receiver.as_mut() { + cmd_receiver.evt.read().map_err(|e| { EpollHelperError::HandleEvent(anyhow!( "Failed to read block command event: {e:?}" )) })?; - if let Some(command) = m.cmd.lock().unwrap().take() { - Self::apply_block_queue_command(&mut self.disk_image, command, helper) - .map_err(|e| { - EpollHelperError::HandleEvent(anyhow!( - "Failed to apply block queue command: {e}" - )) - })?; + if let Some(update) = cmd_receiver.cmd.lock().unwrap().take() + && let Some(stale) = + cmd_receiver.pending_block_queue_command.replace(update) + { + warn!( + "Replacing pending block queue command {:?} before it was applied", + stale.kind + ); } } + self.try_apply_pending_block_queue_command(helper)?; } _ => { return Err(EpollHelperError::HandleEvent(anyhow!( @@ -1575,6 +1628,7 @@ impl VirtioDevice for Block { let cmd_receiver = BlockQueueCommandReceiver { cmd: Arc::clone(&queue_command), evt: mirror_handler_evt, + pending_block_queue_command: None, }; self.queue_cmd_senders.push(BlockQueueCommandSender { cmd: queue_command, From 1f43114113845c466263afb6ce0da0c829bbb2dc Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Sun, 3 May 2026 14:09:32 +0200 Subject: [PATCH 18/40] virtio-devices, block: add Block::complete_mirror After the copy worker reports the mirror ready, the operator needs to switch the device to the destination disk so the source can be detached. Completion is allowed from MirrorPhase `Ready`, or from `Completing` as a retry. A plain destination AsyncIo per virtqueue is pre-built before any command is sent, so a create_async_io failure leaves the device unchanged and the operator can retry. The state transitions to `Completing` before the first CompleteToDestination command goes out: from that point a queue may already write to the destination only, so the source stops being a safe fallback and cancel is no longer allowed. The drain protocol from the previous commit makes each worker finish its in-flight pairings before swapping. Failures of the command send or the acknowledgement wait panic instead of returning an error. A partial completion splits the queues between destination-only writers and source readers, which can serve stale reads to the guest. There is no revert that does not lose acknowledged writes, so we prefer the panic. After all acknowledgements the state becomes `Completed`, the copy worker is joined, and the control plane disk_image is replaced by the destination. Two BlockErrorKind variants cover the operator-visible preconditions. Device manager and REST plumbing follow. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/error.rs | 6 +++ virtio-devices/src/block.rs | 73 +++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/block/src/error.rs b/block/src/error.rs index 439929445..2bfffc301 100644 --- a/block/src/error.rs +++ b/block/src/error.rs @@ -29,6 +29,12 @@ pub enum MirrorError { /// A mirror operation was requested before the device was activated. #[error("Mirror operation rejected: the device is not active")] DeviceNotActive, + /// A mirror operation was requested but no mirror is active for the device. + #[error("No active mirror for the device")] + NotActive, + /// A completion was requested but the mirror has not reached the ready phase. + #[error("Mirror is not yet ready, cannot complete")] + NotReady, /// A mirror swap was requested but was unsuccessful. #[error("Failed to swap AsyncIO in virtqueue worker for mirror")] Swap, diff --git a/virtio-devices/src/block.rs b/virtio-devices/src/block.rs index b7a1e544e..02562eb55 100644 --- a/virtio-devices/src/block.rs +++ b/virtio-devices/src/block.rs @@ -1419,6 +1419,79 @@ impl Block { Ok(()) } + /// Switch the device's mirroring wrapper to the destination disk. + /// + /// Each virtqueue worker swaps its [`MirroringAsyncIo`] for a plain + /// [`AsyncIo`] on the destination through the same slot and eventfd + /// mechanism used to install the mirror. After this call the source + /// disk is no longer used by the VM and the operator can detach or + /// remove it. + /// + /// Returns [`MirrorError::NotActive`] when no mirror is active for the + /// device, and [`MirrorError::NotReady`] when the copy worker has not yet + /// reported the ready phase or the mirror has since failed. Both errors + /// return before any queue command is sent, so the mirror handle is left in + /// place and the caller can poll the state and retry. + /// + /// # Panics + /// + /// Panics if a queue command cannot be sent or acknowledged after the + /// switch-over has started. At that point some queues may already write + /// to the destination only, and there is no revert that keeps + /// acknowledged writes, so aborting is preferred over data loss. + pub fn complete_mirror(&mut self) -> BlockResult { + let handle = self + .mirror_handle + .as_ref() + .ok_or_else(|| BlockError::from_kind(BlockErrorKind::Mirror(MirrorError::NotActive)))?; + + // Only allow completing when the copy worker is in the ready phase. + if !matches!(handle.state.phase(), MirrorPhase::Ready) { + return Err(BlockError::from_kind(BlockErrorKind::Mirror( + MirrorError::NotReady, + ))); + } + + let (commands, ack_rx) = self.create_mirror_queue_commands( + BlockQueueCommandKind::CompleteToDestination, + |ring_depth| handle.destination.create_async_io(ring_depth), + )?; + + // A concurrent destination failure may have moved the mirror to + // Failed since the phase guard above. Confirm Completing took effect + // before sending any command, otherwise we would swap the device + // onto a failed mirror. + handle.state.transition_to_phase(MirrorPhase::Completing); + if !matches!(handle.state.phase(), MirrorPhase::Completing) { + return Err(BlockError::from_kind(BlockErrorKind::Mirror( + MirrorError::NotReady, + ))); + } + + // Once the first command is sent a queue may write to the destination + // only, so a partial switch-over has no safe revert. We panic rather + // than risk losing acknowledged writes. + Self::send_mirror_queue_commands(commands).expect("mirror queue commands sent"); + Self::wait_for_mirror_queue_command_acks(&ack_rx, self.queue_cmd_senders.len()) + .expect("mirror queue command acks received"); + handle.state.transition_to_phase(MirrorPhase::Completed); + + // Pre-build succeeded, own the destination now and commit the completion. + let BlockMirrorHandle { + destination, + destination_path, + copy_worker, + state: _, + } = self.mirror_handle.take().unwrap(); + if let Err(e) = copy_worker.join() { + error!("copy worker thread panicked: {e:?}"); + } + + self.disk_image = destination; + self.disk_path = destination_path.clone(); + Ok(destination_path) + } + fn mirror_swap_error(error: MirrorSwapError) -> BlockError { BlockError::new(BlockErrorKind::Mirror(MirrorError::Swap), error) } From c66101b69f2e4723252abeac76f7fed2db4647ef Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Sun, 3 May 2026 14:35:51 +0200 Subject: [PATCH 19/40] vmm: add vm.disk-mirror-complete REST endpoint Operators trigger the complete stage of blockdev-mirroring through this entrypoint. The endpoint switches the device to the destination disk after the copy worker reports the mirror ready. The PutHandler maps device manager errors to HTTP status codes so management layers can distinguish operator errors (404 for unknown disk or no active mirror, 400 for not-yet-ready) from server faults. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- fuzz/fuzz_targets/http_api.rs | 4 +++ vmm/src/api/http/http_endpoint.rs | 23 ++++++++++--- vmm/src/api/http/mod.rs | 11 +++++-- vmm/src/api/mod.rs | 39 +++++++++++++++++++++++ vmm/src/api/openapi/cloud-hypervisor.yaml | 28 ++++++++++++++++ vmm/src/device_manager.rs | 37 +++++++++++++++++++++ vmm/src/lib.rs | 10 ++++++ vmm/src/vm.rs | 13 ++++++++ 8 files changed, 158 insertions(+), 7 deletions(-) diff --git a/fuzz/fuzz_targets/http_api.rs b/fuzz/fuzz_targets/http_api.rs index 96129f48f..cf7728147 100644 --- a/fuzz/fuzz_targets/http_api.rs +++ b/fuzz/fuzz_targets/http_api.rs @@ -121,6 +121,10 @@ impl RequestHandler for StubApiRequestHandler { Ok(None) } + fn vm_disk_mirror_complete(&mut self, _: String) -> Result<(), VmError> { + Ok(()) + } + #[cfg(target_arch = "x86_64")] fn vm_coredump(&mut self, _: &str) -> Result<(), VmError> { Ok(()) diff --git a/vmm/src/api/http/http_endpoint.rs b/vmm/src/api/http/http_endpoint.rs index 91ef6337f..d53e495c7 100644 --- a/vmm/src/api/http/http_endpoint.rs +++ b/vmm/src/api/http/http_endpoint.rs @@ -37,6 +37,7 @@ use std::fs::File; use std::sync::mpsc::Sender; +use block::error::{BlockErrorKind, MirrorError}; use log::info; use micro_http::{Body, Method, Request, Response, StatusCode, Version}; use vmm_sys_util::eventfd::EventFd; @@ -48,10 +49,10 @@ use crate::api::http::{EndpointHandler, HttpError, error_response}; use crate::api::{ AddDisk, ApiAction, ApiError, ApiRequest, NetConfig, VmAddDevice, VmAddFs, VmAddGenericVhostUser, VmAddNet, VmAddPmem, VmAddUserDevice, VmAddVdpa, VmAddVsock, VmBoot, - VmCancelMigration, VmConfig, VmCounters, VmDelete, VmDiskMirrorStart, VmDiskMirrorStatus, - VmMigrationProgress, VmNmi, VmPause, VmPostMigrationAnnounce, VmPowerButton, VmReboot, - VmReceiveMigration, VmReceiveMigrationData, VmRemoveDevice, VmResize, VmResizeDisk, - VmResizeZone, VmRestore, VmResume, VmSendMigration, VmShutdown, VmSnapshot, + VmCancelMigration, VmConfig, VmCounters, VmDelete, VmDiskMirrorComplete, VmDiskMirrorStart, + VmDiskMirrorStatus, VmMigrationProgress, VmNmi, VmPause, VmPostMigrationAnnounce, + VmPowerButton, VmReboot, VmReceiveMigration, VmReceiveMigrationData, VmRemoveDevice, VmResize, + VmResizeDisk, VmResizeZone, VmRestore, VmResume, VmSendMigration, VmShutdown, VmSnapshot, }; use crate::config::RestoreConfig; use crate::cpu::Error as CpuError; @@ -543,6 +544,20 @@ vm_action_put_handler_body!(VmDiskMirrorStatus, |e| match &e { _ => HttpError::ApiError(e), }); +vm_action_put_handler_body!(VmDiskMirrorComplete, |e| match &e { + ApiError::VmDiskMirrorComplete(VmError::DeviceManager( + DeviceManagerError::UnknownDeviceId(_), + )) => HttpError::NotFound, + ApiError::VmDiskMirrorComplete(VmError::DeviceManager( + DeviceManagerError::BlockMirrorComplete(_, block_err), + )) => match block_err.kind() { + BlockErrorKind::Mirror(MirrorError::NotActive) => HttpError::NotFound, + BlockErrorKind::Mirror(MirrorError::NotReady) => HttpError::BadRequest, + _ => HttpError::ApiError(e), + }, + _ => HttpError::ApiError(e), +}); + impl PutHandler for VmResize { fn handle_request( &'static self, diff --git a/vmm/src/api/http/mod.rs b/vmm/src/api/http/mod.rs index e21f630d4..ed28b2f59 100644 --- a/vmm/src/api/http/mod.rs +++ b/vmm/src/api/http/mod.rs @@ -30,9 +30,10 @@ use crate::api::VmCoredump; use crate::api::{ AddDisk, ApiError, ApiRequest, VmAddDevice, VmAddFs, VmAddGenericVhostUser, VmAddNet, VmAddPmem, VmAddUserDevice, VmAddVdpa, VmAddVsock, VmBoot, VmCancelMigration, VmCounters, - VmDelete, VmDiskMirrorStart, VmDiskMirrorStatus, VmMigrationProgress, VmNmi, VmPause, - VmPostMigrationAnnounce, VmPowerButton, VmReboot, VmReceiveMigration, VmRemoveDevice, VmResize, - VmResizeDisk, VmResizeZone, VmRestore, VmResume, VmSendMigration, VmShutdown, VmSnapshot, + VmDelete, VmDiskMirrorComplete, VmDiskMirrorStart, VmDiskMirrorStatus, VmMigrationProgress, + VmNmi, VmPause, VmPostMigrationAnnounce, VmPowerButton, VmReboot, VmReceiveMigration, + VmRemoveDevice, VmResize, VmResizeDisk, VmResizeZone, VmRestore, VmResume, VmSendMigration, + VmShutdown, VmSnapshot, }; use crate::landlock::Landlock; use crate::seccomp_filters::{Thread, get_seccomp_filter}; @@ -242,6 +243,10 @@ pub static HTTP_ROUTES: LazyLock = LazyLock::new(|| { endpoint!("/vm.disk-mirror-status"), Box::new(VmActionHandler::new(&VmDiskMirrorStatus)), ); + r.routes.insert( + endpoint!("/vm.disk-mirror-complete"), + Box::new(VmActionHandler::new(&VmDiskMirrorComplete)), + ); r.routes.insert(endpoint!("/vm.info"), Box::new(VmInfo {})); r.routes.insert( endpoint!("/vm.pause"), diff --git a/vmm/src/api/mod.rs b/vmm/src/api/mod.rs index 4628b8fd1..3192fd127 100644 --- a/vmm/src/api/mod.rs +++ b/vmm/src/api/mod.rs @@ -157,6 +157,9 @@ pub enum ApiError { #[error("Error reading disk mirror state")] VmDiskMirrorStatus(#[source] VmError), + #[error("Error completing disk mirror")] + VmDiskMirrorComplete(#[source] VmError), + /// The memory zone could not be resized. #[error("The memory zone could not be resized")] VmResizeZone(#[source] VmError), @@ -254,6 +257,11 @@ pub struct VmDiskMirrorStatusData { pub id: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct VmDiskMirrorCompleteData { + pub id: String, +} + /// Wire form of [`MirrorPhase`], without the failure reason payload, /// which the response carries separately in `failure`. #[derive(Clone, Copy, Debug, Serialize)] @@ -819,6 +827,7 @@ pub trait RequestHandler { ) -> Result<(), VmError>; fn vm_disk_mirror_status(&mut self, id: String) -> Result>, VmError>; + fn vm_disk_mirror_complete(&mut self, id: String) -> Result<(), VmError>; fn vm_add_device(&mut self, device_cfg: DeviceConfig) -> Result>, VmError>; @@ -1505,6 +1514,36 @@ impl ApiAction for VmDiskMirrorStatus { } } +pub struct VmDiskMirrorComplete; + +impl ApiAction for VmDiskMirrorComplete { + type RequestBody = VmDiskMirrorCompleteData; + type ResponseBody = Option; + + fn request(&self, data: Self::RequestBody, response_sender: Sender) -> ApiRequest { + Box::new(move |vmm| { + let response = vmm + .vm_disk_mirror_complete(data.id) + .map_err(ApiError::VmDiskMirrorComplete) + .map(|_| ApiResponsePayload::Empty); + + response_sender + .send(response) + .map_err(VmmError::ApiResponseSend)?; + Ok(false) + }) + } + + fn send( + &self, + api_evt: EventFd, + api_sender: Sender, + data: Self::RequestBody, + ) -> ApiResult { + get_response_body(self, api_evt, api_sender, data) + } +} + pub struct VmInfo; impl ApiAction for VmInfo { diff --git a/vmm/src/api/openapi/cloud-hypervisor.yaml b/vmm/src/api/openapi/cloud-hypervisor.yaml index a4280eda9..b6640fe0f 100644 --- a/vmm/src/api/openapi/cloud-hypervisor.yaml +++ b/vmm/src/api/openapi/cloud-hypervisor.yaml @@ -552,6 +552,26 @@ paths: 500: description: The disk mirror status could not be retrieved. + /vm.disk-mirror-complete: + put: + summary: Complete a disk mirror and switch to the destination + requestBody: + description: The identifier of the mirrored disk + content: + application/json: + schema: + $ref: "#/components/schemas/VmDiskMirrorCompleteData" + required: true + responses: + 204: + description: The disk mirror was completed and the device now uses the destination. + 400: + description: The mirror is not ready to complete. + 404: + description: No disk with the given identifier was found, or no mirror is active for it. + 500: + description: The disk mirror could not be completed. + components: schemas: VmmPingResponse: @@ -1650,3 +1670,11 @@ components: format: int64 failure: type: string + + VmDiskMirrorCompleteData: + required: + - id + type: object + properties: + id: + type: string diff --git a/vmm/src/device_manager.rs b/vmm/src/device_manager.rs index 5d554c3da..bfcbb8511 100644 --- a/vmm/src/device_manager.rs +++ b/vmm/src/device_manager.rs @@ -690,6 +690,9 @@ pub enum DeviceManagerError { "Failed to start block mirroring for the disk with identifier: {0} as mirroring is already active" )] BlockMirrorAlreadyActive(String), + + #[error("Failed to complete block mirror for disk {0}: {1}")] + BlockMirrorComplete(String, #[source] BlockError), } pub type DeviceManagerResult = result::Result; @@ -5415,6 +5418,40 @@ impl DeviceManager { .ok_or_else(|| DeviceManagerError::BlockMirrorNotActive(device_id.to_string())) } + /// Completes the active block mirror for the disk identified by `device_id`, + /// switching over to the destination disk. Errors if no disk with that + /// identifier is attached, if no mirror is active, or if the mirror is not + /// yet ready. + pub fn mirror_disk_complete(&self, device_id: &str) -> DeviceManagerResult<()> { + for dev in &self.block_devices { + let mut disk = dev.lock().unwrap(); + if disk.id() == device_id { + let new_path = disk.complete_mirror().map_err(|e| { + DeviceManagerError::BlockMirrorComplete(device_id.to_string(), e) + })?; + + // Repoint the config entry so a rebuild reopens the destination. + if let Some(cfg) = self + .config + .lock() + .unwrap() + .disks + .as_mut() + .and_then(|disks| { + disks + .iter_mut() + .find(|d| d.pci_common.id.as_deref() == Some(device_id)) + }) + { + cfg.path = Some(new_path); + } + + return Ok(()); + } + } + Err(DeviceManagerError::UnknownDeviceId(device_id.to_string())) + } + /// Helps the environment converge quickly after a live migration by /// prompting devices to advertise the VM from its new host. /// diff --git a/vmm/src/lib.rs b/vmm/src/lib.rs index 9024f01f8..6ed76415a 100644 --- a/vmm/src/lib.rs +++ b/vmm/src/lib.rs @@ -3518,6 +3518,16 @@ impl RequestHandler for Vmm { MaybeVmOwnership::None => Err(VmError::DiskMirrorStatus), } } + + fn vm_disk_mirror_complete(&mut self, id: String) -> result::Result<(), VmError> { + self.vm_config.as_ref().ok_or(VmError::VmNotCreated)?; + + match self.vm { + MaybeVmOwnership::Vmm(ref mut vm) => vm.mirror_disk_complete(&id), + MaybeVmOwnership::Migration(_) => Err(VmError::VmMigrating), + MaybeVmOwnership::None => Err(VmError::DiskMirrorComplete), + } + } } const CPU_MANAGER_SNAPSHOT_ID: &str = "cpu-manager"; diff --git a/vmm/src/vm.rs b/vmm/src/vm.rs index 83144bf05..94678257a 100644 --- a/vmm/src/vm.rs +++ b/vmm/src/vm.rs @@ -277,6 +277,9 @@ pub enum Error { #[error("Failed to read disk mirror state")] DiskMirrorStatus, + #[error("Failed to complete disk mirror")] + DiskMirrorComplete, + #[error("Cannot activate virtio devices")] ActivateVirtioDevices(#[source] DeviceManagerError), @@ -3330,6 +3333,16 @@ impl Vm { .map_err(Error::DeviceManager) } + /// Completes the mirror for `id` and switches to its destination. + pub fn mirror_disk_complete(&self, id: &str) -> Result<()> { + self.device_manager + .lock() + .unwrap() + .mirror_disk_complete(id) + .map_err(Error::DeviceManager)?; + Ok(()) + } + /// Calls [`DeviceManager::post_migration_announce`]. pub fn post_migration_announce(&self) { self.device_manager From e5d19f9b90084b25ef2659b6804f3fa77e9d139e Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Wed, 10 Jun 2026 14:06:55 +0200 Subject: [PATCH 20/40] virtio-devices, block: add Block::cancel_mirror Cancel reverts every virtqueue worker to a plain AsyncIo on the source disk, transitions the mirror to Cancelling state and joins the copy worker, releasing the destination disk. The copy worker now exits before the next block once the phase is terminal instead of copying the remainder. Cancel is rejected once a completion was attempted: a queue may already write to the destination only, so reverting would lose acknowledged guest writes. A guest-initiated device reset cancels an active mirror before VirtioCommon::reset tears down the virtqueue workers, which must still be alive to acknowledge the revert. The REST plumbing for cancel comes in a follow-up commit. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/error.rs | 3 ++ block/src/mirror.rs | 5 +++ virtio-devices/src/block.rs | 68 ++++++++++++++++++++++++++++++++++++- 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/block/src/error.rs b/block/src/error.rs index 2bfffc301..617d9b56f 100644 --- a/block/src/error.rs +++ b/block/src/error.rs @@ -26,6 +26,9 @@ use thiserror::Error; /// Errors reported by block mirroring operations. #[derive(Debug, Copy, Clone, Eq, PartialEq, Error)] pub enum MirrorError { + /// A mirror completion is already in progress. + #[error("Mirror completion already in progress")] + CompletionInProgress, /// A mirror operation was requested before the device was activated. #[error("Mirror operation rejected: the device is not active")] DeviceNotActive, diff --git a/block/src/mirror.rs b/block/src/mirror.rs index db28d340d..b879328d9 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -616,6 +616,11 @@ impl CopyWorker { let mut offset = 0; while offset < total_size { + // Return early on cancellation or failure. + if !matches!(self.state.phase(), MirrorPhase::Running) { + return Ok(()); + } + let length = max_length.min(total_size - offset) as usize; self.copy_block(offset, length)?; offset += length as u64; diff --git a/virtio-devices/src/block.rs b/virtio-devices/src/block.rs index 02562eb55..f33ffad33 100644 --- a/virtio-devices/src/block.rs +++ b/virtio-devices/src/block.rs @@ -1583,6 +1583,56 @@ impl Block { Self::wait_for_mirror_queue_command_acks(&ack_rx, self.queue_cmd_senders.len()) } + /// Cancel an active mirror and revert the device to the source disk. + /// + /// Transitions the mirror to [`MirrorPhase::Cancelling`] to mark that + /// cancellation has started, reverts every virtqueue worker to a plain + /// [`AsyncIo`] on the source, then joins the copy worker and releases the + /// destination. + /// + /// Returns [`MirrorError::NotActive`] when no mirror is active, and + /// [`MirrorError::CompletionInProgress`] once a completion has been + /// attempted, because a queue may already write to the destination only + /// and reverting would lose acknowledged guest writes. + /// + /// If the revert fails the mirror stays in [`MirrorPhase::Cancelling`] + /// with the handle held, so calling this again retries the revert and + /// finishes the cancellation. + /// + /// Blocks until the copy worker finishes its current block and joins, + /// which can stall on a slow or hung destination. + pub fn cancel_mirror(&mut self) -> BlockResult<()> { + let state = self + .mirror_handle + .as_ref() + .ok_or_else(|| BlockError::from_kind(BlockErrorKind::Mirror(MirrorError::NotActive)))? + .state + .clone(); + + if !matches!( + state.phase(), + MirrorPhase::Running + | MirrorPhase::Ready + | MirrorPhase::Failed(_) + | MirrorPhase::Cancelling + ) { + return Err(BlockError::from_kind(BlockErrorKind::Mirror( + MirrorError::CompletionInProgress, + ))); + } + + state.transition_to_phase(MirrorPhase::Cancelling); + self.revert_queues_to_source()?; + + if let Some(handle) = self.mirror_handle.take() + && let Err(e) = handle.copy_worker.join() + { + error!("copy worker thread panicked: {e:?}"); + } + + Ok(()) + } + /// Returns a snapshot of the current mirror progress. pub fn mirror_status(&self) -> Option { self.mirror_handle.as_ref().map(|h| h.state.status()) @@ -1596,13 +1646,18 @@ impl Block { impl Drop for Block { fn drop(&mut self) { + let mirror_handle = self.mirror_handle.take(); + if let Some(handle) = mirror_handle.as_ref() { + handle.state.transition_to_phase(MirrorPhase::Cancelling); + } + if let Some(kill_evt) = self.common.kill_evt.take() { // Ignore the result because there is nothing we can do about it. let _ = kill_evt.write(1); } self.common.wait_for_epoll_threads(); - if let Some(handle) = self.mirror_handle.take() + if let Some(handle) = mirror_handle && let Err(e) = handle.copy_worker.join() { error!("copy worker thread panicked: {e:?}"); @@ -1769,6 +1824,17 @@ impl VirtioDevice for Block { } fn reset(&mut self) { + // Cancel the copy worker without reverting the queues: reverting + // rebuilds an AsyncIo (io_setup) on this vcpu thread, which seccomp + // blocks. The queues are dropped by common.reset() and rebuilt by + // activate() anyway. + if let Some(handle) = self.mirror_handle.take() { + handle.state.transition_to_phase(MirrorPhase::Cancelling); + if let Err(e) = handle.copy_worker.join() { + error!("copy worker thread panicked: {e:?}"); + } + } + self.common.reset(); self.draining_active_requests.store(false, Ordering::SeqCst); self.active_request_count.store(0, Ordering::SeqCst); From a8879d53ee68140d70567998d06c0cc1a8f4b00b Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Wed, 10 Jun 2026 14:09:10 +0200 Subject: [PATCH 21/40] vmm: deny conflicting ops while a disk mirror runs Resizing or snapshotting the disk, removing the device, shutting down, rebooting or deleting the VM and starting a live migration all invalidate an active mirror: the destination silently falls behind or the mirror state is lost, since it is not migratable. Reject these operations while a mirror is active so the operator has to complete or cancel first. DeviceManager::active_block_mirrors lists the active mirrors and backs the new Vm::any_active_block_mirrors check. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- virtio-devices/src/block.rs | 12 ++++++++++++ vmm/src/device_manager.rs | 33 +++++++++++++++++++++++++-------- vmm/src/lib.rs | 30 +++++++++++++++++++++++++++--- vmm/src/vm.rs | 11 +++++++++++ 4 files changed, 75 insertions(+), 11 deletions(-) diff --git a/virtio-devices/src/block.rs b/virtio-devices/src/block.rs index f33ffad33..01f69852a 100644 --- a/virtio-devices/src/block.rs +++ b/virtio-devices/src/block.rs @@ -123,6 +123,8 @@ pub enum Error { ConfigChange(#[source] io::Error), #[error("Disk resize failed")] DiskResize(#[source] BlockError), + #[error("Mirror is currently active")] + MirrorActive, } pub type Result = result::Result; @@ -1313,6 +1315,10 @@ impl Block { return Err(Error::InvalidSize); } + if self.mirror_handle.is_some() { + return Err(Error::MirrorActive); + } + self.disk_image .resize(new_size) .map_err(Error::DiskResize)?; @@ -1926,6 +1932,12 @@ impl Snapshottable for Block { } fn snapshot(&mut self) -> std::result::Result { + if self.mirror_handle.is_some() { + return Err(MigratableError::Snapshot(anyhow!( + "Cannot snapshot while mirror is active" + ))); + } + Snapshot::new_from_state(&self.state()) } } diff --git a/vmm/src/device_manager.rs b/vmm/src/device_manager.rs index bfcbb8511..aa1583d08 100644 --- a/vmm/src/device_manager.rs +++ b/vmm/src/device_manager.rs @@ -691,6 +691,12 @@ pub enum DeviceManagerError { )] BlockMirrorAlreadyActive(String), + /// Cannot perform given action, as the device is currently performing a block mirroring operation. + #[error( + "Failed to perform the requested action for the disk with identifier: {0} as it is currently performing a block mirroring operation" + )] + BlockMirrorActive(String), + #[error("Failed to complete block mirror for disk {0}: {1}")] BlockMirrorComplete(String, #[source] BlockError), } @@ -4775,16 +4781,20 @@ impl DeviceManager { // Release advisory locks by dropping all references. // Linux automatically releases all locks of that file if the last open FD is closed. { - let maybe_block_device_index = self + if let Some(index) = self .block_devices .iter() - .enumerate() - .find(|(_, dev)| { - let dev = dev.lock().unwrap(); - dev.id() == id - }) - .map(|(i, _)| i); - if let Some(index) = maybe_block_device_index { + .position(|dev| dev.lock().unwrap().id() == id) + { + // Deny removal of active mirroring block device. + if self.block_devices[index] + .lock() + .unwrap() + .mirror_status() + .is_some() + { + return Err(DeviceManagerError::BlockMirrorActive(id.to_string())); + } let _ = self.block_devices.swap_remove(index); } } @@ -5494,6 +5504,13 @@ impl DeviceManager { MAX_DELAY, ); } + + /// Returns true if there is an active mirror in any of the block devices, false otherwise. + pub fn any_active_block_mirrors(&self) -> bool { + self.block_devices + .iter() + .any(|dev| dev.lock().unwrap().mirror_status().is_some()) + } } /// Starts a thread that periodically performs the post-migration announcements. diff --git a/vmm/src/lib.rs b/vmm/src/lib.rs index 6ed76415a..8c972cfb9 100644 --- a/vmm/src/lib.rs +++ b/vmm/src/lib.rs @@ -2607,6 +2607,10 @@ impl RequestHandler for Vmm { fn vm_snapshot(&mut self, destination_url: &str) -> result::Result<(), VmError> { match self.vm { MaybeVmOwnership::Vmm(ref mut vm) => { + if vm.any_active_block_mirrors() { + return Err(VmError::ActiveBlockMirror); + } + // Drain console_info so that FDs are not reused let _ = self.console_info.take(); vm.snapshot() @@ -2703,6 +2707,11 @@ impl RequestHandler for Vmm { MaybeVmOwnership::Migration(_) => return Err(VmError::VmMigrating), MaybeVmOwnership::None => return Err(VmError::VmNotRunning), }; + + if vm.any_active_block_mirrors() { + return Err(VmError::ActiveBlockMirror); + } + // Drain console_info so that the FDs are not reused let _ = self.console_info.take(); let r = vm.shutdown(); @@ -2724,6 +2733,11 @@ impl RequestHandler for Vmm { MaybeVmOwnership::Migration(_) => return Err(VmError::VmMigrating), MaybeVmOwnership::None => return Err(VmError::VmNotRunning), }; + + if vm.any_active_block_mirrors() { + return Err(VmError::ActiveBlockMirror); + } + let config = vm.get_config(); vm.shutdown()?; self.vm = MaybeVmOwnership::None; @@ -2845,7 +2859,11 @@ impl RequestHandler for Vmm { } match &self.vm { - MaybeVmOwnership::Vmm(_vm) => { + MaybeVmOwnership::Vmm(vm) => { + if vm.any_active_block_mirrors() { + return Err(VmError::ActiveBlockMirror); + } + event!("vm", "deleted"); // If a VM is booted, we first try to shut it down. @@ -3351,8 +3369,14 @@ impl RequestHandler for Vmm { .context("Invalid send migration configuration") .map_err(MigratableError::MigrateSend)?; - match self.vm { - MaybeVmOwnership::Vmm(_) => (), + match &self.vm { + MaybeVmOwnership::Vmm(vm) => { + if vm.any_active_block_mirrors() { + return Err(MigratableError::MigrateSend(anyhow!( + "Cannot start migration with active disk mirrors" + ))); + } + } MaybeVmOwnership::Migration(_) => { return Err(MigratableError::MigrateSend(anyhow!( "There is already an ongoing migration" diff --git a/vmm/src/vm.rs b/vmm/src/vm.rs index 94678257a..243690914 100644 --- a/vmm/src/vm.rs +++ b/vmm/src/vm.rs @@ -280,6 +280,9 @@ pub enum Error { #[error("Failed to complete disk mirror")] DiskMirrorComplete, + #[error("At least one disk mirror is active")] + ActiveBlockMirror, + #[error("Cannot activate virtio devices")] ActivateVirtioDevices(#[source] DeviceManagerError), @@ -3343,6 +3346,14 @@ impl Vm { Ok(()) } + /// Returns true if there is an active mirror in any of the block devices, false otherwise. + pub fn any_active_block_mirrors(&self) -> bool { + self.device_manager + .lock() + .unwrap() + .any_active_block_mirrors() + } + /// Calls [`DeviceManager::post_migration_announce`]. pub fn post_migration_announce(&self) { self.device_manager From ee56decb89b793bdda96be0efa5eb203b86fbff2 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Thu, 23 Jul 2026 15:06:48 +0200 Subject: [PATCH 22/40] block: use source passthrough after mirror failure The virtqueue worker only owns the active AsyncIo and cannot create a plain source backend after a mirror failure. `Block` retains the source disk and must coordinate the backend switch across all virtqueues. Until then, we keep the failed MirroringAsyncIo in source passthrough. Requests bypass the destination and continue on the existing source backend until `Block` cancels the mirror and swaps the queues back to the source. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/mirror.rs | 81 +++++++++++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/block/src/mirror.rs b/block/src/mirror.rs index b879328d9..16f417915 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -272,6 +272,9 @@ pub struct MirroringAsyncIo { /// the request it was submitted with, the result is bytes /// transferred or a negative errno. inflight_completions: VecDeque<(u64, i32)>, + /// Set once this virtqueue worker observes a failure. While true, the + /// virtqueue worker forwards only to the source and ignores the destination. + source_passthrough: bool, } impl MirroringAsyncIo { /// Builds a [`MirroringAsyncIo`] for one virtqueue, wrapped in @@ -295,14 +298,17 @@ impl MirroringAsyncIo { destination, state, inflight_completions: VecDeque::new(), + source_passthrough: false, })) } /// Flip the mirror to the `Failed` phase. The operator must cancel to /// clean up the destination and the copy worker. fn fail(&mut self, failure: MirrorFailure) { + // Phase fails the mirror globally, passthrough is per worker, so other queues fail independently. self.state .transition_to_phase(MirrorPhase::Failed(Arc::new(failure))); + self.source_passthrough = true; } /// Calls source and destination submissions with mirror-specific error handling. @@ -322,31 +328,33 @@ impl MirroringAsyncIo { Ok(()) } - /// Block until `user_data`'s source and destination completion arrive, then - /// queue the single guest-visible `(user_data, src_result)`. Other - /// completions seen while waiting (e.g. an async read finishing) are stashed - /// for later delivery. + /// Block until `user_data`'s source (and, unless already degraded to + /// passthrough, destination) completion arrives, then queue the single + /// guest-visible `(user_data, src_result)`. Other completions seen while + /// waiting (e.g. an async read finishing) are stashed for later delivery. fn wait_for_completions(&mut self, user_data: u64, expected_result: i32) -> io::Result<()> { let src_result = Self::await_completion(&mut self.source, &mut self.inflight_completions, user_data)?; - match Self::await_completion( - &mut self.destination, - &mut self.inflight_completions, - user_data, - ) { - // Destination reported an I/O error or incomplete operation. - Ok(dest_result) if dest_result != expected_result => { - self.fail(MirrorFailure::DestinationCompletion { - user_data, - actual: dest_result, - expected: expected_result, - }); + if !self.source_passthrough { + match Self::await_completion( + &mut self.destination, + &mut self.inflight_completions, + user_data, + ) { + // Destination reported an I/O error or incomplete operation. + Ok(dest_result) if dest_result != expected_result => { + self.fail(MirrorFailure::DestinationCompletion { + user_data, + actual: dest_result, + expected: expected_result, + }); + } + Ok(_) => {} + // The destination wait itself failed (broken notifier or epoll). + // Hide it from the guest like any other destination failure. + Err(source) => self.fail(MirrorFailure::DestinationWait { user_data, source }), } - Ok(_) => {} - // The destination wait itself failed (broken notifier or epoll). - // Hide it from the guest like any other destination failure. - Err(source) => self.fail(MirrorFailure::DestinationWait { user_data, source }), } if src_result != expected_result { @@ -401,6 +409,13 @@ impl AsyncIo for MirroringAsyncIo { iovecs: &[iovec], user_data: u64, ) -> AsyncIoResult<()> { + if self.source_passthrough { + return self + .source + .io_mut() + .write_vectored(offset, iovecs, user_data); + } + let expected_result = iovecs .iter() .map(|iov| iov.iov_len) @@ -426,6 +441,10 @@ impl AsyncIo for MirroringAsyncIo { } fn fsync(&mut self, user_data: Option) -> AsyncIoResult<()> { + if self.source_passthrough { + return self.source.io_mut().fsync(user_data); + } + self.mirror_request(|src| src.fsync(user_data), |dst| dst.fsync(user_data))?; // A tracked fsync (Some) waits for its completion. A barrier fsync (None) does not. @@ -437,6 +456,10 @@ impl AsyncIo for MirroringAsyncIo { } fn punch_hole(&mut self, offset: u64, length: u64, user_data: u64) -> AsyncIoResult<()> { + if self.source_passthrough { + return self.source.io_mut().punch_hole(offset, length, user_data); + } + let _guard = self .state .range_locks @@ -454,6 +477,10 @@ impl AsyncIo for MirroringAsyncIo { } fn write_zeroes(&mut self, offset: u64, length: u64, user_data: u64) -> AsyncIoResult<()> { + if self.source_passthrough { + return self.source.io_mut().write_zeroes(offset, length, user_data); + } + let _guard = self .state .range_locks @@ -471,7 +498,7 @@ impl AsyncIo for MirroringAsyncIo { } fn next_completed_request(&mut self) -> Option<(u64, i32)> { - // Mirrored writes are awaited synchronously. Only async source reads complete here. + // Mirrored writes are awaited synchronously, only reads and post-failure passthrough writes surface here. while let Some((id, res)) = self.source.io_mut().next_completed_request() { self.inflight_completions.push_back((id, res)); } @@ -479,10 +506,18 @@ impl AsyncIo for MirroringAsyncIo { } fn batch_requests_enabled(&self) -> bool { + if self.source_passthrough { + return self.source.io().batch_requests_enabled(); + } + true } fn submit_batch_requests(&mut self, batch_request: &[BatchRequest]) -> AsyncIoResult<()> { + if self.source_passthrough { + return self.source.io_mut().submit_batch_requests(batch_request); + } + for req in batch_request { let result = match req.request_type { RequestType::In => self.read_vectored(req.offset, &req.iovecs, req.user_data), @@ -503,6 +538,10 @@ impl AsyncIo for MirroringAsyncIo { } fn alignment(&self) -> u64 { + if self.source_passthrough { + return self.source.io().alignment(); + } + // Stricter alignment wins. Same iovec goes to both backends. self.source .io() From 4e7520b10c33aaf8098e33fcd813fa3dd1e0c71f Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Thu, 11 Jun 2026 14:59:21 +0200 Subject: [PATCH 23/40] vmm: add vm.disk-mirror-cancel REST endpoint Block::cancel_mirror was only reachable through a guest-initiated device reset. Give the operator a way to abort a mirror and keep the guest on the source disk. Wire the call through the layers: DeviceManager::mirror_disk_cancel resolves the device and maps errors, Vm and the RequestHandler forward the call, and a new VmDiskMirrorCancel action backs the PUT /vm.disk-mirror-cancel endpoint. Unknown device ids and inactive mirrors map to 404, a cancel after an attempted completion maps to 400, and revert failures surface as internal errors. A failed cancel keeps the mirror handle and leaves the mirror in the Cancelling phase. Cancel accepts that phase as a retry, so the request can simply be retried. CancelToSource commands are idempotent per virtqueue. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- fuzz/fuzz_targets/http_api.rs | 4 +++ vmm/src/api/http/http_endpoint.rs | 23 ++++++++++--- vmm/src/api/http/mod.rs | 12 ++++--- vmm/src/api/mod.rs | 41 +++++++++++++++++++++++ vmm/src/api/openapi/cloud-hypervisor.yaml | 28 ++++++++++++++++ vmm/src/device_manager.rs | 22 ++++++++++++ vmm/src/lib.rs | 10 ++++++ vmm/src/vm.rs | 12 +++++++ 8 files changed, 144 insertions(+), 8 deletions(-) diff --git a/fuzz/fuzz_targets/http_api.rs b/fuzz/fuzz_targets/http_api.rs index cf7728147..c2b2fa023 100644 --- a/fuzz/fuzz_targets/http_api.rs +++ b/fuzz/fuzz_targets/http_api.rs @@ -125,6 +125,10 @@ impl RequestHandler for StubApiRequestHandler { Ok(()) } + fn vm_disk_mirror_cancel(&mut self, _: String) -> Result<(), VmError> { + Ok(()) + } + #[cfg(target_arch = "x86_64")] fn vm_coredump(&mut self, _: &str) -> Result<(), VmError> { Ok(()) diff --git a/vmm/src/api/http/http_endpoint.rs b/vmm/src/api/http/http_endpoint.rs index d53e495c7..6db5abd02 100644 --- a/vmm/src/api/http/http_endpoint.rs +++ b/vmm/src/api/http/http_endpoint.rs @@ -49,10 +49,11 @@ use crate::api::http::{EndpointHandler, HttpError, error_response}; use crate::api::{ AddDisk, ApiAction, ApiError, ApiRequest, NetConfig, VmAddDevice, VmAddFs, VmAddGenericVhostUser, VmAddNet, VmAddPmem, VmAddUserDevice, VmAddVdpa, VmAddVsock, VmBoot, - VmCancelMigration, VmConfig, VmCounters, VmDelete, VmDiskMirrorComplete, VmDiskMirrorStart, - VmDiskMirrorStatus, VmMigrationProgress, VmNmi, VmPause, VmPostMigrationAnnounce, - VmPowerButton, VmReboot, VmReceiveMigration, VmReceiveMigrationData, VmRemoveDevice, VmResize, - VmResizeDisk, VmResizeZone, VmRestore, VmResume, VmSendMigration, VmShutdown, VmSnapshot, + VmCancelMigration, VmConfig, VmCounters, VmDelete, VmDiskMirrorCancel, VmDiskMirrorComplete, + VmDiskMirrorStart, VmDiskMirrorStatus, VmMigrationProgress, VmNmi, VmPause, + VmPostMigrationAnnounce, VmPowerButton, VmReboot, VmReceiveMigration, VmReceiveMigrationData, + VmRemoveDevice, VmResize, VmResizeDisk, VmResizeZone, VmRestore, VmResume, VmSendMigration, + VmShutdown, VmSnapshot, }; use crate::config::RestoreConfig; use crate::cpu::Error as CpuError; @@ -558,6 +559,20 @@ vm_action_put_handler_body!(VmDiskMirrorComplete, |e| match &e { _ => HttpError::ApiError(e), }); +vm_action_put_handler_body!(VmDiskMirrorCancel, |e| match &e { + ApiError::VmDiskMirrorCancel(VmError::DeviceManager(DeviceManagerError::UnknownDeviceId( + _, + ))) => HttpError::NotFound, + ApiError::VmDiskMirrorCancel(VmError::DeviceManager( + DeviceManagerError::BlockMirrorCancel(_, block_err), + )) => match block_err.kind() { + BlockErrorKind::Mirror(MirrorError::NotActive) => HttpError::NotFound, + BlockErrorKind::Mirror(MirrorError::CompletionInProgress) => HttpError::BadRequest, + _ => HttpError::ApiError(e), + }, + _ => HttpError::ApiError(e), +}); + impl PutHandler for VmResize { fn handle_request( &'static self, diff --git a/vmm/src/api/http/mod.rs b/vmm/src/api/http/mod.rs index ed28b2f59..59097a582 100644 --- a/vmm/src/api/http/mod.rs +++ b/vmm/src/api/http/mod.rs @@ -30,10 +30,10 @@ use crate::api::VmCoredump; use crate::api::{ AddDisk, ApiError, ApiRequest, VmAddDevice, VmAddFs, VmAddGenericVhostUser, VmAddNet, VmAddPmem, VmAddUserDevice, VmAddVdpa, VmAddVsock, VmBoot, VmCancelMigration, VmCounters, - VmDelete, VmDiskMirrorComplete, VmDiskMirrorStart, VmDiskMirrorStatus, VmMigrationProgress, - VmNmi, VmPause, VmPostMigrationAnnounce, VmPowerButton, VmReboot, VmReceiveMigration, - VmRemoveDevice, VmResize, VmResizeDisk, VmResizeZone, VmRestore, VmResume, VmSendMigration, - VmShutdown, VmSnapshot, + VmDelete, VmDiskMirrorCancel, VmDiskMirrorComplete, VmDiskMirrorStart, VmDiskMirrorStatus, + VmMigrationProgress, VmNmi, VmPause, VmPostMigrationAnnounce, VmPowerButton, VmReboot, + VmReceiveMigration, VmRemoveDevice, VmResize, VmResizeDisk, VmResizeZone, VmRestore, VmResume, + VmSendMigration, VmShutdown, VmSnapshot, }; use crate::landlock::Landlock; use crate::seccomp_filters::{Thread, get_seccomp_filter}; @@ -247,6 +247,10 @@ pub static HTTP_ROUTES: LazyLock = LazyLock::new(|| { endpoint!("/vm.disk-mirror-complete"), Box::new(VmActionHandler::new(&VmDiskMirrorComplete)), ); + r.routes.insert( + endpoint!("/vm.disk-mirror-cancel"), + Box::new(VmActionHandler::new(&VmDiskMirrorCancel)), + ); r.routes.insert(endpoint!("/vm.info"), Box::new(VmInfo {})); r.routes.insert( endpoint!("/vm.pause"), diff --git a/vmm/src/api/mod.rs b/vmm/src/api/mod.rs index 3192fd127..2c809516c 100644 --- a/vmm/src/api/mod.rs +++ b/vmm/src/api/mod.rs @@ -160,6 +160,10 @@ pub enum ApiError { #[error("Error completing disk mirror")] VmDiskMirrorComplete(#[source] VmError), + /// Error cancelling disk mirror + #[error("Error cancelling disk mirror")] + VmDiskMirrorCancel(#[source] VmError), + /// The memory zone could not be resized. #[error("The memory zone could not be resized")] VmResizeZone(#[source] VmError), @@ -262,6 +266,11 @@ pub struct VmDiskMirrorCompleteData { pub id: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct VmDiskMirrorCancelData { + pub id: String, +} + /// Wire form of [`MirrorPhase`], without the failure reason payload, /// which the response carries separately in `failure`. #[derive(Clone, Copy, Debug, Serialize)] @@ -829,6 +838,8 @@ pub trait RequestHandler { fn vm_disk_mirror_status(&mut self, id: String) -> Result>, VmError>; fn vm_disk_mirror_complete(&mut self, id: String) -> Result<(), VmError>; + fn vm_disk_mirror_cancel(&mut self, id: String) -> Result<(), VmError>; + fn vm_add_device(&mut self, device_cfg: DeviceConfig) -> Result>, VmError>; fn vm_add_user_device( @@ -1544,6 +1555,36 @@ impl ApiAction for VmDiskMirrorComplete { } } +pub struct VmDiskMirrorCancel; + +impl ApiAction for VmDiskMirrorCancel { + type RequestBody = VmDiskMirrorCancelData; + type ResponseBody = Option; + + fn request(&self, data: Self::RequestBody, response_sender: Sender) -> ApiRequest { + Box::new(move |vmm| { + let response = vmm + .vm_disk_mirror_cancel(data.id) + .map_err(ApiError::VmDiskMirrorCancel) + .map(|_| ApiResponsePayload::Empty); + + response_sender + .send(response) + .map_err(VmmError::ApiResponseSend)?; + Ok(false) + }) + } + + fn send( + &self, + api_evt: EventFd, + api_sender: Sender, + data: Self::RequestBody, + ) -> ApiResult { + get_response_body(self, api_evt, api_sender, data) + } +} + pub struct VmInfo; impl ApiAction for VmInfo { diff --git a/vmm/src/api/openapi/cloud-hypervisor.yaml b/vmm/src/api/openapi/cloud-hypervisor.yaml index b6640fe0f..bbb81c855 100644 --- a/vmm/src/api/openapi/cloud-hypervisor.yaml +++ b/vmm/src/api/openapi/cloud-hypervisor.yaml @@ -572,6 +572,26 @@ paths: 500: description: The disk mirror could not be completed. + /vm.disk-mirror-cancel: + put: + summary: Cancel a disk mirror and keep the source disk + requestBody: + description: The identifier of the mirrored disk + content: + application/json: + schema: + $ref: "#/components/schemas/VmDiskMirrorCancelData" + required: true + responses: + 204: + description: The disk mirror was cancelled and the device keeps using the source. + 400: + description: The mirror cannot be cancelled because completion is already in progress. + 404: + description: No disk with the given identifier was found, or no mirror is active for it. + 500: + description: The disk mirror could not be cancelled. + components: schemas: VmmPingResponse: @@ -1678,3 +1698,11 @@ components: properties: id: type: string + + VmDiskMirrorCancelData: + required: + - id + type: object + properties: + id: + type: string diff --git a/vmm/src/device_manager.rs b/vmm/src/device_manager.rs index aa1583d08..290b11c56 100644 --- a/vmm/src/device_manager.rs +++ b/vmm/src/device_manager.rs @@ -699,6 +699,10 @@ pub enum DeviceManagerError { #[error("Failed to complete block mirror for disk {0}: {1}")] BlockMirrorComplete(String, #[source] BlockError), + + /// Cancelling the block mirror failed. + #[error("Failed to cancel the block mirror for the disk with identifier: {0}")] + BlockMirrorCancel(String, #[source] BlockError), } pub type DeviceManagerResult = result::Result; @@ -5462,6 +5466,24 @@ impl DeviceManager { Err(DeviceManagerError::UnknownDeviceId(device_id.to_string())) } + /// Cancels the active block mirror for the disk identified by + /// `device_id`, reverting all virtqueue workers to the source disk + /// and releasing the destination. Errors if no disk with that + /// identifier is attached, if no mirror is active, if a completion has + /// already been attempted, or if reverting the virtqueue workers + /// fails. + pub fn mirror_disk_cancel(&self, device_id: &str) -> DeviceManagerResult<()> { + for dev in &self.block_devices { + let mut disk = dev.lock().unwrap(); + if disk.id() == device_id { + return disk + .cancel_mirror() + .map_err(|e| DeviceManagerError::BlockMirrorCancel(device_id.to_string(), e)); + } + } + Err(DeviceManagerError::UnknownDeviceId(device_id.to_string())) + } + /// Helps the environment converge quickly after a live migration by /// prompting devices to advertise the VM from its new host. /// diff --git a/vmm/src/lib.rs b/vmm/src/lib.rs index 8c972cfb9..52f2edd8b 100644 --- a/vmm/src/lib.rs +++ b/vmm/src/lib.rs @@ -3552,6 +3552,16 @@ impl RequestHandler for Vmm { MaybeVmOwnership::None => Err(VmError::DiskMirrorComplete), } } + + fn vm_disk_mirror_cancel(&mut self, id: String) -> result::Result<(), VmError> { + self.vm_config.as_ref().ok_or(VmError::VmNotCreated)?; + + match self.vm { + MaybeVmOwnership::Vmm(ref mut vm) => vm.mirror_disk_cancel(&id), + MaybeVmOwnership::Migration(_) => Err(VmError::VmMigrating), + MaybeVmOwnership::None => Err(VmError::DiskMirrorCancel), + } + } } const CPU_MANAGER_SNAPSHOT_ID: &str = "cpu-manager"; diff --git a/vmm/src/vm.rs b/vmm/src/vm.rs index 243690914..9a182a441 100644 --- a/vmm/src/vm.rs +++ b/vmm/src/vm.rs @@ -280,6 +280,9 @@ pub enum Error { #[error("Failed to complete disk mirror")] DiskMirrorComplete, + #[error("Failed to cancel disk mirror")] + DiskMirrorCancel, + #[error("At least one disk mirror is active")] ActiveBlockMirror, @@ -3346,6 +3349,15 @@ impl Vm { Ok(()) } + /// Cancels the mirror for `id` and keeps its source backend. + pub fn mirror_disk_cancel(&self, id: &str) -> Result<()> { + self.device_manager + .lock() + .unwrap() + .mirror_disk_cancel(id) + .map_err(Error::DeviceManager) + } + /// Returns true if there is an active mirror in any of the block devices, false otherwise. pub fn any_active_block_mirrors(&self) -> bool { self.device_manager From dcda76a5aa653488d3e654e3d64ffed1e4c6f9d2 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Wed, 17 Jun 2026 11:12:37 +0200 Subject: [PATCH 24/40] block: preserve sparseness in CopyWorker During block-dev mirroring, the CopyWorker reads each block from the source disk and writes it to the destination. It used to write zero-filled blocks as well, which allocates storage on the destination for regions that hold no data. When the destination supports sparse operations, we now check whether a block is all zeros. If it is, we call punch_hole instead of write_vectored. This keeps the destination as sparse as the source. The check currently looks at the full MIRROR_BLOCK_SIZE block, so only all-zero blocks become holes. A smaller granularity would also punch holes inside partly-zero blocks and save more space, at the cost of more compute per block. We leave this for a later change. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/mirror.rs | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/block/src/mirror.rs b/block/src/mirror.rs index 16f417915..6934e6b3c 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -595,6 +595,7 @@ fn flush_async_io(user_data: u64, completion_io: &mut CompletionIo) -> io::Resul pub struct CopyWorker { source_io: CompletionIo, dest_io: CompletionIo, + dest_is_sparse: bool, state: Arc, /// Once allocated, the buffer is reused for all blocks to avoid repeated allocations. buf: AlignedBuf, @@ -621,6 +622,7 @@ impl CopyWorker { Ok(Self { source_io, dest_io, + dest_is_sparse: destination_disk.supports_sparse_operations(), state, buf: AlignedBuf::new(block_size_bytes, alignment as usize)?, block_size_bytes, @@ -687,6 +689,8 @@ impl CopyWorker { iov_base: self.buf.as_mut_slice(length).as_mut_ptr().cast(), iov_len: length, }]; + let expected_result = + i32::try_from(length).map_err(|_| io::Error::other("copy block is too large"))?; // Read from source into buf. self.buf.as_mut_slice(length).fill(0); @@ -699,18 +703,39 @@ impl CopyWorker { if result < 0 { return Err(io::Error::from_raw_os_error(-result)); } + if result != expected_result { + return Err(io::Error::other(format!( + "source read completed {result} bytes, expected {expected_result}" + ))); + } debug_assert_eq!(user_data, read_id); - // Write buf to destination. let write_id = self.generate_user_data(); - self.dest_io - .io_mut() - .write_vectored(offset as off_t, &iovecs, write_id) - .map_err(|e| io::Error::other(format!("async io write_vectored failed: {e}")))?; + let punch_hole = self.dest_is_sparse && self.buf.as_slice(length).iter().all(|&b| b == 0); + if punch_hole { + // Source block is all zeros: punch a hole to keep the destination sparse. + self.dest_io + .io_mut() + .punch_hole(offset, length as u64, write_id) + .map_err(|e| io::Error::other(format!("async io punch_hole failed: {e}")))?; + } else { + // Write buf to destination. + self.dest_io + .io_mut() + .write_vectored(offset as off_t, &iovecs, write_id) + .map_err(|e| io::Error::other(format!("async io write_vectored failed: {e}")))?; + } + let (user_data, result) = self.dest_io.next_completion()?; if result < 0 { return Err(io::Error::from_raw_os_error(-result)); } + let expected_result = if punch_hole { 0 } else { expected_result }; + if result != expected_result { + return Err(io::Error::other(format!( + "destination write completed {result} bytes, expected {expected_result}" + ))); + } debug_assert_eq!(user_data, write_id); self.state From 08a926792f23d5a2f26e7e78824f11550929df22 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Mon, 22 Jun 2026 11:59:48 +0200 Subject: [PATCH 25/40] block: add mirror unit tests Cover the range-lock and passthrough behaviour of MirroringAsyncIo with a mock AsyncIo, so the synchronization invariants are checked without real disk I/O. The tests cover: - overlapping mirror writes complete in order under the range lock - a copy-worker range hold blocks an overlapping guest write until it is released - reads pass through to the source only - a destination submit failure degrades the mirror to source passthrough A watchdog thread fails a test on timeout, so a locking regression surfaces as a failure rather than a hang. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/mirror.rs | 243 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) diff --git a/block/src/mirror.rs b/block/src/mirror.rs index 6934e6b3c..387a2b571 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -838,4 +838,247 @@ mod tests { locked.insert(10u64, 20u64); assert!(!RangeLockManager::overlaps_any(&locked, 20, 30)); } + + use std::collections::VecDeque; + use std::sync::mpsc; + use std::time::Duration; + + /// In-memory [`AsyncIo`] backend for driving [`MirroringAsyncIo`] in a unit + /// test without a real fd, io_uring, or the copy worker. Each submission is + /// recorded as an immediately-available completion and the notifier eventfd + /// is signaled, so a synchronous wait loop on the notifier observes it. + struct MockAsyncIo { + evt: EventFd, + completions: VecDeque<(u64, i32)>, + /// When set, the `write_vectored` submit at this 0-based index returns + /// an error instead of completing. Drives the destination-failure and + /// partial-batch paths. + fail_on_nth_write: Option, + writes_seen: usize, + } + + impl MockAsyncIo { + fn new() -> Self { + Self { + evt: EventFd::new(libc::EFD_NONBLOCK).unwrap(), + completions: VecDeque::new(), + fail_on_nth_write: None, + writes_seen: 0, + } + } + + /// Record a completion and wake any waiter parked on the notifier. + fn complete(&mut self, user_data: u64, result: i32) { + self.completions.push_back((user_data, result)); + self.evt.write(1).unwrap(); + } + } + + impl AsyncIo for MockAsyncIo { + fn notifier(&self) -> &EventFd { + &self.evt + } + fn read_vectored(&mut self, _o: off_t, iovecs: &[iovec], ud: u64) -> AsyncIoResult<()> { + self.complete( + ud, + iovecs.iter().map(|iov| iov.iov_len).sum::() as i32, + ); + Ok(()) + } + fn write_vectored(&mut self, _o: off_t, iovecs: &[iovec], ud: u64) -> AsyncIoResult<()> { + let index = self.writes_seen; + self.writes_seen += 1; + if self.fail_on_nth_write == Some(index) { + return Err(AsyncIoError::WriteVectored(io::Error::other( + "injected write submit failure", + ))); + } + self.complete( + ud, + iovecs.iter().map(|iov| iov.iov_len).sum::() as i32, + ); + Ok(()) + } + fn fsync(&mut self, ud: Option) -> AsyncIoResult<()> { + if let Some(ud) = ud { + self.complete(ud, 0); + } + Ok(()) + } + fn punch_hole(&mut self, _o: u64, _l: u64, ud: u64) -> AsyncIoResult<()> { + self.complete(ud, 0); + Ok(()) + } + fn write_zeroes(&mut self, _o: u64, _l: u64, ud: u64) -> AsyncIoResult<()> { + self.complete(ud, 0); + Ok(()) + } + fn next_completed_request(&mut self) -> Option<(u64, i32)> { + self.completions.pop_front() + } + } + + fn mirror_with_mocks() -> MirroringAsyncIo { + mirror_from( + MockAsyncIo::new(), + MockAsyncIo::new(), + MirrorState::new(1 << 20), + ) + } + + /// The one place to update when `MirroringAsyncIo`'s fields change. + fn mirror_from( + source: S, + destination: D, + state: Arc, + ) -> MirroringAsyncIo { + MirroringAsyncIo { + source: CompletionIo::new(Box::new(source)).unwrap(), + destination: CompletionIo::new(Box::new(destination)).unwrap(), + state, + inflight_completions: VecDeque::new(), + source_passthrough: false, + } + } + + /// One iovec over `buf`. The mocks never read it, so it only needs to + /// outlive the submit call. + fn iov_of(buf: &[u8]) -> [iovec; 1] { + [iovec { + iov_base: buf.as_ptr() as *mut libc::c_void, + iov_len: buf.len(), + }] + } + + /// Runs `f` on a worker thread and fails the test if it does not finish + /// within `timeout`. Turns a submit-path deadlock into a clean failure + /// instead of a hung suite: the worker stays blocked, but the test thread + /// resumes after the timeout and panics. + fn run_with_watchdog(timeout: Duration, f: impl FnOnce() + Send + 'static) { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + f(); + let _ = tx.send(()); + }); + if rx.recv_timeout(timeout).is_err() { + panic!("scenario did not finish within {timeout:?} (deadlock)"); + } + } + + /// Drains completions until `n` have arrived (or the budget is exhausted). + fn drain_n(mirror: &mut MirroringAsyncIo, n: usize) -> Vec { + let mut acked = Vec::new(); + for _ in 0..64 { + while let Some((user_data, result)) = mirror.next_completed_request() { + assert!(result >= 0, "unexpected error completion: {result}"); + acked.push(user_data); + } + if acked.len() >= n { + break; + } + } + acked + } + + /// Two overlapping guest writes submitted before either is reaped must both + /// complete in submission order without deadlocking. + #[test] + fn overlapping_writes_complete_in_order() { + run_with_watchdog(Duration::from_secs(5), || { + let mut mirror = mirror_with_mocks(); + let buf = [0u8; 4096]; + let iov = iov_of(&buf); + + mirror.write_vectored(0, &iov, 1).unwrap(); + mirror.write_vectored(0, &iov, 2).unwrap(); + + assert_eq!( + drain_n(&mut mirror, 2), + vec![1, 2], + "both overlapping writes complete in submission order" + ); + }); + } + + /// While the copy worker holds a range (simulated by holding a `RangeGuard` + /// on the shared lock manager), an overlapping guest write must block and + /// proceed only once the range is released. + #[test] + fn copy_worker_hold_serializes_overlapping_guest_write() { + let state = MirrorState::new(1 << 20); + // The "copy worker" holds [0, 4096). + let guard = state.range_locks.clone().lock_range(0, 4096).unwrap(); + + let mut mirror = mirror_from(MockAsyncIo::new(), MockAsyncIo::new(), state.clone()); + + let (tx, rx) = mpsc::channel(); + let handle = thread::spawn(move || { + let buf = [0u8; 4096]; + let iov = iov_of(&buf); + mirror.write_vectored(0, &iov, 1).unwrap(); + tx.send(()).unwrap(); + }); + + // The held range must block the overlapping guest write. + assert!( + rx.recv_timeout(Duration::from_millis(200)).is_err(), + "guest write proceeded while the copy worker held the range" + ); + + // Releasing the range lets the write through. + drop(guard); + assert!( + rx.recv_timeout(Duration::from_secs(5)).is_ok(), + "guest write did not proceed after the range was released" + ); + handle.join().unwrap(); + } + + /// Reads are source-only passthrough (no range lock) and still complete. + #[test] + fn read_passes_through_to_source() { + run_with_watchdog(Duration::from_secs(5), || { + let mut mirror = mirror_with_mocks(); + let buf = [0u8; 4096]; + let iov = iov_of(&buf); + + mirror.read_vectored(0, &iov, 7).unwrap(); + + let mut got = None; + for _ in 0..64 { + if let Some(c) = mirror.next_completed_request() { + got = Some(c); + break; + } + } + assert_eq!(got, Some((7, 4096)), "read completes via the source"); + }); + } + + /// A destination submit failure degrades the mirror to source passthrough: + /// the phase goes `Failed`, and both the failing write and a subsequent + /// write still complete to the guest off the source alone. + #[test] + fn destination_submit_failure_degrades_to_passthrough() { + run_with_watchdog(Duration::from_secs(5), || { + let mut dest = MockAsyncIo::new(); + dest.fail_on_nth_write = Some(0); + let mut mirror = mirror_from(MockAsyncIo::new(), dest, MirrorState::new(1 << 20)); + let buf = [0u8; 4096]; + let iov = iov_of(&buf); + + mirror.write_vectored(0, &iov, 1).unwrap(); + assert!( + matches!(mirror.state.phase(), MirrorPhase::Failed(_)), + "destination failure transitions the mirror to Failed" + ); + + // Subsequent write goes to the source only. + mirror.write_vectored(0, &iov, 2).unwrap(); + + let mut acked = drain_n(&mut mirror, 2); + acked.sort(); + assert_eq!(acked, vec![1, 2], "both writes complete off the source"); + }); + } } From d25575443327998428a41b2b723b19804a02ec16 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Tue, 23 Jun 2026 10:42:45 +0200 Subject: [PATCH 26/40] block: test range guard held across mirror write The synchronous mirrored write holds its range guard from acquisition through both the source and destination completions. Nothing else pins that lifetime, so a regression to dropping the guard early (`let _` instead of `let _guard`) would let an overlapping lock_range acquire while the write is still in flight, the exact race the range lock exists to prevent. Add guard_is_held_across_submit_and_wait and a GatedMockAsyncIo backend whose destination completion is withheld until released from another thread. The write parks in wait_for_completions holding its guard while the test asserts an overlapping lock_range blocks, then acquires only once the completion is released and the write drops the guard. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/mirror.rs | 149 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/block/src/mirror.rs b/block/src/mirror.rs index 387a2b571..4c4803cb5 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -1081,4 +1081,153 @@ mod tests { assert_eq!(acked, vec![1, 2], "both writes complete off the source"); }); } + + /// Mock backend whose completions are withheld until [`Gate::release`], so a + /// test can hold a write parked in `wait_for_completions`. + struct GatedMockAsyncIo { + evt: EventFd, + inner: Arc>, + /// Notified on each submit, so a test can wait until the in-flight write + /// has reached this backend (and so already holds its range guard). + on_submit: mpsc::Sender<()>, + } + + struct GatedInner { + /// Submitted, not yet released. + pending: VecDeque<(u64, i32)>, + /// Released, deliverable via `next_completed_request`. + ready: VecDeque<(u64, i32)>, + } + + /// Releases a [`GatedMockAsyncIo`]'s withheld completions from another thread. + struct Gate { + evt: EventFd, + inner: Arc>, + } + + impl Gate { + fn release(&self) { + let mut inner = self.inner.lock().unwrap(); + while let Some(completion) = inner.pending.pop_front() { + inner.ready.push_back(completion); + } + self.evt.write(1).unwrap(); + } + } + + impl GatedMockAsyncIo { + fn new(on_submit: mpsc::Sender<()>) -> Self { + Self { + evt: EventFd::new(libc::EFD_NONBLOCK).unwrap(), + inner: Arc::new(Mutex::new(GatedInner { + pending: VecDeque::new(), + ready: VecDeque::new(), + })), + on_submit, + } + } + + fn gate(&self) -> Gate { + Gate { + evt: self.evt.try_clone().unwrap(), + inner: Arc::clone(&self.inner), + } + } + + fn submit(&self, user_data: u64, result: i32) { + self.inner + .lock() + .unwrap() + .pending + .push_back((user_data, result)); + let _ = self.on_submit.send(()); + } + } + + impl AsyncIo for GatedMockAsyncIo { + fn notifier(&self) -> &EventFd { + &self.evt + } + fn read_vectored(&mut self, _o: off_t, iovecs: &[iovec], ud: u64) -> AsyncIoResult<()> { + self.submit( + ud, + iovecs.iter().map(|iov| iov.iov_len).sum::() as i32, + ); + Ok(()) + } + fn write_vectored(&mut self, _o: off_t, iovecs: &[iovec], ud: u64) -> AsyncIoResult<()> { + self.submit( + ud, + iovecs.iter().map(|iov| iov.iov_len).sum::() as i32, + ); + Ok(()) + } + fn fsync(&mut self, ud: Option) -> AsyncIoResult<()> { + if let Some(ud) = ud { + self.submit(ud, 0); + } + Ok(()) + } + fn punch_hole(&mut self, _o: u64, _l: u64, ud: u64) -> AsyncIoResult<()> { + self.submit(ud, 0); + Ok(()) + } + fn write_zeroes(&mut self, _o: u64, _l: u64, ud: u64) -> AsyncIoResult<()> { + self.submit(ud, 0); + Ok(()) + } + fn next_completed_request(&mut self) -> Option<(u64, i32)> { + self.inner.lock().unwrap().ready.pop_front() + } + } + + /// The range guard must stay held across the whole synchronous submit+wait, + /// not just acquisition. A regression to `let _ =` drops it early and lets + /// an overlapping `lock_range` acquire while the write is still in flight. + #[test] + fn guard_is_held_across_submit_and_wait() { + let state = MirrorState::new(1 << 20); + + // Source completes immediately; destination is gated, so the write parks + // waiting on the destination completion while holding the range lock. + let (submitted_tx, submitted_rx) = mpsc::channel(); + let dest = GatedMockAsyncIo::new(submitted_tx); + let gate = dest.gate(); + let mut mirror = mirror_from(MockAsyncIo::new(), dest, state.clone()); + + let writer = thread::spawn(move || { + let buf = [0u8; 4096]; + let iov = iov_of(&buf); + mirror.write_vectored(0, &iov, 1).unwrap(); + }); + + // The write reached the destination submit, so its range guard is held. + submitted_rx.recv().unwrap(); + + // An overlapping lock_range must block while the in-flight write holds it. + let locker_state = state.clone(); + let (locked_tx, locked_rx) = mpsc::channel(); + let locker = thread::spawn(move || { + let _g = locker_state + .range_locks + .clone() + .lock_range(0, 4096) + .unwrap(); + locked_tx.send(()).unwrap(); + }); + assert!( + locked_rx.recv_timeout(Duration::from_millis(200)).is_err(), + "lock_range acquired while the in-flight write still held the range" + ); + + // Releasing the destination completion lets the write finish and drop its + // guard, which unblocks the overlapping lock_range. + gate.release(); + writer.join().unwrap(); + assert!( + locked_rx.recv_timeout(Duration::from_secs(5)).is_ok(), + "lock_range did not acquire after the write released the range" + ); + locker.join().unwrap(); + } } From be15b879a83f11ec6077f338d89d78ce9bc29403 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Tue, 23 Jun 2026 13:11:59 +0200 Subject: [PATCH 27/40] virtio-devices, block: reject mirror ops on a paused device A paused virtqueue worker is parked on its pause barrier and never reaches its epoll loop, so it cannot pick up a staged BlockQueueCommand. start_mirror, complete_mirror, and cancel_mirror staged the command anyway and blocked in wait_for_mirror_queue_command_acks until the ack timeout, then returned an error while the command lingered in the slot and was applied late once the VM resumed, leaving the mirror half-installed. complete_mirror additionally panicked on that timeout. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/error.rs | 3 +++ virtio-devices/src/block.rs | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/block/src/error.rs b/block/src/error.rs index 617d9b56f..518c6a875 100644 --- a/block/src/error.rs +++ b/block/src/error.rs @@ -32,6 +32,9 @@ pub enum MirrorError { /// A mirror operation was requested before the device was activated. #[error("Mirror operation rejected: the device is not active")] DeviceNotActive, + /// A mirror operation was requested while the device is paused. + #[error("Mirror operation rejected: the device is paused")] + DevicePaused, /// A mirror operation was requested but no mirror is active for the device. #[error("No active mirror for the device")] NotActive, diff --git a/virtio-devices/src/block.rs b/virtio-devices/src/block.rs index 01f69852a..13c2981f2 100644 --- a/virtio-devices/src/block.rs +++ b/virtio-devices/src/block.rs @@ -1366,6 +1366,7 @@ impl Block { MirrorError::DeviceNotActive, ))); } + self.ensure_not_paused_for_mirror()?; let source_size = self.disk_image.logical_size()?; let dest_size = destination.logical_size()?; if dest_size != source_size { @@ -1446,6 +1447,8 @@ impl Block { /// to the destination only, and there is no revert that keeps /// acknowledged writes, so aborting is preferred over data loss. pub fn complete_mirror(&mut self) -> BlockResult { + self.ensure_not_paused_for_mirror()?; + let handle = self .mirror_handle .as_ref() @@ -1498,6 +1501,17 @@ impl Block { Ok(destination_path) } + /// Fails with [`MirrorError::DevicePaused`] when the device is paused, since a + /// parked worker cannot apply a staged mirror command. + fn ensure_not_paused_for_mirror(&self) -> BlockResult<()> { + if self.common.paused.load(Ordering::SeqCst) { + return Err(BlockError::from_kind(BlockErrorKind::Mirror( + MirrorError::DevicePaused, + ))); + } + Ok(()) + } + fn mirror_swap_error(error: MirrorSwapError) -> BlockError { BlockError::new(BlockErrorKind::Mirror(MirrorError::Swap), error) } @@ -1608,6 +1622,7 @@ impl Block { /// Blocks until the copy worker finishes its current block and joins, /// which can stall on a slow or hung destination. pub fn cancel_mirror(&mut self) -> BlockResult<()> { + self.ensure_not_paused_for_mirror()?; let state = self .mirror_handle .as_ref() From af2c8ac03de29427e2e155dbcc0a7852605cefef Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Wed, 24 Jun 2026 11:14:01 +0200 Subject: [PATCH 28/40] vmm: reject mirror destination already backing a disk Cloud Hypervisor holds the disk image lock process-wide, so re-opening the destination here would not trip the lock. Compare canonicalized paths instead and refuse a destination that already backs one of the VM's disks. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- vmm/src/api/http/http_endpoint.rs | 3 +++ vmm/src/device_manager.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/vmm/src/api/http/http_endpoint.rs b/vmm/src/api/http/http_endpoint.rs index 6db5abd02..c71171a7b 100644 --- a/vmm/src/api/http/http_endpoint.rs +++ b/vmm/src/api/http/http_endpoint.rs @@ -532,6 +532,9 @@ vm_action_put_handler_body!(VmDiskMirrorStart, |e| match &e { ApiError::VmDiskMirrorStart(VmError::DeviceManager( DeviceManagerError::BlockMirrorAlreadyActive(_), )) => HttpError::BadRequest, + ApiError::VmDiskMirrorStart(VmError::DeviceManager( + DeviceManagerError::BlockMirrorDestinationInUse(_), + )) => HttpError::BadRequest, _ => HttpError::ApiError(e), }); diff --git a/vmm/src/device_manager.rs b/vmm/src/device_manager.rs index 290b11c56..657bd1bd8 100644 --- a/vmm/src/device_manager.rs +++ b/vmm/src/device_manager.rs @@ -691,6 +691,10 @@ pub enum DeviceManagerError { )] BlockMirrorAlreadyActive(String), + /// The mirror destination path is already backing one of the VM's disks. + #[error("Cannot mirror to '{0}': it is already in use as a disk image by this VM")] + BlockMirrorDestinationInUse(String), + /// Cannot perform given action, as the device is currently performing a block mirroring operation. #[error( "Failed to perform the requested action for the disk with identifier: {0} as it is currently performing a block mirroring operation" @@ -5372,6 +5376,24 @@ impl DeviceManager { ))); } + // Refuse a destination that already backs one of this VM's disks, comparing canonicalized paths. + let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()); + let dest_canon = canon(dest_path); + let dest_in_use = self + .config + .lock() + .unwrap() + .disks + .iter() + .flatten() + .filter_map(|d| d.path.as_deref()) + .any(|src| canon(src) == dest_canon); + if dest_in_use { + return Err(DeviceManagerError::BlockMirrorDestinationInUse( + dest_path.display().to_string(), + )); + } + let (options, image_type) = { let cfg = self.config.lock().unwrap(); let src = cfg From 1345f6400dfdea5be20c36fb32bd399cadbf3fb2 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Wed, 24 Jun 2026 11:46:29 +0200 Subject: [PATCH 29/40] vmm: seccomp: allow io_uring and eventfd2 on vcpus A virtio-blk device activated by the guest, and the blockdev mirror rebuilding a backend on a guest-initiated reset, set up io_uring rings and eventfds on the activating vcpu thread, after that thread's seccomp filter is installed. Allow io_uring_setup and io_uring_register plus eventfd2 for the notifier, matching what the vmm thread already permits, so the backend is not killed with SIGSYS. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- vmm/src/seccomp_filters.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vmm/src/seccomp_filters.rs b/vmm/src/seccomp_filters.rs index 69c4a83f3..4b2192df2 100644 --- a/vmm/src/seccomp_filters.rs +++ b/vmm/src/seccomp_filters.rs @@ -890,6 +890,9 @@ fn vcpu_thread_rules( (libc::SYS_dup, vec![]), (libc::SYS_exit, vec![]), (libc::SYS_epoll_ctl, vec![]), + (libc::SYS_eventfd2, vec![]), + (libc::SYS_io_uring_setup, vec![]), + (libc::SYS_io_uring_register, vec![]), ( libc::SYS_fallocate, or![and![Cond::new( From 06982151dc336efb467a03920739390ddfed757e Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Wed, 24 Jun 2026 13:57:40 +0200 Subject: [PATCH 30/40] block: test batched submit on partial failure submit_batch_requests serializes each entry and queues a completion per write, a submit failure mid-batch must still return Ok with one completion per entry. Otherwise the virtqueue worker, which records the batch as in-flight only on Ok, strands the completions already queued for earlier entries and dies with MissingEntryRequestList. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/mirror.rs | 57 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/block/src/mirror.rs b/block/src/mirror.rs index 4c4803cb5..5a5207b85 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -1230,4 +1230,61 @@ mod tests { ); locker.join().unwrap(); } + + /// A single-iovec `Out` batch entry backed by `buf`. + fn batch_write(offset: off_t, buf: &[u8], user_data: u64) -> BatchRequest { + BatchRequest { + offset, + iovecs: [iovec { + iov_base: buf.as_ptr() as *mut libc::c_void, + iov_len: buf.len(), + }] + .into_iter() + .collect(), + user_data, + request_type: RequestType::Out, + } + } + + /// A mid-batch submit failure must still return `Ok` with one completion per + /// entry (an error completion for the failed one). The worker records the + /// batch as in-flight only on `Ok`, so aborting with `Err` strands the + /// completions already queued for earlier entries. + #[test] + fn failed_batch_submit_accounts_every_request() { + // Second write (index 1) fails at submit on the source; the first + // already went through. + let mut source = MockAsyncIo::new(); + source.fail_on_nth_write = Some(1); + let mut mirror = mirror_from(source, MockAsyncIo::new(), MirrorState::new(1 << 20)); + let buf = [0u8; 4096]; + + let batch = [batch_write(0, &buf, 1), batch_write(4096, &buf, 2)]; + + mirror + .submit_batch_requests(&batch) + .expect("a mid-batch submit failure must not fail the whole batch"); + + let mut completions = Vec::new(); + while let Some(c) = mirror.next_completed_request() { + completions.push(c); + } + completions.sort_by_key(|(user_data, _)| *user_data); + + assert_eq!( + completions.len(), + 2, + "every batch entry owes exactly one completion" + ); + assert_eq!( + completions[0], + (1, 4096), + "first write completes successfully" + ); + assert_eq!(completions[1].0, 2, "second entry is still accounted"); + assert!( + completions[1].1 < 0, + "second entry carries an error result (reported IOERR), not an orphan" + ); + } } From 3020d69981cca4c7286c80333ee76a82adcab204 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Wed, 24 Jun 2026 14:45:24 +0200 Subject: [PATCH 31/40] block: test mirror phase transitions and op fan-out Test the MirrorState phase state machine (allowed transitions, rejected ones, terminal Completed, and Failed keeping its first reason), the tracked-vs-barrier fsync split, write_zeroes mirroring, and that a degraded mirror passes every op through to the source alone. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/mirror.rs | 115 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/block/src/mirror.rs b/block/src/mirror.rs index 5a5207b85..b7ed2781f 100644 --- a/block/src/mirror.rs +++ b/block/src/mirror.rs @@ -1287,4 +1287,119 @@ mod tests { "second entry carries an error result (reported IOERR), not an orphan" ); } + + /// The lifecycle advances Running -> Ready -> Completing -> Completed, each + /// state reached only from its documented predecessor. + #[test] + fn phase_advances_through_the_lifecycle() { + let state = MirrorState::new(1 << 20); + assert!(matches!(state.phase(), MirrorPhase::Running)); + state.transition_to_phase(MirrorPhase::Ready); + state.transition_to_phase(MirrorPhase::Completing); + state.transition_to_phase(MirrorPhase::Completed); + assert!(matches!(state.phase(), MirrorPhase::Completed)); + } + + /// A transition not in the table is ignored, leaving the phase unchanged. + #[test] + fn invalid_phase_transition_is_ignored() { + let state = MirrorState::new(1 << 20); + // Running -> Completed skips Ready and Completing, so it is rejected. + state.transition_to_phase(MirrorPhase::Completed); + assert!(matches!(state.phase(), MirrorPhase::Running)); + } + + /// `Completed` is terminal: no later transition takes effect. + #[test] + fn completed_phase_is_terminal() { + let state = MirrorState::new(1 << 20); + state.transition_to_phase(MirrorPhase::Ready); + state.transition_to_phase(MirrorPhase::Completing); + state.transition_to_phase(MirrorPhase::Completed); + state.transition_to_phase(MirrorPhase::Cancelling); + assert!(matches!(state.phase(), MirrorPhase::Completed)); + } + + /// A failure keeps its first reason (transitions compare only the variant) + /// and can still move to `Cancelling` for cleanup. + #[test] + fn failed_keeps_first_reason_then_cancels() { + let state = MirrorState::new(1 << 20); + let first = Arc::new(MirrorFailure::SourceCompletion { + user_data: 1, + actual: -libc::EIO, + expected: 0, + }); + state.transition_to_phase(MirrorPhase::Failed(first.clone())); + state.transition_to_phase(MirrorPhase::Failed(Arc::new( + MirrorFailure::SourceCompletion { + user_data: 2, + actual: -libc::EIO, + expected: 0, + }, + ))); + let MirrorPhase::Failed(reason) = state.phase() else { + panic!("mirror did not enter the failed phase"); + }; + assert!(Arc::ptr_eq(&reason, &first)); + state.transition_to_phase(MirrorPhase::Cancelling); + assert!(matches!(state.phase(), MirrorPhase::Cancelling)); + } + + /// A tracked fsync (`Some`) flushes both backends and surfaces one guest + /// completion for its user_data. + #[test] + fn tracked_fsync_completes_to_guest() { + run_with_watchdog(Duration::from_secs(5), || { + let mut mirror = mirror_with_mocks(); + mirror.fsync(Some(5)).unwrap(); + assert_eq!(drain_n(&mut mirror, 1), vec![5]); + }); + } + + /// A barrier fsync (`None`) flushes both backends but owes the guest no + /// completion, so nothing surfaces. + #[test] + fn barrier_fsync_surfaces_no_completion() { + let mut mirror = mirror_with_mocks(); + mirror.fsync(None).unwrap(); + assert!(mirror.next_completed_request().is_none()); + } + + /// `write_zeroes` mirrors to both backends under the range lock and + /// surfaces one guest completion, like a write. + #[test] + fn write_zeroes_mirrors_and_completes() { + run_with_watchdog(Duration::from_secs(5), || { + let mut mirror = mirror_with_mocks(); + mirror.write_zeroes(0, 4096, 3).unwrap(); + assert_eq!(drain_n(&mut mirror, 1), vec![3]); + }); + } + + /// Once degraded to passthrough, every mutating op forwards to the source + /// alone and still completes, with no destination and no range lock. + #[test] + fn degraded_mirror_passes_all_ops_through_to_source() { + run_with_watchdog(Duration::from_secs(5), || { + let mut dest = MockAsyncIo::new(); + dest.fail_on_nth_write = Some(0); + let mut mirror = mirror_from(MockAsyncIo::new(), dest, MirrorState::new(1 << 20)); + let buf = [0u8; 4096]; + let iov = iov_of(&buf); + + // The first write fails on the destination and flips to passthrough. + mirror.write_vectored(0, &iov, 1).unwrap(); + assert!(matches!(mirror.state.phase(), MirrorPhase::Failed(_))); + + // Subsequent ops take the source-only passthrough branch. + mirror.fsync(Some(2)).unwrap(); + mirror.punch_hole(0, 4096, 3).unwrap(); + mirror.write_zeroes(0, 4096, 4).unwrap(); + + let mut acked = drain_n(&mut mirror, 4); + acked.sort(); + assert_eq!(acked, vec![1, 2, 3, 4]); + }); + } } From 0fececbdc1aaf49b39a923956aecc7445f9358ac Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Thu, 25 Jun 2026 13:00:24 +0200 Subject: [PATCH 32/40] docs: add disk mirroring guide Document the operator workflow (start, status, complete, cancel, failure handling, unrecoverable errors, and conflicting operations) and the design behind it: the CopyWorker, the MirroringAsyncIo write fan-out, and the range lock. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- docs/disk_mirroring.md | 178 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 docs/disk_mirroring.md diff --git a/docs/disk_mirroring.md b/docs/disk_mirroring.md new file mode 100644 index 000000000..92c216b3f --- /dev/null +++ b/docs/disk_mirroring.md @@ -0,0 +1,178 @@ +# Disk Mirroring + +Disk mirroring copies a running VM's disk to another file on the host and +keeps the two in sync, so the disk image can be moved to a different backing +store without stopping the guest. It is the Cloud Hypervisor counterpart of +QEMU's `blockdev-mirror`. + +A typical use is rebalancing storage: when the share backing a disk image +fills up, the operator mirrors that disk onto a file on another share and +switches the VM over to it. + +## Overview + +Mirroring runs as a sequence of phases driven by four API calls: + +- `/vm.disk-mirror-start` begins mirroring a disk onto a destination path. +- `/vm.disk-mirror-status` reports the current phase and copy progress. +- `/vm.disk-mirror-complete` switches the VM over to the destination. +- `/vm.disk-mirror-cancel` aborts and keeps the VM on the source. + +```mermaid +stateDiagram-v2 + [*] --> running: disk-mirror-start + running --> ready: background copy finished + ready --> completing: disk-mirror-complete + completing --> completed: all queues switched + completed --> [*] + running --> cancelling: disk-mirror-cancel + ready --> cancelling: disk-mirror-cancel + failed --> cancelling: disk-mirror-cancel + running --> failed: destination I/O error + ready --> failed: destination I/O error + cancelling --> [*] +``` + +While `running`, a background worker copies the existing data block by block. +At the same time every guest write is forwarded to both disks, so once the +copy finishes the two are identical. Reaching `ready` means the two disks are +in sync and stay so until the operator completes or cancels. + +## Operator usage + +The examples use `curl` against the VMM's API socket. Replace the socket path +and the disk identifier with your own. The disk identifier is the device `id` +shown by `vm.info` (the same `id` used when the disk was configured or hot +added). + +### Start a mirror + +The destination image must already exist with the same image format and +logical size as the source. Cloud Hypervisor does not create or resize it. + +```console +curl --unix-socket /tmp/cloud-hypervisor.sock -i \ + -X PUT 'http://localhost/api/v1/vm.disk-mirror-start' \ + -H 'Content-Type: application/json' \ + -d '{"id": "_disk0", "destination_path": "/new/store/disk0.raw"}' +``` + +This switches the disk to a mirroring backend and starts the background copy. +The VM keeps serving I/O throughout. A `204` response means mirroring started. + +### Check progress + +```console +curl --unix-socket /tmp/cloud-hypervisor.sock \ + -X PUT 'http://localhost/api/v1/vm.disk-mirror-status' \ + -H 'Content-Type: application/json' \ + -d '{"id": "_disk0"}' +``` + +The response reports the phase and how far the copy has progressed: + +```json +{"phase": "running", "copied_bytes": 1073741824, "total_bytes": 4294967296} +``` + +`phase` is one of `running`, `ready`, `completing`, `completed`, +`cancelling`, or `failed`. A `failed` status also carries a `failure` field +describing what went wrong. Poll this endpoint until the phase becomes +`ready`. + +### Complete the mirror + +Once the phase is `ready`, switch the VM over to the destination: + +```console +curl --unix-socket /tmp/cloud-hypervisor.sock -i \ + -X PUT 'http://localhost/api/v1/vm.disk-mirror-complete' \ + -H 'Content-Type: application/json' \ + -d '{"id": "_disk0"}' +``` + +The call blocks until the switch-over finishes. On success (`204`) the VM +serves all I/O from the destination disk and the source disk can be removed. +Completion is only accepted from the `ready` phase. A `404` or `400` leaves the +mirror active, so you can fix the cause and retry. + +### Cancel the mirror + +At any time before completion the operator can abort and keep the VM on the +source disk: + +```console +curl --unix-socket /tmp/cloud-hypervisor.sock -i \ + -X PUT 'http://localhost/api/v1/vm.disk-mirror-cancel' \ + -H 'Content-Type: application/json' \ + -d '{"id": "_disk0"}' +``` + +The destination disk is released and the VM continues on the source. Cancel is +refused once completion has been requested, because by then a queue may +already be writing only to the destination. + +### Failure handling + +If the destination disk fails (for example its backing store becomes +unreachable), the mirror moves to `failed` and the affected queues fall back +to serving the guest from the source disk, so the guest keeps running on +intact data. The operator then cancels the failed mirror to release the +destination. + +### Unrecoverable errors + +Completing a mirror cannot be undone. Once the switch to the destination +begins, some virtqueues may already be writing only to the destination, so +there is no consistent state to roll back to. If a queue cannot be switched +over during completion, the VMM aborts. The alternative would leave the disk +half on the source and half on the destination and could lose acknowledged +writes. This is rare: it needs a queue worker to fail mid-swap (for example an +epoll registration error), or its switch-over command to be lost or +unacknowledged. + +### Conflicting operations + +While a mirror is active, the VMM rejects operations that would disturb it: +snapshotting, live migration, resizing the disk, removing the device, and +rebooting, shutting down, or deleting the VM. Complete or cancel the mirror +first. Pausing the VM is allowed, but a mirror cannot be started, completed, +or cancelled while the device is paused. + +## Implementation details + +Mirroring is built from two cooperating pieces and a range lock that keeps +them from corrupting each other: + +```mermaid +flowchart LR + guest[Guest] -->|read / write| mio[MirroringAsyncIo] + mio -->|reads, all writes| src[(Source disk)] + mio -->|writes only| dst[(Destination disk)] + cw[CopyWorker] -->|read block| src + cw -->|write block| dst + mio -.range lock.- rl((RangeLockManager)) + cw -.range lock.- rl +``` + +**CopyWorker.** A background thread copies the source disk to the destination +in 512 KiB blocks. A block that reads back as all zeros is punched as a hole +on the destination instead of being written, so sparse images stay sparse. The +worker updates the copied-byte counter that `vm.disk-mirror-status` reports, +and stops early once the phase becomes terminal. + +**MirroringAsyncIo.** When a mirror starts, each virtqueue worker's `AsyncIo` +backend is swapped for a `MirroringAsyncIo`. It forwards reads to the source +and forwards every mutating operation (`write_vectored`, `fsync`, +`punch_hole`, `write_zeroes`) to both the source and the destination. The +completions of the two sides are awaited inside the write call, so an error on +the destination can be handled before the guest sees the write as done. On a +destination error that queue degrades to source passthrough and the mirror +fails, rather than letting the guest diverge from intact data. + +**Range lock.** The CopyWorker and the guest writes can target overlapping +byte ranges at the same time. Each side takes an exclusive lock on the range +it is about to touch and holds it until its I/O completes, so a copy and a +guest write to the same region cannot interleave into an inconsistent result. +Lookups are over a small set of held ranges, so the lock is cheap in the +common non-overlapping case. From e200e1f46fb79669e29b02053a781538f90515b9 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Mon, 6 Jul 2026 10:50:11 +0200 Subject: [PATCH 33/40] vmm: dedup block device lookup in DeviceManager Refactor the redundant find-by-id scan over block_devices. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- vmm/src/device_manager.rs | 96 +++++++++++++++++---------------------- 1 file changed, 41 insertions(+), 55 deletions(-) diff --git a/vmm/src/device_manager.rs b/vmm/src/device_manager.rs index 657bd1bd8..5f00e0f8d 100644 --- a/vmm/src/device_manager.rs +++ b/vmm/src/device_manager.rs @@ -18,7 +18,7 @@ use std::os::unix::io::{AsRawFd, FromRawFd}; #[cfg(not(target_arch = "riscv64"))] use std::path::Path; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard}; use std::time::Duration; #[cfg(not(target_arch = "riscv64"))] use std::time::Instant; @@ -5270,16 +5270,22 @@ impl DeviceManager { 0 } + /// Locks and returns the block device with the given id. + /// + /// Returns [`DeviceManagerError::UnknownDeviceId`] when no attached + /// block device matches. + fn find_block_device(&self, device_id: &str) -> DeviceManagerResult> { + self.block_devices + .iter() + .map(|dev| dev.lock().unwrap()) + .find(|disk| disk.id() == device_id) + .ok_or_else(|| DeviceManagerError::UnknownDeviceId(device_id.to_string())) + } + pub fn resize_disk(&mut self, device_id: &str, new_size: u64) -> DeviceManagerResult<()> { - for dev in &self.block_devices { - let mut disk = dev.lock().unwrap(); - if disk.id() == device_id { - return disk - .resize(new_size) - .map_err(DeviceManagerError::DiskResize); - } - } - Err(DeviceManagerError::UnknownDeviceId(device_id.to_string())) + self.find_block_device(device_id)? + .resize(new_size) + .map_err(DeviceManagerError::DiskResize) } pub fn device_tree(&self) -> Arc> { @@ -5362,12 +5368,7 @@ impl DeviceManager { /// Returns an error if no disk with the given identifier is attached /// to the VM, or the destination cannot be opened. pub fn mirror_disk(&self, device_id: &str, dest_path: &Path) -> DeviceManagerResult<()> { - let mut disk = self - .block_devices - .iter() - .map(|dev| dev.lock().unwrap()) - .find(|disk| disk.id() == device_id) - .ok_or_else(|| DeviceManagerError::UnknownDeviceId(device_id.to_string()))?; + let mut disk = self.find_block_device(device_id)?; if let Some(status) = disk.mirror_status() { return Err(DeviceManagerError::BlockMirrorAlreadyActive(format!( @@ -5445,11 +5446,7 @@ impl DeviceManager { /// Returns an error if no disk with the given identifier is /// attached to the VM, or if the disk has no active mirror. pub fn mirror_disk_status(&self, device_id: &str) -> DeviceManagerResult { - self.block_devices - .iter() - .map(|dev| dev.lock().unwrap()) - .find(|disk| disk.id() == device_id) - .ok_or_else(|| DeviceManagerError::UnknownDeviceId(device_id.to_string()))? + self.find_block_device(device_id)? .mirror_status() .ok_or_else(|| DeviceManagerError::BlockMirrorNotActive(device_id.to_string())) } @@ -5459,33 +5456,28 @@ impl DeviceManager { /// identifier is attached, if no mirror is active, or if the mirror is not /// yet ready. pub fn mirror_disk_complete(&self, device_id: &str) -> DeviceManagerResult<()> { - for dev in &self.block_devices { - let mut disk = dev.lock().unwrap(); - if disk.id() == device_id { - let new_path = disk.complete_mirror().map_err(|e| { - DeviceManagerError::BlockMirrorComplete(device_id.to_string(), e) - })?; - - // Repoint the config entry so a rebuild reopens the destination. - if let Some(cfg) = self - .config - .lock() - .unwrap() - .disks - .as_mut() - .and_then(|disks| { - disks - .iter_mut() - .find(|d| d.pci_common.id.as_deref() == Some(device_id)) - }) - { - cfg.path = Some(new_path); - } + let mut disk = self.find_block_device(device_id)?; + let new_path = disk + .complete_mirror() + .map_err(|e| DeviceManagerError::BlockMirrorComplete(device_id.to_string(), e))?; - return Ok(()); - } + // Repoint the config entry so a rebuild reopens the destination. + if let Some(cfg) = self + .config + .lock() + .unwrap() + .disks + .as_mut() + .and_then(|disks| { + disks + .iter_mut() + .find(|d| d.pci_common.id.as_deref() == Some(device_id)) + }) + { + cfg.path = Some(new_path); } - Err(DeviceManagerError::UnknownDeviceId(device_id.to_string())) + + Ok(()) } /// Cancels the active block mirror for the disk identified by @@ -5495,15 +5487,9 @@ impl DeviceManager { /// already been attempted, or if reverting the virtqueue workers /// fails. pub fn mirror_disk_cancel(&self, device_id: &str) -> DeviceManagerResult<()> { - for dev in &self.block_devices { - let mut disk = dev.lock().unwrap(); - if disk.id() == device_id { - return disk - .cancel_mirror() - .map_err(|e| DeviceManagerError::BlockMirrorCancel(device_id.to_string(), e)); - } - } - Err(DeviceManagerError::UnknownDeviceId(device_id.to_string())) + self.find_block_device(device_id)? + .cancel_mirror() + .map_err(|e| DeviceManagerError::BlockMirrorCancel(device_id.to_string(), e)) } /// Helps the environment converge quickly after a live migration by From b605c6a818151178dfb3e208cccf6cc6eb577b72 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Mon, 13 Jul 2026 11:26:34 +0200 Subject: [PATCH 34/40] virtio-devices: generalize lock granularity Calculate advisory lock granularity for an explicitly supplied disk backend and path instead of always using Block::disk_image. This lets mirror destinations reuse the configured locking policy. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- virtio-devices/src/block.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/virtio-devices/src/block.rs b/virtio-devices/src/block.rs index 13c2981f2..9e7b40545 100644 --- a/virtio-devices/src/block.rs +++ b/virtio-devices/src/block.rs @@ -13,7 +13,7 @@ use std::collections::{BTreeMap, HashMap, VecDeque}; use std::num::Wrapping; use std::ops::Deref; use std::os::unix::io::AsRawFd; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Barrier, Mutex, mpsc}; @@ -1194,16 +1194,20 @@ impl Block { has_feature(self.features(), VIRTIO_BLK_F_RO.into()) } - /// Returns the granularity for the advisory lock for this disk. - fn lock_granularity(&mut self) -> LockGranularity { + /// Returns the configured advisory lock granularity for `disk_image`. + fn lock_granularity( + &self, + disk_image: &dyn AsyncFullDiskFile, + disk_path: &Path, + ) -> LockGranularity { match self.lock_granularity_choice { LockGranularityChoice::Full => LockGranularity::WholeFile, LockGranularityChoice::ByteRange => { // Byte range lock covering [0, max(logical, physical)) // logical > physical for sparse files, physical > logical // for small dense files due to filesystem block rounding. - let logical = self.disk_image.logical_size(); - let physical = self.disk_image.physical_size(); + let logical = disk_image.logical_size(); + let physical = disk_image.physical_size(); match (logical, physical) { (Ok(l), Ok(p)) => LockGranularity::ByteRange(0, max(l, p)), (Ok(l), Err(_)) => LockGranularity::ByteRange(0, l), @@ -1213,7 +1217,7 @@ impl Block { warn!( "Can't get disk size for id={},path={}, falling back to {:?}: error: {e}", self.id, - self.disk_path.display(), + disk_path.display(), fallback ); fallback @@ -1230,7 +1234,7 @@ impl Block { true => LockType::Read, false => LockType::Write, }; - let granularity = self.lock_granularity(); + let granularity = self.lock_granularity(self.disk_image.as_ref(), &self.disk_path); debug!( "Attempting to acquire {lock_type:?} lock for disk image: id={},path={},granularity={granularity:?}", self.id, @@ -1263,7 +1267,7 @@ impl Block { /// Releases the advisory lock held for the corresponding disk image. pub fn unlock_image(&mut self) -> Result<()> { - let granularity = self.lock_granularity(); + let granularity = self.lock_granularity(self.disk_image.as_ref(), &self.disk_path); // It is very unlikely that this fails; // Should we remove the Result to simplify the error propagation on From e75f4215c3d91f7b43b08e71baa6d44112a33010 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Mon, 13 Jul 2026 11:29:09 +0200 Subject: [PATCH 35/40] virtio-devices: generalize disk locking Extract advisory lock acquisition into a helper that accepts a disk backend, path, requested mode, and current mode. Keep try_lock_image as the source-disk wrapper. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- virtio-devices/src/block.rs | 45 +++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/virtio-devices/src/block.rs b/virtio-devices/src/block.rs index 9e7b40545..2a155f3e4 100644 --- a/virtio-devices/src/block.rs +++ b/virtio-devices/src/block.rs @@ -1228,43 +1228,60 @@ impl Block { } } - /// Tries to set an advisory lock for the corresponding disk image. - pub fn try_lock_image(&mut self) -> Result<()> { - let lock_type = match self.read_only() { - true => LockType::Read, - false => LockType::Write, - }; - let granularity = self.lock_granularity(self.disk_image.as_ref(), &self.disk_path); + /// Acquires an advisory lock for an arbitrary disk backend. + fn try_lock_disk_image( + &self, + disk_image: &dyn AsyncFullDiskFile, + disk_path: &Path, + lock_type: LockType, + current_lock: LockType, + ) -> Result<()> { + let granularity = self.lock_granularity(disk_image, disk_path); debug!( "Attempting to acquire {lock_type:?} lock for disk image: id={},path={},granularity={granularity:?}", self.id, - self.disk_path.display() + disk_path.display() ); - let fd = self.disk_image.fd(); + let fd = disk_image.fd(); granularity - .try_acquire_lock(&fd, lock_type, self.held_lock) + .try_acquire_lock(&fd, lock_type, current_lock) .map_err(|error| { error!( "Cannot acquire {lock_type:?} lock for disk image: id={},path={},granularity={granularity:?}", self.id, - self.disk_path.display() + disk_path.display() ); Error::LockDiskImage { - path: self.disk_path.clone(), + path: disk_path.to_path_buf(), error, lock_type, } })?; - self.held_lock = lock_type; info!( "Acquired {lock_type:?} lock for disk image id={},path={}", self.id, - self.disk_path.display() + disk_path.display() ); Ok(()) } + /// Tries to set an advisory lock for the corresponding disk image. + pub fn try_lock_image(&mut self) -> Result<()> { + let lock_type = match self.read_only() { + true => LockType::Read, + false => LockType::Write, + }; + self.try_lock_disk_image( + self.disk_image.as_ref(), + &self.disk_path, + lock_type, + self.held_lock, + )?; + self.held_lock = lock_type; + Ok(()) + } + /// Releases the advisory lock held for the corresponding disk image. pub fn unlock_image(&mut self) -> Result<()> { let granularity = self.lock_granularity(self.disk_image.as_ref(), &self.disk_path); From f521887a429fa4a5357bd8a5810b8f07930b90f3 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Mon, 13 Jul 2026 11:29:28 +0200 Subject: [PATCH 36/40] virtio-devices, vmm: lock mirror destination Acquire a write lock on the destination before installing mirror queue backends. The destination uses the source disk locking granularity and retains the lock through its open file description. On completion, retain a write lock for writable disks and downgrade to a read lock for read-only disks. Report a lock conflict as HTTP 400. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/error.rs | 3 +++ virtio-devices/src/block.rs | 26 ++++++++++++++++++++++++++ vmm/src/api/http/http_endpoint.rs | 6 ++++++ vmm/src/device_manager.rs | 20 ++++++++++++++++++-- 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/block/src/error.rs b/block/src/error.rs index 518c6a875..112a6ee6b 100644 --- a/block/src/error.rs +++ b/block/src/error.rs @@ -29,6 +29,9 @@ pub enum MirrorError { /// A mirror completion is already in progress. #[error("Mirror completion already in progress")] CompletionInProgress, + /// The mirror destination advisory lock could not be acquired. + #[error("Failed to lock the mirror destination")] + DestinationLock, /// A mirror operation was requested before the device was activated. #[error("Mirror operation rejected: the device is not active")] DeviceNotActive, diff --git a/virtio-devices/src/block.rs b/virtio-devices/src/block.rs index 2a155f3e4..699814fbc 100644 --- a/virtio-devices/src/block.rs +++ b/virtio-devices/src/block.rs @@ -1373,6 +1373,10 @@ impl Block { /// The [`MirroringAsyncIo`] stays in place until completion, keeping the device's /// disk and `destination` in sync. /// + /// The destination is write-locked before queue installation. Its open file + /// description retains that lock until completion transfers the backend to + /// the device or cancellation drops the final destination descriptor. + /// /// Returns an error if the destination size differs from the source, on /// `logical_size()` failure, [`MirroringAsyncIo`] construction failure, or /// copy worker spawn failure. @@ -1399,6 +1403,14 @@ impl Block { )); } + self.try_lock_disk_image( + destination.as_ref(), + &destination_path, + LockType::Write, + LockType::Unlock, + ) + .map_err(|e| BlockError::new(BlockErrorKind::Mirror(MirrorError::DestinationLock), e))?; + let state = MirrorState::new(source_size); let (commands, ack_rx) = self.create_mirror_queue_commands( BlockQueueCommandKind::InstallMirror, @@ -1482,6 +1494,19 @@ impl Block { ))); } + let destination_lock = if self.read_only() { + LockType::Read + } else { + LockType::Write + }; + self.try_lock_disk_image( + handle.destination.as_ref(), + &handle.destination_path, + destination_lock, + LockType::Write, + ) + .map_err(|e| BlockError::new(BlockErrorKind::Mirror(MirrorError::DestinationLock), e))?; + let (commands, ack_rx) = self.create_mirror_queue_commands( BlockQueueCommandKind::CompleteToDestination, |ring_depth| handle.destination.create_async_io(ring_depth), @@ -1519,6 +1544,7 @@ impl Block { self.disk_image = destination; self.disk_path = destination_path.clone(); + self.held_lock = destination_lock; Ok(destination_path) } diff --git a/vmm/src/api/http/http_endpoint.rs b/vmm/src/api/http/http_endpoint.rs index c71171a7b..810e1d377 100644 --- a/vmm/src/api/http/http_endpoint.rs +++ b/vmm/src/api/http/http_endpoint.rs @@ -535,6 +535,12 @@ vm_action_put_handler_body!(VmDiskMirrorStart, |e| match &e { ApiError::VmDiskMirrorStart(VmError::DeviceManager( DeviceManagerError::BlockMirrorDestinationInUse(_), )) => HttpError::BadRequest, + ApiError::VmDiskMirrorStart(VmError::DeviceManager( + DeviceManagerError::BlockMirrorDestinationLock(_, _), + )) => HttpError::BadRequest, + ApiError::VmDiskMirrorStart(VmError::DeviceManager( + DeviceManagerError::DiskImageTypeMismatch { .. }, + )) => HttpError::BadRequest, _ => HttpError::ApiError(e), }); diff --git a/vmm/src/device_manager.rs b/vmm/src/device_manager.rs index 5f00e0f8d..91d9ca078 100644 --- a/vmm/src/device_manager.rs +++ b/vmm/src/device_manager.rs @@ -34,7 +34,7 @@ use arch::layout::{APIC_START, IOAPIC_SIZE, IOAPIC_START}; use arch::{DeviceType, MmioDeviceInfo}; use arch::{NumaNodes, layout}; use block::ImageType; -use block::error::BlockError; +use block::error::{BlockError, BlockErrorKind, MirrorError}; use block::factory::{DiskOpenOptions, OpenedDisk, open_disk}; use block::mirror::MirrorStatus; #[cfg(target_arch = "riscv64")] @@ -695,6 +695,10 @@ pub enum DeviceManagerError { #[error("Cannot mirror to '{0}': it is already in use as a disk image by this VM")] BlockMirrorDestinationInUse(String), + /// The mirror destination could not be locked for exclusive access. + #[error("Cannot mirror to '{0}': failed to lock the destination")] + BlockMirrorDestinationLock(String, #[source] BlockError), + /// Cannot perform given action, as the device is currently performing a block mirroring operation. #[error( "Failed to perform the requested action for the disk with identifier: {0} as it is currently performing a block mirroring operation" @@ -5435,7 +5439,19 @@ impl DeviceManager { }; disk.start_mirror(destination, dest_path.to_path_buf()) - .map_err(DeviceManagerError::Disk)?; + .map_err(|e| { + if matches!( + e.kind(), + BlockErrorKind::Mirror(MirrorError::DestinationLock) + ) { + DeviceManagerError::BlockMirrorDestinationLock( + dest_path.display().to_string(), + e, + ) + } else { + DeviceManagerError::Disk(e) + } + })?; Ok(()) } From f271514c6046fc62f35e42bd533759d3f4b37395 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Mon, 20 Jul 2026 10:24:18 +0200 Subject: [PATCH 37/40] block: release stale QEMU lock after downgrade QEMU-compatible lock transitions acquire the marker bytes needed for the new state before checking for conflicts. On failure, they restore the previous state. After a successful downgrade, however, marker bytes used only by the previous state remain locked. This affects mirroring of read-only disks. The destination needs a write lock while Cloud Hypervisor copies data to it. When the mirror completes, the destination replaces the source and must return to the source disk's read-only lock state. Keeping the stale write marker prevents another reader from locking the image. Release marker bytes that are not needed by the new state after the conflict checks succeed. Add a test that downgrades a write lock to a read lock and verifies that another reader can acquire the image. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/fcntl.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/block/src/fcntl.rs b/block/src/fcntl.rs index f5cb626c0..7184fa818 100644 --- a/block/src/fcntl.rs +++ b/block/src/fcntl.rs @@ -248,6 +248,8 @@ impl LockGranularity { let _ = self.release_unneeded_locks_qemu(file, current_lock_status); return Err(error); } + + self.release_unneeded_locks_qemu(file, lock_type)?; Ok(()) } @@ -369,3 +371,31 @@ impl FromStr for LockGranularityChoice { } } } + +#[cfg(test)] +mod tests { + use std::fs::OpenOptions; + + use vmm_sys_util::tempfile::TempFile; + + use super::{LockGranularity, LockType}; + + #[test] + fn qemu_lock_downgrade_allows_another_reader() { + let disk = TempFile::new().unwrap(); + let other_reader = OpenOptions::new() + .read(true) + .write(true) + .open(disk.as_path()) + .unwrap(); + let lock = LockGranularity::QemuCompatible; + + lock.try_acquire_lock(disk.as_file(), LockType::Write, LockType::Unlock) + .unwrap(); + lock.try_acquire_lock(disk.as_file(), LockType::Read, LockType::Write) + .unwrap(); + + lock.try_acquire_lock(&other_reader, LockType::Read, LockType::Unlock) + .unwrap(); + } +} From c2142b438636199db132d32e18eeb0d9c75d789a Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Mon, 20 Jul 2026 14:18:40 +0200 Subject: [PATCH 38/40] block: reject QCOW2 backing chains for mirroring Block mirroring copies a disk's logical contents to the destination. For QCOW2 images with backing files, this would flatten the image at the destination and discard the backing-chain structure. We add a disk backend validation hook and reject mirroring when either the source or destination QCOW2 image has a backing file. This validates the source before creating the destination. Hence, a rejected request does not leave a new file behind. Return HTTP 400 for unsupported images. Standalone QCOW2 images remain supported. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- block/src/disk_file.rs | 9 ++- block/src/error.rs | 3 + block/src/qcow_disk.rs | 14 +++- block/src/raw_disk.rs | 6 +- docs/disk_mirroring.md | 4 ++ virtio-devices/src/block.rs | 102 ++++++++++++++++++++++++++++++ vmm/src/api/http/http_endpoint.rs | 3 + vmm/src/device_manager.rs | 21 ++++-- 8 files changed, 150 insertions(+), 12 deletions(-) diff --git a/block/src/disk_file.rs b/block/src/disk_file.rs index 7f044ea7e..49d54ea52 100644 --- a/block/src/disk_file.rs +++ b/block/src/disk_file.rs @@ -36,7 +36,7 @@ use std::fmt::Debug; use crate::async_io::{AsyncIo, BorrowedDiskFd}; -use crate::{BlockResult, DiskTopology}; +use crate::{BlockError, BlockErrorKind, BlockResult, DiskTopology}; /// Reported capacity of a disk image. pub trait DiskSize: Send + Debug { @@ -96,7 +96,12 @@ pub trait Resizable: Send + Debug { /// Every disk format implements `DiskSize` and `Geometry`. /// `Sync` is required so that `Arc` can be shared /// across threads for concurrent readonly access. -pub trait DiskFile: DiskSize + Geometry + Sync {} +pub trait DiskFile: DiskSize + Geometry + Sync { + /// Returns an error if this disk image cannot participate in block mirroring. + fn supports_mirroring(&self) -> BlockResult<()> { + Err(BlockError::from_kind(BlockErrorKind::UnsupportedFeature)) + } +} /// Full capability disk file trait. /// diff --git a/block/src/error.rs b/block/src/error.rs index 112a6ee6b..6e85719e2 100644 --- a/block/src/error.rs +++ b/block/src/error.rs @@ -26,6 +26,9 @@ use thiserror::Error; /// Errors reported by block mirroring operations. #[derive(Debug, Copy, Clone, Eq, PartialEq, Error)] pub enum MirrorError { + /// Block mirroring does not support disk images with backing files. + #[error("Block mirroring does not support disk images with backing files")] + BackingFileUnsupported, /// A mirror completion is already in progress. #[error("Mirror completion already in progress")] CompletionInProgress, diff --git a/block/src/qcow_disk.rs b/block/src/qcow_disk.rs index ef2730526..5709b9cbc 100644 --- a/block/src/qcow_disk.rs +++ b/block/src/qcow_disk.rs @@ -9,7 +9,7 @@ use std::{fmt, io}; use crate::async_io::{AsyncIo, BorrowedDiskFd, DiskFileError}; use crate::disk_file; -use crate::error::{BlockError, BlockErrorKind, BlockResult, ErrorOp}; +use crate::error::{BlockError, BlockErrorKind, BlockResult, ErrorOp, MirrorError}; use crate::qcow::backing::shared_backing_from; use crate::qcow::metadata::{BackingRead, QcowMetadata}; use crate::qcow::qcow_raw_file::QcowRawFile; @@ -142,7 +142,17 @@ impl disk_file::Resizable for QcowDisk { } } -impl disk_file::DiskFile for QcowDisk {} +impl disk_file::DiskFile for QcowDisk { + fn supports_mirroring(&self) -> BlockResult<()> { + if self.backing_file.is_some() { + return Err(BlockError::from_kind(BlockErrorKind::Mirror( + MirrorError::BackingFileUnsupported, + ))); + } + + Ok(()) + } +} impl disk_file::AsyncDiskFile for QcowDisk { fn try_clone(&self) -> BlockResult> { diff --git a/block/src/raw_disk.rs b/block/src/raw_disk.rs index 82dd3c530..2d6bc1d7f 100644 --- a/block/src/raw_disk.rs +++ b/block/src/raw_disk.rs @@ -113,7 +113,11 @@ impl disk_file::Resizable for RawDisk { } } -impl disk_file::DiskFile for RawDisk {} +impl disk_file::DiskFile for RawDisk { + fn supports_mirroring(&self) -> BlockResult<()> { + Ok(()) + } +} impl disk_file::AsyncDiskFile for RawDisk { fn try_clone(&self) -> BlockResult> { diff --git a/docs/disk_mirroring.md b/docs/disk_mirroring.md index 92c216b3f..df1a39385 100644 --- a/docs/disk_mirroring.md +++ b/docs/disk_mirroring.md @@ -60,6 +60,10 @@ curl --unix-socket /tmp/cloud-hypervisor.sock -i \ This switches the disk to a mirroring backend and starts the background copy. The VM keeps serving I/O throughout. A `204` response means mirroring started. +Mirroring supports standalone QCOW2 images. QCOW2 sources and destinations +with backing files are rejected because mirroring copies the full logical +contents and would flatten the image. + ### Check progress ```console diff --git a/virtio-devices/src/block.rs b/virtio-devices/src/block.rs index 699814fbc..b2577b8ea 100644 --- a/virtio-devices/src/block.rs +++ b/virtio-devices/src/block.rs @@ -1385,6 +1385,9 @@ impl Block { destination: Box, destination_path: PathBuf, ) -> BlockResult<()> { + self.supports_mirroring()?; + destination.supports_mirroring()?; + // Mirroring requires activation to have installed at least one live queue worker. if self.common.epoll_threads.is_none() || self.queue_cmd_senders.is_empty() { return Err(BlockError::from_kind(BlockErrorKind::Mirror( @@ -1459,6 +1462,11 @@ impl Block { Ok(()) } + /// Returns an error if this disk image cannot participate in block mirroring. + pub fn supports_mirroring(&self) -> BlockResult<()> { + self.disk_image.supports_mirroring() + } + /// Switch the device's mirroring wrapper to the destination disk. /// /// Each virtqueue worker swaps its [`MirroringAsyncIo`] for a plain @@ -2005,3 +2013,97 @@ impl Snapshottable for Block { } impl Transportable for Block {} impl Migratable for Block {} + +#[cfg(test)] +mod unit_tests { + use block::qcow::{BackingFileConfig, ImageType, QcowFile, RawFile}; + use block::qcow_disk::QcowDisk; + use vmm_sys_util::tempfile::TempFile; + + use super::*; + + const TEST_DISK_SIZE: u64 = 1 << 20; + + fn qcow2_disk(with_backing_file: bool) -> (TempFile, Box) { + let image = TempFile::new().unwrap(); + let backing = with_backing_file.then(|| TempFile::new().unwrap()); + + if let Some(backing) = &backing { + backing.as_file().set_len(TEST_DISK_SIZE).unwrap(); + let backing_config = BackingFileConfig { + path: backing.as_path().to_string_lossy().into_owned(), + format: Some(ImageType::Raw), + }; + let raw = RawFile::new(image.as_file().try_clone().unwrap(), false); + QcowFile::new_from_backing(raw, 3, TEST_DISK_SIZE, &backing_config, true).unwrap(); + } else { + let raw = RawFile::new(image.as_file().try_clone().unwrap(), false); + QcowFile::new(raw, 3, TEST_DISK_SIZE, true).unwrap(); + } + + let disk = QcowDisk::new( + image.as_file().try_clone().unwrap(), + false, + with_backing_file, + true, + false, + ) + .unwrap(); + + (image, Box::new(disk)) + } + + fn block_with_disk(disk_path: &Path, disk: Box) -> Block { + Block::new( + "test".to_string(), + disk, + disk_path.to_path_buf(), + false, + false, + 1, + 128, + None, + SeccompAction::Allow, + None, + EventFd::new(libc::EFD_NONBLOCK).unwrap(), + None, + BTreeMap::new(), + true, + false, + LockGranularityChoice::QemuCompatible, + ) + .unwrap() + } + + #[test] + fn mirror_rejects_qcow2_backing_source() { + let (source_file, source) = qcow2_disk(true); + let (destination_file, destination) = qcow2_disk(false); + let mut block = block_with_disk(source_file.as_path(), source); + + let error = block + .start_mirror(destination, destination_file.as_path().to_path_buf()) + .unwrap_err(); + + assert_eq!( + error.kind(), + BlockErrorKind::Mirror(MirrorError::BackingFileUnsupported) + ); + } + + #[test] + fn mirror_rejects_qcow2_backing_destination() { + let (source_file, source) = qcow2_disk(false); + let (destination_file, destination) = qcow2_disk(true); + let mut block = block_with_disk(source_file.as_path(), source); + + let error = block + .start_mirror(destination, destination_file.as_path().to_path_buf()) + .unwrap_err(); + + assert_eq!( + error.kind(), + BlockErrorKind::Mirror(MirrorError::BackingFileUnsupported) + ); + } +} diff --git a/vmm/src/api/http/http_endpoint.rs b/vmm/src/api/http/http_endpoint.rs index 810e1d377..0c26ba081 100644 --- a/vmm/src/api/http/http_endpoint.rs +++ b/vmm/src/api/http/http_endpoint.rs @@ -538,6 +538,9 @@ vm_action_put_handler_body!(VmDiskMirrorStart, |e| match &e { ApiError::VmDiskMirrorStart(VmError::DeviceManager( DeviceManagerError::BlockMirrorDestinationLock(_, _), )) => HttpError::BadRequest, + ApiError::VmDiskMirrorStart(VmError::DeviceManager( + DeviceManagerError::BlockMirrorUnsupported(_, _), + )) => HttpError::BadRequest, ApiError::VmDiskMirrorStart(VmError::DeviceManager( DeviceManagerError::DiskImageTypeMismatch { .. }, )) => HttpError::BadRequest, diff --git a/vmm/src/device_manager.rs b/vmm/src/device_manager.rs index 91d9ca078..942fc8f1e 100644 --- a/vmm/src/device_manager.rs +++ b/vmm/src/device_manager.rs @@ -699,6 +699,10 @@ pub enum DeviceManagerError { #[error("Cannot mirror to '{0}': failed to lock the destination")] BlockMirrorDestinationLock(String, #[source] BlockError), + /// The source or destination disk image does not support block mirroring. + #[error("Cannot mirror disk with identifier '{0}': {1}")] + BlockMirrorUnsupported(String, #[source] BlockError), + /// Cannot perform given action, as the device is currently performing a block mirroring operation. #[error( "Failed to perform the requested action for the disk with identifier: {0} as it is currently performing a block mirroring operation" @@ -5381,6 +5385,9 @@ impl DeviceManager { ))); } + disk.supports_mirroring() + .map_err(|e| DeviceManagerError::BlockMirrorUnsupported(device_id.to_string(), e))?; + // Refuse a destination that already backs one of this VM's disks, comparing canonicalized paths. let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()); let dest_canon = canon(dest_path); @@ -5439,18 +5446,18 @@ impl DeviceManager { }; disk.start_mirror(destination, dest_path.to_path_buf()) - .map_err(|e| { - if matches!( - e.kind(), - BlockErrorKind::Mirror(MirrorError::DestinationLock) - ) { + .map_err(|e| match e.kind() { + BlockErrorKind::Mirror(MirrorError::DestinationLock) => { DeviceManagerError::BlockMirrorDestinationLock( dest_path.display().to_string(), e, ) - } else { - DeviceManagerError::Disk(e) } + BlockErrorKind::Mirror(MirrorError::BackingFileUnsupported) + | BlockErrorKind::UnsupportedFeature => { + DeviceManagerError::BlockMirrorUnsupported(device_id.to_string(), e) + } + _ => DeviceManagerError::Disk(e), })?; Ok(()) From 0d089e78ae2a84b566bcb7614dc363137beac1e9 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Tue, 21 Jul 2026 15:32:28 +0200 Subject: [PATCH 39/40] vmm: generalize postponed lifecycle events The postponed lifecycle event describes a deferred reboot or shutdown, but its type and helpers are named after live migration. Rename them to describe the lifecycle operation itself and centralize event replay. This removes duplicate reboot and shutdown dispatch while preserving live-migration behavior. It enables reuse within blockdev-mirroring. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- vmm/src/lib.rs | 86 +++++++++++++++++++++++--------------------------- vmm/src/vm.rs | 16 +++++----- 2 files changed, 47 insertions(+), 55 deletions(-) diff --git a/vmm/src/lib.rs b/vmm/src/lib.rs index 52f2edd8b..e0bb13728 100644 --- a/vmm/src/lib.rs +++ b/vmm/src/lib.rs @@ -78,7 +78,7 @@ use crate::migration_transport::{ ReceiveAdditionalConnections, ReceiveListener, SendAdditionalConnections, SocketStream, }; use crate::seccomp_filters::{Thread, get_seccomp_filter}; -use crate::vm::{Error as VmError, PostMigrationLifecycleEvent, Vm, VmState}; +use crate::vm::{Error as VmError, PostponedLifecycleEvent, Vm, VmState}; use crate::vm_config::{ DeviceConfig, DiskConfig, FsConfig, GenericVhostUserConfig, MemoryZoneConfig, NetConfig, PmemConfig, UserDeviceConfig, VdpaConfig, VmConfig, VsockConfig, @@ -682,7 +682,7 @@ struct MigrationWorker { check_migration_evt: EventFd, config: VmSendMigrationData, // Shared with main VMM thread - postponed_lifecycle_event: Arc>>, + postponed_lifecycle_event: Arc>>, #[cfg(all(feature = "kvm", target_arch = "x86_64"))] hypervisor: Arc, cancel: Arc, @@ -724,7 +724,7 @@ impl MigrationWorker { vm: Vm, check_migration_evt: EventFd, config: VmSendMigrationData, - postponed_lifecycle_event: Arc>>, + postponed_lifecycle_event: Arc>>, #[cfg(all(feature = "kvm", target_arch = "x86_64"))] hypervisor: Arc< dyn hypervisor::Hypervisor, >, @@ -872,12 +872,24 @@ pub struct Vmm { console_info: Option, no_shutdown: bool, check_migration_evt: EventFd, - postponed_lifecycle_event: Arc>>, - received_postponed_lifecycle_event: Option, + postponed_lifecycle_event: Arc>>, + received_postponed_lifecycle_event: Option, /// Handle to the [`MigrationWorker`] thread. migration_thread_handle: Option, } +/// Replays a postponed guest lifecycle event. +fn replay_lifecycle_event( + event: PostponedLifecycleEvent, + reset_evt: &EventFd, + guest_exit_evt: &EventFd, +) -> io::Result<()> { + match event { + PostponedLifecycleEvent::VmReboot => reset_evt.write(1), + PostponedLifecycleEvent::VmShutdown => guest_exit_evt.write(1), + } +} + /// Just a wrapper for the data that goes into /// [`ReceiveMigrationState::Configured`] struct ReceiveMigrationConfiguredData { @@ -1108,7 +1120,7 @@ impl Vmm { }) } - fn postpone_lifecycle_event_during_migration(&self, event: PostMigrationLifecycleEvent) { + fn postpone_lifecycle_event(&self, event: PostponedLifecycleEvent) { let mut postponed_event = self.postponed_lifecycle_event.lock().unwrap(); if postponed_event.is_none() { *postponed_event = Some(event); @@ -1116,15 +1128,23 @@ impl Vmm { } } - fn current_postponed_lifecycle_event(&self) -> Option { - *self.postponed_lifecycle_event.lock().unwrap() - } - fn clear_postponed_lifecycle_event(&self) { let mut postponed_event = self.postponed_lifecycle_event.lock().unwrap(); *postponed_event = None; } + /// Replays and clears the postponed lifecycle event. + fn replay_postponed_lifecycle_event(&self) { + let mut postponed_event = self.postponed_lifecycle_event.lock().unwrap(); + let Some(event) = *postponed_event else { + return; + }; + match replay_lifecycle_event(event, &self.reset_evt, &self.guest_exit_evt) { + Ok(()) => *postponed_event = None, + Err(e) => error!("Failed replaying postponed lifecycle event: {e}"), + } + } + /// Try to receive a file descriptor from a socket. Returns the slot number and the file descriptor. fn vm_receive_memory_fd( socket: &mut SocketStream, @@ -1306,16 +1326,9 @@ impl Vmm { resume_duration.as_millis() ); } - Some(PostMigrationLifecycleEvent::VmReboot) => { - self.reset_evt - .write(1) - .context("Failed writing reset eventfd after migration") - .map_err(MigratableError::MigrateReceive)?; - } - Some(PostMigrationLifecycleEvent::VmShutdown) => { - self.guest_exit_evt - .write(1) - .context("Failed writing guest exit eventfd after migration") + Some(event) => { + replay_lifecycle_event(event, &self.reset_evt, &self.guest_exit_evt) + .context("Failed replaying lifecycle event after migration") .map_err(MigratableError::MigrateReceive)?; } } @@ -1592,7 +1605,7 @@ impl Vmm { ctx: &mut MemoryMigrationContext, is_converged: impl Fn(&MemoryMigrationContext) -> result::Result, mem_send: &mut SendAdditionalConnections, - postponed_lifecycle_event: &Mutex>, + postponed_lifecycle_event: &Mutex>, return_if_cancelled_cb: &impl Fn(&mut SocketStream) -> result::Result<(), MigratableError>, ) -> result::Result { let total_memory_size_bytes = vm @@ -1803,7 +1816,7 @@ impl Vmm { send_data_migration: &VmSendMigrationData, mem_send: &mut SendAdditionalConnections, ctx: &mut OngoingMigrationContext, - postponed_lifecycle_event: &Mutex>, + postponed_lifecycle_event: &Mutex>, return_if_cancelled_cb: &impl Fn(&mut SocketStream) -> result::Result<(), MigratableError>, ) -> result::Result<(), MigratableError> { let mut mem_ctx = MemoryMigrationContext::new(); @@ -1861,7 +1874,7 @@ impl Vmm { #[cfg(all(feature = "kvm", target_arch = "x86_64"))] hypervisor: &dyn hypervisor::Hypervisor, send_data_migration: &VmSendMigrationData, - postponed_lifecycle_event: &Mutex>, + postponed_lifecycle_event: &Mutex>, cancel: Arc, ) -> result::Result<(), MigratableError> { // State machine that is updated with more context as we progress. @@ -2257,24 +2270,7 @@ impl Vmm { // Give VMM back control. self.vm = MaybeVmOwnership::Vmm(vm); - if let Some(event) = self.current_postponed_lifecycle_event() { - match event { - PostMigrationLifecycleEvent::VmReboot => { - self.reset_evt - .write(1) - .context("Failed replaying reset event after failed migration") - .inspect_err(|write_err| error!("{write_err}")) - .ok(); - } - PostMigrationLifecycleEvent::VmShutdown => { - self.guest_exit_evt - .write(1) - .context("Failed replaying guest exit event after failed migration") - .inspect_err(|write_err| error!("{write_err}")) - .ok(); - } - } - } + self.replay_postponed_lifecycle_event(); }; match migration_res { @@ -2380,9 +2376,7 @@ impl Vmm { self.reset_evt.read().map_err(Error::EventFdRead)?; // Workaround for guest-induced shutdown during a live-migration. if matches!(self.vm, MaybeVmOwnership::Migration(_)) { - self.postpone_lifecycle_event_during_migration( - PostMigrationLifecycleEvent::VmReboot, - ); + self.postpone_lifecycle_event(PostponedLifecycleEvent::VmReboot); continue; } self.vm_reboot().map_err(Error::VmReboot)?; @@ -2392,9 +2386,7 @@ impl Vmm { self.guest_exit_evt.read().map_err(Error::EventFdRead)?; // Workaround for guest-induced shutdown during a live-migration. if matches!(self.vm, MaybeVmOwnership::Migration(_)) { - self.postpone_lifecycle_event_during_migration( - PostMigrationLifecycleEvent::VmShutdown, - ); + self.postpone_lifecycle_event(PostponedLifecycleEvent::VmShutdown); continue; } if self.no_shutdown { diff --git a/vmm/src/vm.rs b/vmm/src/vm.rs index 9a182a441..3a3b83cc9 100644 --- a/vmm/src/vm.rs +++ b/vmm/src/vm.rs @@ -562,11 +562,11 @@ pub struct Vm { stop_on_boot: bool, load_payload_handle: Option>>, vcpu_throttler: ThrottleThreadHandle, - post_migration_lifecycle_event: Option, + post_migration_lifecycle_event: Option, } #[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum PostMigrationLifecycleEvent { +pub enum PostponedLifecycleEvent { VmReboot, VmShutdown, } @@ -1411,14 +1411,14 @@ impl Vm { self.vcpu_throttler.shutdown(); } - pub fn set_post_migration_lifecycle_event( - &mut self, - event: Option, - ) { + /// Stores a lifecycle event until migration and active disk mirrors permit + /// it to be replayed. + pub fn set_post_migration_lifecycle_event(&mut self, event: Option) { self.post_migration_lifecycle_event = event; } - pub fn post_migration_lifecycle_event(&self) -> Option { + /// Returns the lifecycle event currently waiting to be replayed. + pub fn post_migration_lifecycle_event(&self) -> Option { self.post_migration_lifecycle_event } @@ -3451,7 +3451,7 @@ impl Pausable for Vm { #[derive(Serialize, Deserialize)] pub struct VmSnapshot { #[serde(default)] - pub post_migration_lifecycle_event: Option, + pub post_migration_lifecycle_event: Option, #[cfg(target_arch = "x86_64")] pub clock: Option, #[cfg(all(feature = "kvm", target_arch = "x86_64"))] From 10dddd4abde931363f7423e81de6c2541bd32e39 Mon Sep 17 00:00:00 2001 From: Leander Kohler Date: Tue, 21 Jul 2026 15:54:34 +0200 Subject: [PATCH 40/40] vmm: postpone guest lifecycle for disk mirrors Guest-initiated reboot and shutdown currently reset block devices and cancel active mirrors. This loses the mirror operation before the operator can complete or cancel it. Instead, this change keeps the mirror and copy worker alive when the VM stops. Record the guest lifecycle request and replay it after the operator resolves the last active mirror. Clear stale queue command senders during reset so offline mirror completion and cancellation do not wait for terminated workers. When replaying, skip a second Vm::shutdown() because the VM was already stopped while postponing the event. On-behalf-of: SAP leander.kohler@sap.com Signed-off-by: Leander Kohler --- docs/disk_mirroring.md | 9 ++-- virtio-devices/src/block.rs | 12 +----- vmm/src/lib.rs | 82 +++++++++++++++++++++++++++---------- 3 files changed, 68 insertions(+), 35 deletions(-) diff --git a/docs/disk_mirroring.md b/docs/disk_mirroring.md index df1a39385..393e58fb8 100644 --- a/docs/disk_mirroring.md +++ b/docs/disk_mirroring.md @@ -139,9 +139,12 @@ unacknowledged. While a mirror is active, the VMM rejects operations that would disturb it: snapshotting, live migration, resizing the disk, removing the device, and -rebooting, shutting down, or deleting the VM. Complete or cancel the mirror -first. Pausing the VM is allowed, but a mirror cannot be started, completed, -or cancelled while the device is paused. +API requests to reboot, shut down, or delete the VM. Complete or cancel the +mirror first. If the guest requests a reboot or shutdown, the VMM stops the +guest but keeps the mirror and API available. The requested lifecycle operation +continues after the operator completes or cancels every active mirror. Pausing +the VM is allowed, but a mirror cannot be started, completed, or cancelled while +the device is paused. ## Implementation details diff --git a/virtio-devices/src/block.rs b/virtio-devices/src/block.rs index b2577b8ea..112e17597 100644 --- a/virtio-devices/src/block.rs +++ b/virtio-devices/src/block.rs @@ -1900,18 +1900,8 @@ impl VirtioDevice for Block { } fn reset(&mut self) { - // Cancel the copy worker without reverting the queues: reverting - // rebuilds an AsyncIo (io_setup) on this vcpu thread, which seccomp - // blocks. The queues are dropped by common.reset() and rebuilt by - // activate() anyway. - if let Some(handle) = self.mirror_handle.take() { - handle.state.transition_to_phase(MirrorPhase::Cancelling); - if let Err(e) = handle.copy_worker.join() { - error!("copy worker thread panicked: {e:?}"); - } - } - self.common.reset(); + self.queue_cmd_senders.clear(); self.draining_active_requests.store(false, Ordering::SeqCst); self.active_request_count.store(0, Ordering::SeqCst); self.set_writeback_mode(true); diff --git a/vmm/src/lib.rs b/vmm/src/lib.rs index e0bb13728..d83e580e0 100644 --- a/vmm/src/lib.rs +++ b/vmm/src/lib.rs @@ -1120,12 +1120,32 @@ impl Vmm { }) } - fn postpone_lifecycle_event(&self, event: PostponedLifecycleEvent) { - let mut postponed_event = self.postponed_lifecycle_event.lock().unwrap(); - if postponed_event.is_none() { - *postponed_event = Some(event); - info!("Postponed post-migration lifecycle event: {event:?}"); + /// Postpones a lifecycle event while migration or disk mirroring is active. + fn postpone_lifecycle_event( + &mut self, + event: PostponedLifecycleEvent, + ) -> result::Result { + let vm = match &mut self.vm { + MaybeVmOwnership::Migration(_) => None, + MaybeVmOwnership::Vmm(vm) if vm.any_active_block_mirrors() => Some(vm), + _ => return Ok(false), + }; + + { + let mut postponed_event = self.postponed_lifecycle_event.lock().unwrap(); + if postponed_event.is_none() { + *postponed_event = Some(event); + info!("Postponed guest lifecycle event: {event:?}"); + } } + + if let Some(vm) = vm + && vm.get_state() != VmState::Shutdown + { + vm.shutdown()?; + } + + Ok(true) } fn clear_postponed_lifecycle_event(&self) { @@ -2374,9 +2394,10 @@ impl Vmm { info!("VM reset event"); // Consume the event. self.reset_evt.read().map_err(Error::EventFdRead)?; - // Workaround for guest-induced shutdown during a live-migration. - if matches!(self.vm, MaybeVmOwnership::Migration(_)) { - self.postpone_lifecycle_event(PostponedLifecycleEvent::VmReboot); + if self + .postpone_lifecycle_event(PostponedLifecycleEvent::VmReboot) + .map_err(Error::VmReboot)? + { continue; } self.vm_reboot().map_err(Error::VmReboot)?; @@ -2384,9 +2405,10 @@ impl Vmm { EpollDispatch::GuestExit => { info!("VM guest exit event"); self.guest_exit_evt.read().map_err(Error::EventFdRead)?; - // Workaround for guest-induced shutdown during a live-migration. - if matches!(self.vm, MaybeVmOwnership::Migration(_)) { - self.postpone_lifecycle_event(PostponedLifecycleEvent::VmShutdown); + if self + .postpone_lifecycle_event(PostponedLifecycleEvent::VmShutdown) + .map_err(Error::VmShutdown)? + { continue; } if self.no_shutdown { @@ -2706,7 +2728,11 @@ impl RequestHandler for Vmm { // Drain console_info so that the FDs are not reused let _ = self.console_info.take(); - let r = vm.shutdown(); + let r = if vm.get_state() == VmState::Shutdown { + Ok(()) + } else { + vm.shutdown() + }; self.vm = MaybeVmOwnership::None; if r.is_ok() { @@ -2731,7 +2757,9 @@ impl RequestHandler for Vmm { } let config = vm.get_config(); - vm.shutdown()?; + if vm.get_state() != VmState::Shutdown { + vm.shutdown()?; + } self.vm = MaybeVmOwnership::None; // vm.shutdown() closes all the console devices, so set console_info to None @@ -3538,21 +3566,33 @@ impl RequestHandler for Vmm { fn vm_disk_mirror_complete(&mut self, id: String) -> result::Result<(), VmError> { self.vm_config.as_ref().ok_or(VmError::VmNotCreated)?; - match self.vm { - MaybeVmOwnership::Vmm(ref mut vm) => vm.mirror_disk_complete(&id), - MaybeVmOwnership::Migration(_) => Err(VmError::VmMigrating), - MaybeVmOwnership::None => Err(VmError::DiskMirrorComplete), + let vm = match self.vm { + MaybeVmOwnership::Vmm(ref mut vm) => vm, + MaybeVmOwnership::Migration(_) => return Err(VmError::VmMigrating), + MaybeVmOwnership::None => return Err(VmError::DiskMirrorComplete), + }; + vm.mirror_disk_complete(&id)?; + + if !vm.any_active_block_mirrors() { + self.replay_postponed_lifecycle_event(); } + Ok(()) } fn vm_disk_mirror_cancel(&mut self, id: String) -> result::Result<(), VmError> { self.vm_config.as_ref().ok_or(VmError::VmNotCreated)?; - match self.vm { - MaybeVmOwnership::Vmm(ref mut vm) => vm.mirror_disk_cancel(&id), - MaybeVmOwnership::Migration(_) => Err(VmError::VmMigrating), - MaybeVmOwnership::None => Err(VmError::DiskMirrorCancel), + let vm = match self.vm { + MaybeVmOwnership::Vmm(ref mut vm) => vm, + MaybeVmOwnership::Migration(_) => return Err(VmError::VmMigrating), + MaybeVmOwnership::None => return Err(VmError::DiskMirrorCancel), + }; + vm.mirror_disk_cancel(&id)?; + + if !vm.any_active_block_mirrors() { + self.replay_postponed_lifecycle_event(); } + Ok(()) } }