OpenVMM: virtio-fs queues never serve requests after a restore behind the post-restore input gate
Maintainers usually reply within 1 day
@ppenna is already working on this.
Since Oct 26, 2026.
Assessment
This issue has not been assessed yet.
Description
Summary
After a restore of a tiered (ABI v2) microVM snapshot, OpenVMM's virtio-fs queue workers end before they serve any request. A virtio-fs share attached at the restore, such as a template clone's directory export bound to a dormant filesystem slot, mounts but never answers: FUSE_INIT and every later FUSE request wait forever. The guest's mount still succeeds, because Linux's virtiofs mount returns before FUSE_INIT completes.
This blocks the export scenarios of #846 (a clone's export and its mapping table) on MSHV and WHP alike.
The fix belongs in OpenVMM (feature/microvm/dev). NVX needs no code change, only a move of its OpenVMM pin once the fix lands.
Affected versions
OpenVMM 79a501b6 and 2b681564. 2b681564 is NVX dev's current pin and the head of feature/microvm/dev as of Oct 26. The virtiofs, virtio, fuse and task_control trees are identical at both commits.
Root cause
- A tiered (ABI v2) snapshot requires the post-restore input gate:
requires_post_restore_gate(openvmm/openvmm_helpers/src/snapshot/microvm.rs:1505), used by openvmm/openvmm_entry/src/microvm/restore.rs:220. - When the gate is armed,
LoadedVm::start_state_units(openvmm/openvmm_core/src/worker/dispatch/restore.rs:315-327) callsquiesce_input_for_savebeforestate_units.start(). - For a microVM virtio-fs device,
quiesce_input(vm/devices/virtio/virtiofs/src/microvm/device.rs:167-171) callsRequestAdmission::quiesce, which setsaccepting = false. state_units.start()then starts the queues.VirtioFsWorker::run(vm/devices/virtio/virtiofs/src/virtio.rs:416-418) getsNonefromadmission.accept(), breaks and returnsOk(()).- task_control records the task as done (
done = run(..).is_ok(), support/task_control/src/lib.rs:512-517) and never runs it again. - When the guest acknowledges the restore with its snapshot boundary,
release_snapshot_boundary(openvmm/openvmm_core/src/worker/dispatch/microvm.rs:282) callsresume_input_after_save, which callsadmission.resume(). Admission reopens, but no worker is left to serve either queue.
Devices whose quiesce_input is a no-op are unaffected. A snapshot that isn't tiered arms no gate, which is why NVX's existing microVM filesystem-snapshot scenario passes.
Symptom
With OPENVMM_LOG=virtiofs=trace,fuse=trace,virtio=trace, the VMM logs, in order:
- virtio-fs
restore_staged,private_state_applyandqueue_start(queues 0 and 1, success); - about 20 ms later, "ignoring microVM snapshot request because no destination is configured" (the guest's boundary);
- then no FUSE request at all, not even FUSE_INIT.
On the NVX side, a clone with an export reaches Ready and the guest has the export mounted, but its first mapping table (MAPS) never completes.
Proposed fix
VirtioFsWorker::run waits, stop-aware, for admission to reopen instead of returning:
RequestAdmissionkeeps the wakers of the workers that wait, andresume()wakes them.- The worker awaits
stop.until_stopped(poll_fn(|cx| admission.poll_accept(cx)))beforequeue.next().
Admission still precedes queue.next(), so a closed gate leaves descriptors on the virtqueue, and verify_drained still holds for saves.
A candidate patch with a unit test is below. Its base is 79a501b6; git apply --check passes on 2b681564.
- The new test
microvm_queue_started_while_input_is_quiesced_serves_requests_once_it_resumestimes out without the change and passes with it. - virtiofs's check, clippy (
-D warnings) and doc are clean; 118 tests pass;cargo xtask fmtis clean. - With a VMM built from it, #846's export scenarios pass on MSHV and WHP. With the pinned VMM, the three that reach MAPS hang on both.
Candidate patch (git format-patch)
From 9fa024aa4df2439af3f8546ef4d5e86518f74d2c Mon Sep 17 00:00:00 2001
From: Enrique Saurez <[email protected]>
Date: Sun, 25 Oct 2026 21:51:16 -0700
Subject: [PATCH] virtiofs: wait for request admission instead of ending a
queue worker
A virtio-fs queue worker ended as soon as it found request admission
closed. A restore under the post-restore input gate quiesces device
input before the VM's state units start, so the queues of a microVM
virtio-fs device start with admission closed: each worker ended at
once, task_control marked it done, and nothing started it again once
the guest acknowledged the restore and input resumed. Every request of
the guest, from its FUSE_INIT on, then waited forever, so a filesystem
attached at a restore, such as one bound to a dormant slot, could be
mounted but never used.
The worker now waits for admission to reopen, until it is stopped:
RequestAdmission keeps the wakers of the workers that wait, and
resume() wakes them. Admission still precedes queue.next(), so a closed
gate leaves descriptors on the virtqueue, and a save still finds no
request in flight.
A new test starts a microVM device's queue while input is quiesced,
checks that a posted FUSE_INIT stays on the virtqueue, resumes input,
and waits for the reply. Without the change, the reply never comes.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 34e6ffe3-3ebf-42db-bfca-e0ecfade6873
---
.../virtio/virtiofs/src/integration_tests.rs | 83 +++++++++++++++++--
vm/devices/virtio/virtiofs/src/virtio.rs | 51 +++++++++++-
2 files changed, 123 insertions(+), 11 deletions(-)
diff --git a/vm/devices/virtio/virtiofs/src/integration_tests.rs b/vm/devices/virtio/virtiofs/src/integration_tests.rs
index 831b8df..375ce5d 100644
--- a/vm/devices/virtio/virtiofs/src/integration_tests.rs
+++ b/vm/devices/virtio/virtiofs/src/integration_tests.rs
@@ -8,12 +8,17 @@
//! as a guest kernel would.
use crate::VirtioFs;
+use crate::profile::MICROVM_ATTACHMENT_ID;
+use crate::profile::MicroVmOwnerMode;
+use crate::profile::microvm_root_identity;
use crate::virtio::VirtioFsDevice;
use fuse::protocol::*;
use guestmem::GuestMemory;
use pal_async::DefaultDriver;
use pal_async::async_test;
+use pal_async::timer::PolledTimer;
use pal_event::Event;
+use std::time::Duration;
use test_with_tracing::test;
use virtio::QueueResources;
use virtio::VirtioDevice;
@@ -64,15 +69,42 @@ struct TestHarness {
impl TestHarness {
fn new(driver: &DefaultDriver) -> Self {
let tmpdir = tempfile::tempdir().unwrap();
+ let fs = VirtioFs::new(tmpdir.path(), None).unwrap();
+ let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone()));
+ let device = VirtioFsDevice::new(&driver_source, "testfs", fs, 0, None);
+ Self::with_device(driver, device, tmpdir)
+ }
+ /// Creates a harness for the fixed microVM device of a writable host
+ /// directory, whose requests run as the VMM.
+ fn new_microvm(driver: &DefaultDriver) -> Self {
+ let tmpdir = tempfile::tempdir().unwrap();
+ let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone()));
+ let device = VirtioFsDevice::new_microvm_hostfs(
+ &driver_source,
+ MICROVM_ATTACHMENT_ID.to_owned(),
+ microvm_root_identity(tmpdir.path()).unwrap(),
+ false,
+ Vec::new(),
+ Vec::new(),
+ Vec::new(),
+ MicroVmOwnerMode::Vmm,
+ tmpdir.path(),
+ None,
+ )
+ .unwrap();
+ Self::with_device(driver, device, tmpdir)
+ }
+
+ fn with_device(
+ driver: &DefaultDriver,
+ device: VirtioFsDevice,
+ tmpdir: tempfile::TempDir,
+ ) -> Self {
let mem = GuestMemory::allocate(TOTAL_MEM_SIZE);
init_avail_ring(&mem, AVAIL_ADDR);
init_used_ring(&mem, USED_ADDR);
- let fs = VirtioFs::new(tmpdir.path(), None).unwrap();
- let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone()));
- let device = VirtioFsDevice::new(&driver_source, "testfs", fs, 0, None);
-
let queue_event = Event::new();
let interrupt_event = Event::new();
@@ -277,8 +309,9 @@ fn read_response<T: FromBytes>(&self, resp_gpa: u64) -> T {
T::read_from_bytes(&buf).unwrap()
}
- /// Send FUSE_INIT and wait for the response. Panics on failure.
- async fn fuse_init(&mut self, head_desc: u16) {
+ /// Post FUSE_INIT. Returns `(unique, resp_gpa)`, as
+ /// [`post_fuse_request`](Self::post_fuse_request) does.
+ fn post_fuse_init(&mut self, head_desc: u16) -> (u64, u64) {
let init_args = fuse_init_in {
major: FUSE_KERNEL_VERSION,
minor: FUSE_KERNEL_MINOR_VERSION,
@@ -289,9 +322,12 @@ async fn fuse_init(&mut self, head_desc: u16) {
};
let resp_size = OUT_HEADER_SIZE + size_of::<fuse_init_out>() as u32;
- let (unique, resp_gpa) =
- self.post_fuse_request(head_desc, FUSE_INIT, 0, init_args.as_bytes(), resp_size);
+ self.post_fuse_request(head_desc, FUSE_INIT, 0, init_args.as_bytes(), resp_size)
+ }
+ /// Wait for the response to the FUSE_INIT `unique`, whose response buffer
+ /// is at `resp_gpa`. Panics on failure.
+ async fn wait_for_init(&mut self, unique: u64, resp_gpa: u64) {
let (_used_id, used_len) = self.wait_for_used().await;
assert!(used_len > 0, "FUSE_INIT response should not be empty");
@@ -303,6 +339,12 @@ async fn fuse_init(&mut self, head_desc: u16) {
assert_eq!(init_out.major, FUSE_KERNEL_VERSION);
}
+ /// Send FUSE_INIT and wait for the response. Panics on failure.
+ async fn fuse_init(&mut self, head_desc: u16) {
+ let (unique, resp_gpa) = self.post_fuse_init(head_desc);
+ self.wait_for_init(unique, resp_gpa).await;
+ }
+
/// Return the path to the temp directory backing the filesystem.
fn tmpdir_path(&self) -> &std::path::Path {
self._tmpdir.path()
@@ -319,6 +361,31 @@ async fn fuse_init_succeeds(driver: DefaultDriver) {
harness.fuse_init(0).await;
}
+/// A microVM device whose queue starts while host input is quiesced, as a
+/// restore starts it behind its post-restore input gate, leaves a request on
+/// the virtqueue until input resumes, and then serves it.
+#[async_test]
+async fn microvm_queue_started_while_input_is_quiesced_serves_requests_once_it_resumes(
+ driver: DefaultDriver,
+) {
+ let mut harness = TestHarness::new_microvm(&driver);
+ harness.device.quiesce_input().await.unwrap();
+ harness.enable().await;
+
+ let (unique, resp_gpa) = harness.post_fuse_init(0);
+ PolledTimer::new(&driver)
+ .sleep(Duration::from_millis(100))
+ .await;
+ let used_idx: u16 = harness.mem.read_plain(USED_ADDR + 2).unwrap();
+ assert_eq!(
+ used_idx, 0,
+ "the device served a request while input was quiesced"
+ );
+
+ harness.device.resume_input().await.unwrap();
+ harness.wait_for_init(unique, resp_gpa).await;
+}
+
/// GETATTR on the root inode returns a directory after INIT.
#[async_test]
async fn getattr_root_returns_directory(driver: DefaultDriver) {
diff --git a/vm/devices/virtio/virtiofs/src/virtio.rs b/vm/devices/virtio/virtiofs/src/virtio.rs
index 31960ac..a44afb6 100644
--- a/vm/devices/virtio/virtiofs/src/virtio.rs
+++ b/vm/devices/virtio/virtiofs/src/virtio.rs
@@ -17,6 +17,9 @@
use std::io;
use std::io::Write;
use std::sync::Arc;
+use std::task::Context;
+use std::task::Poll;
+use std::task::Waker;
use task_control::AsyncRun;
use task_control::Cancelled;
use task_control::StopTask;
@@ -99,6 +102,8 @@ pub struct VirtioFsDevice {
struct AdmissionState {
accepting: bool,
in_flight: u32,
+ /// The wakers of the queue workers that wait for admission to reopen.
+ waiters: Vec<Waker>,
}
pub(crate) struct RequestAdmission {
@@ -115,10 +120,12 @@ fn new() -> Self {
state: Mutex::new(AdmissionState {
accepting: true,
in_flight: 0,
+ waiters: Vec::new(),
}),
}
}
+ #[cfg(test)]
fn accept(self: &Arc<Self>) -> Option<RequestGuard> {
let mut state = self.state.lock();
if !state.accepting {
@@ -130,14 +137,45 @@ fn accept(self: &Arc<Self>) -> Option<RequestGuard> {
})
}
+ /// Admits one request, or, while admission is closed, registers the
+ /// caller's waker so that [`Self::resume`] wakes it. Returns `None` if the
+ /// in-flight count would overflow.
+ fn poll_accept(self: &Arc<Self>, cx: &mut Context<'_>) -> Poll<Option<RequestGuard>> {
+ let mut state = self.state.lock();
+ if !state.accepting {
+ if !state
+ .waiters
+ .iter()
+ .any(|waker| waker.will_wake(cx.waker()))
+ {
+ state.waiters.push(cx.waker().clone());
+ }
+ return Poll::Pending;
+ }
+ let Some(in_flight) = state.in_flight.checked_add(1) else {
+ return Poll::Ready(None);
+ };
+ state.in_flight = in_flight;
+ Poll::Ready(Some(RequestGuard {
+ admission: Arc::clone(self),
+ }))
+ }
+
pub(crate) fn quiesce(&self) {
let mut state = self.state.lock();
state.accepting = false;
}
+ /// Reopens admission, and wakes the queue workers that wait for it.
pub(crate) fn resume(&self) {
- let mut state = self.state.lock();
- state.accepting = true;
+ let waiters = {
+ let mut state = self.state.lock();
+ state.accepting = true;
+ std::mem::take(&mut state.waiters)
+ };
+ for waker in waiters {
+ waker.wake();
+ }
}
pub(crate) fn verify_drained(&self) -> anyhow::Result<()> {
@@ -413,7 +451,14 @@ async fn run(
// Admission must precede queue.next(): after save closes the
// gate, an unowned descriptor must remain on the virtqueue rather
// than being dequeued and falsely completed with an empty reply.
- let Some(request_guard) = self.admission.accept() else {
+ // While the gate is closed, as when a restore starts the queues
+ // behind its post-restore input gate, the worker waits for it to
+ // reopen rather than ending, since nothing would start it again.
+ let admission = &self.admission;
+ let Some(request_guard) = stop
+ .until_stopped(std::future::poll_fn(|cx| admission.poll_accept(cx)))
+ .await?
+ else {
break;
};
let work = stop.until_stopped(state.queue.next()).await?;
--
2.55.0.windows.5
Follow-up in NVX
Once the fix is on feature/microvm/dev, NVX's OpenVMM pin on dev has to move past it. We'll open that PR.
- Dominant language
- Rust
- Stars
- 758
- Forks
- 48
- Avg merge
- 18h 37m
- Merged PRs (30d)
- 398
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from microsoft/nvx
-
Difficulty 5/5 Over a week Newbie friendliness 38/100
Maintainers usually reply within 1 day
-
CPU profile: Standard_D8ads_v5 / EPYC 9V74 fails amd.genoa.v1 on WHPPossibly taken @ppenna claimed this today. Openbug
Difficulty 4/5 3-5 days Newbie friendliness 20/100
microsoft/nvx#928 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 76/100
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 55/100
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 35/100
Maintainers usually reply within 1 day
Similar issues
-
component:benchmark
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
agentic-os-org/ANOLISA#9177 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
oracle/rust-oracledb#61 ·
-
bug priority-medium to-agent
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 82/100
Maintainers usually reply within 2 days
-
Databricks tool-only response crashes in resolve_reasoning_and_content with KeyError: contentPossibly taken @1divyanshdev claimed this today. Openllm translation
Difficulty 2/5 1-3 hours Newbie friendliness 79/100
BerriAI/litellm#48637 · 2 comments ·
Maintainers usually reply within 1 day