jj-vfs-poc / The FUSE adapter

The FUSE adapter

JjFuse is the only code that talks to fuser. It implements six Filesystem methods, turns each inode number back into a path, and runs the actual work on a tokio runtime. Results come back as FileAttrs, bytes, directory entries or errnos.

On this page
  1. JjFuse
  2. Implemented Filesystem methods
  3. Methods left to fuser's defaults
  4. The async bridge and reply_async!
  5. One read, end to end
  6. FileAttributes → FileAttr
  7. FileType conversion
  8. JjError → errno

In short: fuser calls a JjFuse method on its session thread. The method clones what it needs, and the reply_async! macro spawns a task on the tokio runtime and returns straight away. The task resolves the inode to a path with InodeMap::get_path, awaits the matching VirtualFilesystem call, and replies. Errors become errnos: ENOENT, ENOTDIR, EISDIR, EINVAL, or EIO for everything else. Every method JjFuse doesn't implement falls back to fuser's default. The mount is read-only (MountOption::RO).

This page covers src/fuse.rs and src/jj_error.rs. The layer beneath, VirtualFilesystem and PathMappedVfs, is on vfs-layer. How inode numbers are handed out is on inodes.

JjFuse

JjFuse<FS: VirtualFilesystem> holds three things. fs is the virtual file system behind an Arc. inode_map is an Arc<InodeMap>, created fresh in new, so inode numbers start over at every mount. rt_handle is a tokio::runtime::Handle. main.rs passes the runtime's handle and an Arc of the PathMappedVfs (source).

Implemented Filesystem methods

MethodWhat it doesVirtualFilesystem callReplySource
initAsks the kernel for FUSE_PARALLEL_DIROPS and FUSE_ASYNC_READ, ignoring the result. Runs synchronously.—Ok(())L70
lookupRejects non-UTF-8 names (InvalidPath), assigns or reuses the child's inode with get_ino(parent, name), appends the name to the parent's path, and fetches attributes.get_attributes(parent/name)reply.entry(TTL, attr, Generation(0))L78
getattrAttributes for an inode's path. The file handle is ignored.get_attributes(path)reply.attr(TTL, attr)L98
readReads size bytes at offset. The file handle, flags and lock owner are ignored.read(path, offset, size)reply.data(bytes)L110
readdirEmits . (offset 1) and .. (offset 2), then the listing with offsets 3, 4, …, skipping entries already sent. It assigns each child an inode, and stops when the reply buffer is full.read_directory(path)reply.ok()L132
readlinkThe symlink target's raw bytes.read_link(path)reply.data(bytes)L176

The list above is FUSE_OPS in jj_vfs_docs.py, which is refreshed whenever the pin moves. readdir lists the whole directory on every call and then skips to offset, so a large directory read in several batches is listed several times. It also answers .. of the root with the root itself, since InodeMap::new records the root as its own parent (source, get_parent_ino).

Methods left to fuser's defaults

Everything else uses the default bodies in fuser 0.18.0's Filesystem trait (source). The ones a reader of this mount actually hits:

MethodDefault behaviourEffect hereSource
open, opendirReply opened(FileHandle(0), empty flags).Opening always succeeds with handle 0. read and readdir ignore the handle, so this is stateless I/O.L576, L706
release, releasedirReply ok.Closing always succeeds.L671, L748
forget, batch_forgetDo nothing.Inodes are never freed (see namespace).L425
statfsReply all zeros, with block size 512 and a 255-byte name limit.df shows an empty file system.L776
flush, fsync, fsyncdir, access, readdirplus, xattrs, locks and the restLog [Not Implemented] and reply ENOSYS.Callers get "function not implemented".L651
setattr, mknod, mkdir, unlink, write, create, …ENOSYS as above.The mount is RO (source), so writes are refused before they reach the file system.L442

The async bridge and reply_async!

fuser's methods are synchronous and take &self, while the VirtualFilesystem is async. reply_async! bridges the two. Its arguments are self, the reply object, the inode, a path => async body pair and a value => success body pair. It then:

  1. clones the inode map and runtime handle, and spawns a task on the runtime. The FUSE method returns immediately, and the reply object moves into the task.
  2. In the task, resolves the inode with inode_map.get_path(ino). On failure it logs at error level and replies with the errno.
  3. Awaits the body with the path bound mutably (lookup pushes the child's name onto it).
  4. On Ok, runs the success body, which sends the reply. On Err, logs at debug level with the path, and replies with err.into().

Because replies are sent from tokio tasks, several requests can be in flight at once. init requests FUSE_ASYNC_READ and FUSE_PARALLEL_DIROPS so the kernel will send them that way (source). Every method also carries #[tracing::instrument(level = "debug")].

One read, end to end

Sequence of one read through jjfsd A process calls read on an open file. The kernel FUSE driver sends a READ request with the inode, offset and size. fuser's session thread calls JjFuse::read, which spawns a task on the tokio runtime and returns. The task calls InodeMap::get_path to turn the inode into a path, then PathMappedVfs::read, which asks the path mapper for the entry, opens a reader, discards offset bytes and reads up to size bytes. The task calls reply.data with the bytes, fuser writes the reply to the kernel, and the process's read returns. Errors take the same way back as an errno. process kernel FUSE fuser session JjFuse::read InodeMap PathMappedVfs read(fd, buf, n) READ ino, offset, size read(…, offset, size, reply) spawn task; return get_path(ino) path read(path, offset, size) get_entry · reader · skip offset · take size bytes (or JjError) reply.data(bytes) / reply.error(errno) n bytes (or −errno)

How PathMappedVfs::read serves an offset, by reading and discarding the first offset bytes every time, is on vfs-layer (source). Before any read, the kernel has already looked the path up and opened it with fuser's default open.

FileAttributes → FileAttr

FileAttributes has four fields: size, file_type, created and modified. to_fuse(ino) fills in every FileAttr field:

FileAttr fieldValue
inoThe inode passed in.
sizesize
blockssize.div_ceil(512)
atimeSystemTime::now(), with a TODO: properly set timestamps.
mtime, ctimemodified
crtimecreated
kindfile_type converted as below.
perm0o755 for directories, 0o644 otherwise, including symlinks. Nothing is ever executable.
nlink2 for directories, 1 otherwise.
uid, gidHard-coded 1000, whoever mounted it.
rdev, flags0
blksize4096

Both entry and attribute replies use TTL = 1 s (source), so the kernel re-asks after a second. lookup always reports generation 0.

FileType conversion

virtual_file::FileTypefuser::FileType
FileRegularFile
DirectoryDirectory
SymlinkSymlink

impl From<FileType> for fuser::FileType

JjError → errno

JjError variantMessageerrno
NotFoundNot foundENOENT
NotADirectoryExpected a directoryENOTDIR
NotAFileExpected a fileEISDIR
NotASymlinkExpected a symlinkEINVAL
InvalidPathInvalid pathEIO
IO(std::io::Error)the I/O error's textEIO
JjLibBackendError(BackendError)Underlying jj-lib error: …EIO

(enum, mapping). The fallback arm sends every other variant to EIO, including InvalidPath, so a non-UTF-8 name in lookup reports an I/O error rather than "no such file". It also discards the wrapped I/O error's own errno.