Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

OpenVMM: virtio-fs queues never serve requests after a restore behind the post-restore input gate

Closed
#904 0 comments 0 reactions 1 assignee View on GitHub

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

bug

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

  1. 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.
  2. When the gate is armed, LoadedVm::start_state_units (openvmm/openvmm_core/src/worker/dispatch/restore.rs:315-327) calls quiesce_input_for_save before state_units.start().
  3. For a microVM virtio-fs device, quiesce_input (vm/devices/virtio/virtiofs/src/microvm/device.rs:167-171) calls RequestAdmission::quiesce, which sets accepting = false.
  4. state_units.start() then starts the queues. VirtioFsWorker::run (vm/devices/virtio/virtiofs/src/virtio.rs:416-418) gets None from admission.accept(), breaks and returns Ok(()).
  5. task_control records the task as done (done = run(..).is_ok(), support/task_control/src/lib.rs:512-517) and never runs it again.
  6. When the guest acknowledges the restore with its snapshot boundary, release_snapshot_boundary (openvmm/openvmm_core/src/worker/dispatch/microvm.rs:282) calls resume_input_after_save, which calls admission.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_apply and queue_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:

  • RequestAdmission keeps the wakers of the workers that wait, and resume() wakes them.
  • The worker awaits stop.until_stopped(poll_fn(|cx| admission.poll_accept(cx))) before queue.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_resumes times out without the change and passes with it.
  • virtiofs's check, clippy (-D warnings) and doc are clean; 118 tests pass; cargo xtask fmt is 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from microsoft/nvx

All issues in microsoft/nvx

Similar issues

More Rust issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.