Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@
- [269](https://github.com/cloud-hypervisor/fuse-backend-rs/pull/269): `FuseSession::detach()` lazily unmounts a Linux session through `&self`,
so the owner of a serving layer can start the teardown while another thread waits in
`FuseServing::wait()`.
- On Linux 7.3 and later, `UringFuseServing` gives each kernel queue a buffer pool of
`UringConfig::bufs_per_queue` payload buffers. The kernel lends them to the queue's ring entries
while they serve a request, so with fewer buffers than entries a mount needs less memory.
`bufs_per_queue` defaults to 4, the default number of entries, and counts as at most
`entries_per_queue`. 0 keeps a buffer per entry, as on older kernels. `UringConfig` has a new
field, so struct literals need `..Default::default()`.
- `abi::fuse_uring` has the commands, INIT flag and fields of protocol 7.46 for buffer pools.
`Server::uring_bufpool_supported()` tells whether the kernel offered them at INIT, and
`Server::uring_payload_size()` returns the size of the kernel's payload buffers.

### Changed
- Bump the declared minimum supported Rust version (MSRV) from 1.80 to 1.86.
Expand All @@ -49,6 +58,7 @@
- The FUSE-over-io_uring transport answers opcodes it doesn't know with ENOSYS instead of EIO.
Kernels that send `FUSE_STATX` only fall back to `FUSE_GETATTR` after ENOSYS, so `statx()`
calls that ask for more than the basic fields, like the birth time `stat(1)` shows, failed.
- `FuseUringEntInOut::padding` is now `offset`, and `FuseUringCmdReq` has a new `bufpool` field.
- [254](https://github.com/cloud-hypervisor/fuse-backend-rs/pull/254): `fuse-backend-rs` is now a thin facade re-exporting the sub-crates. Every
historical `fuse_backend_rs::{abi, api, buffer, common, transport, passthrough,
overlayfs}` import path and cargo feature name (`fusedev`, `virtiofs`,
Expand Down Expand Up @@ -88,6 +98,10 @@
- READDIR and READDIRPLUS answer ENOMEM when the reply can't hold the reply header plus the
requested size. The check left out the header, so a writer short by up to 16 bytes started the
listing and failed partway.
- A `UringFuseServing` worker that stops frees its ring entries only after the kernel has given
them all back, and leaks them if that takes more than a second. A worker that stopped on an
error while the connection was up freed entries the kernel could still copy requests into. A
signal that interrupts the worker's `io_uring_enter()` calls no longer stops it.
- [278](https://github.com/cloud-hypervisor/fuse-backend-rs/pull/278): Treat `EOPNOTSUPP`/`ENOTSUP` and `ENOSYS` from `getxattr` as "not opaque"
in `Layer::is_opaque()`, so `OverlayFs::import()` no longer aborts when a layer's
backing filesystem has no xattr support at all (tmpfs answers `EOPNOTSUPP` to
Expand Down
46 changes: 36 additions & 10 deletions crates/fuse-backend-core/src/abi/fuse_uring.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
// Copyright (C) 2026 Alibaba Cloud. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Linux FUSE-over-io_uring ABI definitions, protocol version 7.42 (kernel 6.14+).
//! Linux FUSE-over-io_uring ABI definitions, protocol version 7.42 (kernel 6.14+),
//! and the buffer pools of protocol 7.46 (kernel 7.3).
//!
//! Experimental: the kernel interface is still evolving (reduced queue counts,
//! bufpool/zero-copy extensions are being developed upstream). Only the minimal
//! 7.42 interface is defined here; in-flight extensions are intentionally left
//! out.
//! Experimental: the kernel interface is still evolving.

#![allow(missing_docs)]

Expand All @@ -15,6 +13,9 @@ use vm_memory::ByteValued;
/// INIT flag: client supports serving FUSE requests over io_uring (protocol 7.42).
pub const FUSE_OVER_IO_URING: u64 = 1_u64 << 41;

/// INIT flag the kernel sets when it supports io_uring buffer pools (protocol 7.46).
pub const FUSE_HAS_IO_URING_BUFPOOL: u64 = 1_u64 << 43;

/// Size of the fuse_in_header/fuse_out_header slot inside `FuseUringReqHeader`.
pub const FUSE_URING_IN_OUT_HEADER_SZ: usize = 128;

Expand All @@ -34,6 +35,11 @@ pub const FUSE_URING_IOV_PAYLOAD: usize = 1;
pub const FUSE_IO_URING_CMD_INVALID: u32 = 0;
pub const FUSE_IO_URING_CMD_REGISTER: u32 = 1;
pub const FUSE_IO_URING_CMD_COMMIT_AND_FETCH: u32 = 2;
/// Create a queue (protocol 7.46). Buffer pools need it.
pub const FUSE_IO_URING_CMD_ADD_QUEUE: u32 = 3;
/// Give a queue a buffer pool (protocol 7.46). The queue must exist and must
/// not have registered entries with their own payload buffers yet.
pub const FUSE_IO_URING_CMD_ADD_BUFPOOL: u32 = 4;

/// Shared in/out descriptor of one ring entry, embedded in `FuseUringReqHeader`.
#[repr(C)]
Expand All @@ -44,7 +50,9 @@ pub struct FuseUringEntInOut {
pub commit_id: u64,
/// Size of the payload in bytes (request: set by kernel, reply: set by daemon).
pub payload_sz: u32,
pub padding: u32,
/// Offset of the request's payload buffer in the queue's buffer pool, if
/// the queue has one (protocol 7.46). Before that it was padding.
pub offset: u32,
pub reserved: u64,
}

Expand Down Expand Up @@ -72,8 +80,9 @@ impl Default for FuseUringReqHeader {
/// Command payload of an uring command SQE, placed in the 80-byte command
/// area of an SQE128.
///
/// Layout mirrors `struct fuse_uring_cmd_req`. For REGISTER and
/// COMMIT_AND_FETCH only `commit_id` and `qid` are used.
/// Layout mirrors `struct fuse_uring_cmd_req`. REGISTER and
/// COMMIT_AND_FETCH use `commit_id` and `qid`, ADD_QUEUE uses `qid`, and
/// ADD_BUFPOOL uses `qid` and `bufpool`.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default)]
pub struct FuseUringCmdReq {
Expand All @@ -83,13 +92,28 @@ pub struct FuseUringCmdReq {
/// Index of the per-CPU queue the command is for.
pub qid: u16,
pub padding: [u8; 6],
/// The buffer pool of an ADD_BUFPOOL command (protocol 7.46).
pub bufpool: FuseUringBufpool,
}

/// Buffer pool of a queue, `fuse_uring_cmd_req.bufpool`. The kernel cuts it
/// into buffers of the connection's maximum payload size.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default)]
pub struct FuseUringBufpool {
/// Address of the pool in the daemon's memory.
pub uaddr: u64,
/// Length of the pool in bytes.
pub len: u32,
pub reserved: u32,
}

// SAFETY: all three structs are `repr(C)` plain data types without padding
// SAFETY: all four structs are `repr(C)` plain data types without padding
// holes or pointer members, matching their kernel counterparts.
unsafe impl ByteValued for FuseUringEntInOut {}
unsafe impl ByteValued for FuseUringReqHeader {}
unsafe impl ByteValued for FuseUringCmdReq {}
unsafe impl ByteValued for FuseUringBufpool {}

#[cfg(test)]
mod tests {
Expand All @@ -99,7 +123,9 @@ mod tests {
fn test_layouts() {
assert_eq!(std::mem::size_of::<FuseUringEntInOut>(), 32);
assert_eq!(std::mem::size_of::<FuseUringReqHeader>(), 288);
assert_eq!(std::mem::size_of::<FuseUringCmdReq>(), 24);
assert_eq!(std::mem::size_of::<FuseUringCmdReq>(), 40);
assert_eq!(std::mem::offset_of!(FuseUringCmdReq, bufpool), 24);
assert_eq!(std::mem::offset_of!(FuseUringEntInOut, offset), 20);
assert_eq!(
std::mem::offset_of!(FuseUringReqHeader, ring_ent_in_out),
FUSE_URING_IN_OUT_HEADER_SZ + FUSE_URING_OP_IN_OUT_SZ
Expand Down
62 changes: 62 additions & 0 deletions crates/fuse-backend-core/src/api/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ use std::io::{self, Read};
use std::marker::PhantomData;
use std::mem::size_of;
use std::sync::atomic::AtomicU64;
#[cfg(all(target_os = "linux", feature = "fusedev-uring"))]
use std::sync::atomic::{AtomicBool, AtomicU32};

use crate::abi::fuse_abi::*;
use crate::api::filesystem::{Context, FileSystem, ZeroCopyReader, ZeroCopyWriter};
Expand Down Expand Up @@ -65,6 +67,15 @@ pub struct Server<F: FileSystem + Sync> {
/// Capability flags actually enabled by the INIT exchange.
#[cfg(all(target_os = "linux", feature = "fusedev-uring"))]
negotiated_init_flags: AtomicU64,
/// Whether the kernel offered io_uring buffer pools in INIT.
#[cfg(all(target_os = "linux", feature = "fusedev-uring"))]
uring_bufpool: AtomicBool,
/// `max_write` of the INIT reply.
#[cfg(all(target_os = "linux", feature = "fusedev-uring"))]
init_max_write: AtomicU32,
/// `max_pages` of the INIT reply, 0 if it doesn't set `FUSE_MAX_PAGES`.
#[cfg(all(target_os = "linux", feature = "fusedev-uring"))]
init_max_pages: AtomicU32,
}

impl<F: FileSystem + Sync> Server<F> {
Expand All @@ -78,6 +89,12 @@ impl<F: FileSystem + Sync> Server<F> {
extra_init_flags: AtomicU64::new(0),
#[cfg(all(target_os = "linux", feature = "fusedev-uring"))]
negotiated_init_flags: AtomicU64::new(0),
#[cfg(all(target_os = "linux", feature = "fusedev-uring"))]
uring_bufpool: AtomicBool::new(false),
#[cfg(all(target_os = "linux", feature = "fusedev-uring"))]
init_max_write: AtomicU32::new(0),
#[cfg(all(target_os = "linux", feature = "fusedev-uring"))]
init_max_pages: AtomicU32::new(0),
}
}

Expand All @@ -104,6 +121,51 @@ impl<F: FileSystem + Sync> Server<F> {
self.negotiated_init_flags.load(Ordering::Acquire) & FsOptions::OVER_IO_URING.bits() != 0
}

/// Report whether the kernel offered buffer pools for FUSE-over-io_uring
/// in INIT (`FUSE_HAS_IO_URING_BUFPOOL`, Linux 7.3). Returns false before
/// INIT completes.
#[cfg(all(target_os = "linux", feature = "fusedev-uring"))]
pub fn uring_bufpool_supported(&self) -> bool {
self.uring_bufpool.load(Ordering::Acquire)
}

/// The size of the payload buffer the kernel needs for one
/// FUSE-over-io_uring request on this connection, and the size of each
/// buffer it cuts from a buffer pool. It follows `fuse_uring_create()`:
/// the largest of `FUSE_MIN_READ_BUFFER`, `max_write` and `max_pages`
/// pages. The kernel can lower `max_pages` to its `max_pages_limit`
/// sysctl, but the INIT reply sets `max_write` to at least `max_pages`
/// pages, so that doesn't change the size. Meaningful only after INIT.
#[cfg(all(target_os = "linux", feature = "fusedev-uring"))]
pub fn uring_payload_size(&self) -> usize {
// The defaults of process_init_reply() and fuse_conn_init().
const MIN_MAX_WRITE: usize = 4096;
const DEFAULT_MAX_PAGES: usize = 32;
let max_write = self.init_max_write.load(Ordering::Acquire) as usize;
let max_pages = match self.init_max_pages.load(Ordering::Acquire) as usize {
0 => DEFAULT_MAX_PAGES,
n => n,
};
(MIN_READ_BUFFER as usize)
.max(max_write.max(MIN_MAX_WRITE))
.max(max_pages * crate::buffer::pagesize())
}

/// Remember what FUSE-over-io_uring needs from INIT: whether the kernel
/// offered buffer pools in `kernel_flags`, and the `max_write` and
/// `max_pages` of the reply.
#[cfg(all(target_os = "linux", feature = "fusedev-uring"))]
fn record_uring_init(&self, kernel_flags: u64, max_write: u32, max_pages: u16) {
use crate::abi::fuse_uring::FUSE_HAS_IO_URING_BUFPOOL;
self.uring_bufpool.store(
kernel_flags & FUSE_HAS_IO_URING_BUFPOOL != 0,
Ordering::Release,
);
self.init_max_write.store(max_write, Ordering::Release);
self.init_max_pages
.store(u32::from(max_pages), Ordering::Release);
}

/// OR the extra INIT flags requested through `set_uring()` into the
/// enabled capability set and remember the negotiation outcome.
#[cfg(all(target_os = "linux", feature = "fusedev-uring"))]
Expand Down
77 changes: 75 additions & 2 deletions crates/fuse-backend-core/src/api/server/sync_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,8 @@ impl<F: FileSystem + Sync> Server<F> {
out.max_pages = MAX_REQ_PAGES;
out.max_write = MAX_REQ_PAGES as u32 * pagesize() as u32; // 1MB
}
#[cfg(all(target_os = "linux", feature = "fusedev-uring"))]
self.record_uring_init(flags_u64, out.max_write, out.max_pages);
self.vers.store(
encode_version(version.major, version.minor),
Ordering::Release,
Expand Down Expand Up @@ -1726,10 +1728,10 @@ mod tests {
use super::super::test_util::{MockFS, TestWriter};
use super::*;

fn prepare_srvcontext<'a>(
fn prepare_srvcontext<'a, F: FileSystem>(
read_buf: &'a mut [u8],
write_buf: &'a mut [u8],
) -> SrvContext<'a, MockFS, (), TestWriter<'a>> {
) -> SrvContext<'a, F, (), TestWriter<'a>> {
let reader = Reader::<()>::from_slice(read_buf);
let writer = TestWriter::new(write_buf);
let in_header = InHeader::default();
Expand Down Expand Up @@ -2019,6 +2021,77 @@ mod tests {
);
}

#[cfg(feature = "fusedev-uring")]
#[test]
fn test_server_init_uring_bufpool() {
use crate::abi::fuse_uring::FUSE_HAS_IO_URING_BUFPOOL;

/// A file system that wants everything the kernel offers.
struct WantAll;
impl FileSystem for WantAll {
type Inode = u64;
type Handle = u64;
fn init(&self, capable: FsOptions) -> io::Result<FsOptions> {
Ok(capable)
}
}

// Run INIT with `flags` and `flags2` from the kernel, and return the
// server and the reply.
let init = |flags: FsOptions, flags2: u64| {
let mut read_buf = [0u8; size_of::<InitIn>() + size_of::<InitIn2>()];
let init_in = InitIn {
major: KERNEL_VERSION,
minor: KERNEL_MINOR_VERSION,
max_readahead: 0,
flags: (flags | FsOptions::INIT_EXT).bits() as u32,
};
read_buf[..size_of::<InitIn>()].copy_from_slice(init_in.as_slice());
let init_in2 = InitIn2 {
flags2: ((FsOptions::OVER_IO_URING.bits() | flags2) >> 32) as u32,
unused: [0; 11],
};
read_buf[size_of::<InitIn>()..].copy_from_slice(init_in2.as_slice());
let server = Server::new(WantAll);
server.set_uring(true);
let mut write_buf = [0u8; 4096];
let res = {
let ctx = prepare_srvcontext(&mut read_buf, &mut write_buf);
server.init(ctx, |_| {}).unwrap()
};
let mut out = InitOut::default();
out.as_mut_slice()
.copy_from_slice(&write_buf[size_of::<OutHeader>()..res]);
(server, out)
};

// The kernel's flag for buffer pools isn't an FsOptions flag, but
// the server still sees it.
let (server, _) = init(FsOptions::empty(), FUSE_HAS_IO_URING_BUFPOOL);
assert!(server.uring_bufpool_supported());
let (server, out) = init(FsOptions::empty(), 0);
assert!(!server.uring_bufpool_supported());
// Without FUSE_MAX_PAGES the kernel keeps 32 pages per request.
assert_eq!(out.max_pages, 0);
assert_eq!(
server.uring_payload_size(),
(MIN_READ_BUFFER as usize)
.max(out.max_write as usize)
.max(32 * pagesize())
);

// With FUSE_MAX_PAGES the kernel takes max_pages up to its limit.
let (server, out) = init(FsOptions::MAX_PAGES | FsOptions::BIG_WRITES, 0);
assert_eq!(out.max_pages, MAX_REQ_PAGES);
assert_eq!(
server.uring_payload_size(),
MAX_REQ_PAGES as usize * pagesize()
);
// The kernel's max_pages_limit can't change the size, because the
// reply's max_write covers max_pages pages.
assert!(out.max_write as usize >= MAX_REQ_PAGES as usize * pagesize());
}

#[cfg(feature = "fusedev-uring")]
#[test]
fn test_server_init_uring_negotiation() {
Expand Down
2 changes: 1 addition & 1 deletion crates/fuse-backend-fusedev/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ log = "0.4.6"
mio = { version = "0.8", features = ["os-poll", "os-ext"] }
nix = "0.24"
tokio = { version = "1", optional = true }
vm-memory = { workspace = true }
vm-memory = { workspace = true, features = ["backend-mmap"] }
vmm-sys-util = { workspace = true }

[target.'cfg(target_os = "macos")'.dependencies]
Expand Down
Loading
Loading