Skip to content

Commit d288842

Browse files
RoyLinRoyLin
authored andcommitted
feat(snapshot): persist+restore virtio-fs inode map (fixes exec EBADF on restored guest)
ROOT CAUSE (workflow-confirmed via FUSE errno trace 'do_getattr MAP-MISS inode=1 -> EBADF, map has 0 inodes'): the host virtio-fs PassthroughFs is rebuilt fresh on restore with an EMPTY inode map. The root inode (nodeid=1) is only inserted by the FUSE_INIT handler, which the restored (already-mounted) guest never re-sends — so every rootfs op the guest makes (execve reading /bin/sh) resolves a cached nodeid the host has forgotten and returns EBADF, surfacing as 'Failed to spawn command sh: Bad file descriptor'. Fix: serialize the inode map (nodeid -> host path, recovered via readlink(/proc/self/fd/N) on the live O_PATH fds while the template is paused-but-alive) and rehydrate it on restore by reopening each path under its original nodeid. Plumbing: VirtioDevice gains save_device_state/restore_device_state (opaque blob); Fs shares its PassthroughFs with the worker via Arc (Server now holds Arc<F>) so the device can snapshot it; the blob rides MmioDeviceState through the snapshot state and is reapplied after activate() before vCPUs run. Open file handles deferred (a fresh exec opens its own). macOS/Windows stubs no-op.
1 parent 7f3e399 commit d288842

10 files changed

Lines changed: 191 additions & 6 deletions

File tree

src/devices/src/virtio/device.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,18 @@ pub trait VirtioDevice: AsAny + Send {
8181
self.queues().iter().map(Queue::save_state).collect()
8282
}
8383

84+
/// Snapshot device-internal state that is NOT in the virtqueues, as an opaque
85+
/// blob (snapshot-fork). Defaults to `None`. virtio-fs overrides this to persist
86+
/// its FUSE inode map (nodeid → path), without which a restored guest's cached
87+
/// nodeids all resolve to EBADF against the freshly-built, empty host inode map.
88+
fn save_device_state(&self) -> Option<Vec<u8>> {
89+
None
90+
}
91+
92+
/// Re-apply device-internal state captured by [`save_device_state`]. Called on
93+
/// restore after `activate()` and before the vCPUs run. Default no-op.
94+
fn restore_device_state(&mut self, _blob: &[u8]) {}
95+
8496
/// Returns the device queues event fds.
8597
fn queue_events(&self) -> &[EventFd];
8698

src/devices/src/virtio/fs/device.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ use virtio_bindings::{virtio_config::VIRTIO_F_VERSION_1, virtio_ring::VIRTIO_RIN
1515
use vm_memory::{ByteValued, GuestMemoryMmap};
1616

1717
use super::super::{
18-
ActivateResult, DeviceState, FsError, Queue as VirtQueue, VirtioDevice, VirtioShmRegion,
18+
ActivateError, ActivateResult, DeviceState, FsError, Queue as VirtQueue, VirtioDevice,
19+
VirtioShmRegion,
1920
};
2021
use super::passthrough;
2122
use super::worker::FsWorker;
@@ -72,6 +73,10 @@ pub struct Fs {
7273
config: VirtioFsConfig,
7374
shm_region: Option<VirtioShmRegion>,
7475
passthrough_cfg: passthrough::Config,
76+
// The live FUSE filesystem, shared with the worker thread (via the Server) so
77+
// this device can snapshot/restore its inode map for snapshot-fork. `Some` once
78+
// activated.
79+
passthrough_fs: Option<Arc<passthrough::PassthroughFs>>,
7580
worker_thread: Option<JoinHandle<()>>,
7681
worker_stopfd: EventFd,
7782
exit_code: Arc<AtomicI32>,
@@ -115,6 +120,7 @@ impl Fs {
115120
config,
116121
shm_region: None,
117122
passthrough_cfg: fs_cfg,
123+
passthrough_fs: None,
118124
worker_thread: None,
119125
worker_stopfd: EventFd::new(EFD_NONBLOCK).map_err(FsError::EventFd)?,
120126
exit_code,
@@ -192,6 +198,16 @@ impl VirtioDevice for Fs {
192198
&self.queue_events
193199
}
194200

201+
fn save_device_state(&self) -> Option<Vec<u8>> {
202+
self.passthrough_fs.as_ref().map(|fs| fs.save_fs_state())
203+
}
204+
205+
fn restore_device_state(&mut self, blob: &[u8]) {
206+
if let Some(fs) = self.passthrough_fs.as_ref() {
207+
fs.restore_fs_state(blob);
208+
}
209+
}
210+
195211
fn read_config(&self, offset: u64, mut data: &mut [u8]) {
196212
let config_slice = self.config.as_slice();
197213
let config_len = config_slice.len() as u64;
@@ -230,13 +246,21 @@ impl VirtioDevice for Fs {
230246
.iter()
231247
.map(|e| e.try_clone().unwrap())
232248
.collect();
249+
// Build the FUSE filesystem and keep a shared handle so this device can
250+
// snapshot/restore its inode map (the worker thread otherwise owns it).
251+
let passthrough_fs = Arc::new(
252+
passthrough::PassthroughFs::new(self.passthrough_cfg.clone())
253+
.map_err(|_| ActivateError::BadActivate)?,
254+
);
255+
self.passthrough_fs = Some(passthrough_fs.clone());
256+
233257
let worker = FsWorker::new(
234258
self.queues.clone(),
235259
queue_evts,
236260
interrupt.clone(),
237261
mem.clone(),
238262
self.shm_region.clone(),
239-
self.passthrough_cfg.clone(),
263+
passthrough_fs,
240264
self.worker_stopfd.try_clone().unwrap(),
241265
self.exit_code.clone(),
242266
#[cfg(target_os = "macos")]

src/devices/src/virtio/fs/linux/passthrough.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,113 @@ impl PassthroughFs {
417417
})
418418
}
419419

420+
/// Snapshot the FUSE inode namespace (nodeid → host path) for snapshot-fork.
421+
///
422+
/// Each `InodeData.file` is a live `O_PATH` fd, so while the template VMM is
423+
/// paused-but-alive its absolute path is recoverable via
424+
/// `readlink(/proc/self/fd/N)`. The opaque blob is consumed by
425+
/// [`restore_fs_state`](Self::restore_fs_state) on the forked VM to rebuild the
426+
/// same nodeids, so the restored guest's cached nodeids resolve instead of all
427+
/// hitting EBADF against an empty inode map.
428+
pub fn save_fs_state(&self) -> Vec<u8> {
429+
use std::os::unix::ffi::OsStrExt;
430+
let inodes = self.inodes.read().unwrap();
431+
let mut out = Vec::new();
432+
out.extend_from_slice(&self.next_inode.load(Ordering::Relaxed).to_le_bytes());
433+
out.extend_from_slice(&self.next_handle.load(Ordering::Relaxed).to_le_bytes());
434+
let count_pos = out.len();
435+
out.extend_from_slice(&0u64.to_le_bytes());
436+
let mut count: u64 = 0;
437+
for (nodeid, data) in inodes.iter() {
438+
let link = format!("/proc/self/fd/{}", data.file.as_raw_fd());
439+
let path = match std::fs::read_link(&link) {
440+
Ok(p) => p,
441+
Err(_) => continue,
442+
};
443+
let path_bytes = path.as_os_str().as_bytes();
444+
// Unlinked-but-open files read back as "<path> (deleted)" and cannot be
445+
// reopened by path; skip them — the guest will re-LOOKUP if needed.
446+
if path_bytes.ends_with(b" (deleted)") || path_bytes.contains(&0) {
447+
continue;
448+
}
449+
out.extend_from_slice(&(*nodeid).to_le_bytes());
450+
out.extend_from_slice(&data.refcount.load(Ordering::Relaxed).to_le_bytes());
451+
out.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
452+
out.extend_from_slice(path_bytes);
453+
count += 1;
454+
}
455+
out[count_pos..count_pos + 8].copy_from_slice(&count.to_le_bytes());
456+
out
457+
}
458+
459+
/// Rebuild the inode map from a [`save_fs_state`](Self::save_fs_state) blob by
460+
/// reopening each path under its original nodeid. Runs on restore after the fs
461+
/// device is rebuilt and before the vCPUs resume.
462+
pub fn restore_fs_state(&self, blob: &[u8]) {
463+
if blob.len() < 24 {
464+
return;
465+
}
466+
let rd = |o: usize| u64::from_le_bytes(blob[o..o + 8].try_into().unwrap());
467+
let next_inode = rd(0);
468+
let next_handle = rd(8);
469+
let count = rd(16);
470+
let mut off = 24usize;
471+
let mut inodes = self.inodes.write().unwrap();
472+
for _ in 0..count {
473+
if off + 20 > blob.len() {
474+
break;
475+
}
476+
let nodeid = rd(off);
477+
off += 8;
478+
let refcount = rd(off);
479+
off += 8;
480+
let plen = u32::from_le_bytes(blob[off..off + 4].try_into().unwrap()) as usize;
481+
off += 4;
482+
if off + plen > blob.len() {
483+
break;
484+
}
485+
let path = &blob[off..off + plen];
486+
off += plen;
487+
let cpath = match CString::new(path) {
488+
Ok(c) => c,
489+
Err(_) => continue,
490+
};
491+
let fd = unsafe {
492+
libc::openat(
493+
libc::AT_FDCWD,
494+
cpath.as_ptr(),
495+
libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC,
496+
)
497+
};
498+
if fd < 0 {
499+
continue;
500+
}
501+
// Safe because we just opened this fd.
502+
let f = unsafe { File::from_raw_fd(fd) };
503+
let (st, mnt_id) = match statx(&f) {
504+
Ok(v) => v,
505+
Err(_) => continue,
506+
};
507+
inodes.insert(
508+
nodeid,
509+
InodeAltKey {
510+
ino: st.st_ino,
511+
dev: st.st_dev,
512+
mnt_id,
513+
},
514+
Arc::new(InodeData {
515+
inode: nodeid,
516+
file: f,
517+
dev: st.st_dev,
518+
mnt_id,
519+
refcount: AtomicU64::new(refcount),
520+
}),
521+
);
522+
}
523+
self.next_inode.store(next_inode, Ordering::Relaxed);
524+
self.next_handle.store(next_handle, Ordering::Relaxed);
525+
}
526+
420527
fn open_inode(&self, inode: Inode, mut flags: i32) -> io::Result<File> {
421528
let data = self
422529
.inodes

src/devices/src/virtio/fs/macos/passthrough.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,15 @@ pub struct PassthroughFs {
444444
}
445445

446446
impl PassthroughFs {
447+
/// Snapshot-fork inode-map persistence is Linux-only (depends on
448+
/// `/proc/self/fd`); a no-op on macOS.
449+
pub fn save_fs_state(&self) -> Vec<u8> {
450+
Vec::new()
451+
}
452+
453+
/// Snapshot-fork inode-map persistence is Linux-only; a no-op on macOS.
454+
pub fn restore_fs_state(&self, _blob: &[u8]) {}
455+
447456
pub fn new(cfg: Config) -> io::Result<PassthroughFs> {
448457
let mut cfg = cfg;
449458

src/devices/src/virtio/fs/multikey.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ where
4747
self.main.get(key).map(|(_, v)| v)
4848
}
4949

50+
/// Iterate over `(main_key, value)` pairs. Used to walk the inode map when
51+
/// snapshotting virtio-fs state.
52+
pub fn iter(&self) -> impl Iterator<Item = (&K1, &V)> {
53+
self.main.iter().map(|(k, (_, v))| (k, v))
54+
}
55+
5056
/// Returns a reference to the value corresponding to the alternate key.
5157
///
5258
/// The key may be any borrowed form of the `K2``, but the ordering on the borrowed form must

src/devices/src/virtio/fs/server.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,15 @@ impl io::Write for ZCWriter<'_> {
9292
}
9393

9494
pub struct Server<F: FileSystem + Sync> {
95-
fs: F,
95+
// Shared with the owning device so it can snapshot/restore the filesystem's
96+
// inode map (the worker thread that holds this Server is otherwise the only
97+
// owner of the live FUSE namespace).
98+
fs: std::sync::Arc<F>,
9699
options: AtomicU64,
97100
}
98101

99102
impl<F: FileSystem + Sync> Server<F> {
100-
pub fn new(fs: F) -> Server<F> {
103+
pub fn new(fs: std::sync::Arc<F>) -> Server<F> {
101104
Server {
102105
fs,
103106
options: AtomicU64::new(FsOptions::empty().bits()),

src/devices/src/virtio/fs/windows/passthrough.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,14 @@ pub struct PassthroughFs {
157157
}
158158

159159
impl PassthroughFs {
160+
/// Snapshot-fork inode-map persistence is Linux-only; a no-op on Windows.
161+
pub fn save_fs_state(&self) -> Vec<u8> {
162+
Vec::new()
163+
}
164+
165+
/// Snapshot-fork inode-map persistence is Linux-only; a no-op on Windows.
166+
pub fn restore_fs_state(&self, _blob: &[u8]) {}
167+
160168
pub fn new(cfg: Config) -> io::Result<PassthroughFs> {
161169
let root_dir = PathBuf::from(&cfg.root_dir);
162170
virtiofs_debug_log(format!("PassthroughFs::new root_dir={}", root_dir.display()));

src/devices/src/virtio/fs/worker.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ impl FsWorker {
6565
interrupt: InterruptTransport,
6666
mem: GuestMemoryMmap,
6767
shm_region: Option<VirtioShmRegion>,
68-
passthrough_cfg: passthrough::Config,
68+
passthrough_fs: Arc<PassthroughFs>,
6969
stop_fd: EventFd,
7070
exit_code: Arc<AtomicI32>,
7171
#[cfg(target_os = "macos")] map_sender: Option<Sender<WorkerMessage>>,
@@ -76,7 +76,7 @@ impl FsWorker {
7676
interrupt,
7777
mem,
7878
shm_region,
79-
server: Server::new(PassthroughFs::new(passthrough_cfg).unwrap()),
79+
server: Server::new(passthrough_fs),
8080
stop_fd,
8181
exit_code,
8282
#[cfg(target_os = "macos")]

src/devices/src/virtio/mmio.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ pub struct MmioDeviceState {
106106
pub config_generation: u32,
107107
pub acked_features: u64,
108108
pub queues: Vec<QueueState>,
109+
/// Opaque device-internal state (e.g. virtio-fs inode map). Empty if none.
110+
pub device_blob: Vec<u8>,
109111
}
110112

111113
pub struct MmioTransport {
@@ -309,6 +311,7 @@ impl MmioTransport {
309311
config_generation: self.config_generation,
310312
acked_features: dev.acked_features(),
311313
queues,
314+
device_blob: dev.save_device_state().unwrap_or_default(),
312315
}
313316
}
314317

@@ -344,6 +347,15 @@ impl MmioTransport {
344347
})?;
345348
activated = true;
346349
}
350+
351+
// Rehydrate device-internal state (e.g. the virtio-fs inode map) AFTER
352+
// activate() has rebuilt the device's shared backend, and before the vCPUs
353+
// run so the guest's first FUSE op finds a populated map.
354+
if !state.device_blob.is_empty() {
355+
self.locked_device()
356+
.restore_device_state(&state.device_blob);
357+
}
358+
347359
self.device_status = state.device_status;
348360
Ok(activated)
349361
}

src/vmm/src/snapshot.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ pub struct MmioDeviceStateSer {
121121
pub config_generation: u32,
122122
pub acked_features: u64,
123123
pub queues: Vec<QueueStateSer>,
124+
#[serde(default)]
125+
pub device_blob: Vec<u8>,
124126
}
125127

126128
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
@@ -135,6 +137,7 @@ impl From<&devices::virtio::MmioDeviceState> for MmioDeviceStateSer {
135137
config_generation: d.config_generation,
136138
acked_features: d.acked_features,
137139
queues: d.queues.iter().map(QueueStateSer::from).collect(),
140+
device_blob: d.device_blob.clone(),
138141
}
139142
}
140143
}
@@ -151,6 +154,7 @@ impl From<&MmioDeviceStateSer> for devices::virtio::MmioDeviceState {
151154
config_generation: d.config_generation,
152155
acked_features: d.acked_features,
153156
queues: d.queues.iter().map(devices::virtio::QueueState::from).collect(),
157+
device_blob: d.device_blob.clone(),
154158
}
155159
}
156160
}

0 commit comments

Comments
 (0)