jj-vfs-poc / Inodes

Inodes

The kernel names files by inode number, while everything below JjFuse names them by path. InodeMap translates between the two: a tree of entries, one per path ever seen, keyed by a counter that only goes up.

On this page
  1. The types
  2. How numbers are allocated
  3. How a number becomes a path
  4. Who calls what
  5. Worked example
  6. Lifetime: nothing is forgotten
  7. Concurrency
  8. Tests
  9. Limitations

In short: inode 1 is the mount's root. The first time a name is looked up or listed under a directory, get_ino gives it the next number and records its parent and name. To serve a request, get_path walks parent links back to the root to rebuild the path. One mutex guards the whole map. Nothing is ever removed: forget isn't implemented, so the map holds every path the kernel has asked about since the mount, including names that turned out not to exist.

This page covers src/inode_map.rs. Its only user is the FUSE adapter, which owns one map per mount. Paths go from there to the virtual file layer.

The types

ItemType/SignatureMeaningSource
ROOT_INODEconst INodeNo = INodeNo(1)The mount's root directory.inode_map.rs
Entrystruct { parent: INodeNo, name: Ustr, children: HashMap<Ustr, INodeNo> }One path: its parent's inode, its last component, and the children seen so far, by name. Private to the module.inode_map.rs
InodeMapstruct { inodes: Mutex<HashMap<INodeNo, Entry>>, next_inode: AtomicU64 }Every entry by number, and the next number to hand out.inode_map.rs
newfn new() -> SelfInserts the root with itself as parent, the empty name and no children, and sets the counter to 2. Default calls it.L26, L93
get_pathfn get_path(&self, ino: INodeNo) -> Result<PathBuf, JjError>The path from the root, built from parent links. The root gives the empty path.L43
get_parent_inofn get_parent_ino(&self, ino: INodeNo) -> Result<INodeNo, JjError>The parent's number; the root's is 1.L58
get_inofn get_ino(&self, parent: INodeNo, name: &str) -> Result<INodeNo, JjError>The child's number, allocating one if this name hasn't been seen under this parent.L69

Names are Ustrs from the ustr crate (Cargo.toml): interned strings, so each distinct name is stored once and compared cheaply. The crate's own docs note that interned strings are never freed (ustr source).

How numbers are allocated

get_ino(parent, name) interns the name, takes the lock and finds the parent's entry. An unknown parent is JjError::NotFound. If the parent's children already has the name, that number is returned. Otherwise it takes the next number with next_inode.fetch_add(1, Relaxed), adds it to the parent's children, inserts a new Entry for it, and returns it.

  • Numbers follow request order. They count up from 2 in the order names are first seen, so the same file can get a different number on another mount (each JjFuse creates its own map, fuse.rs).
  • One number per path, not per object. The key is (parent, name), so the same file reached through commits/<id>/ and workspaces/<name>/ has two inodes, and nlink is always 1 for files (fuse.rs).
  • No checks against the file system. get_ino doesn't know whether the name exists or whether the parent is a directory. lookup calls it before asking for attributes (fuse.rs), so a lookup that fails with ENOENT still leaves an entry behind.
  • Never reused. Nothing is removed and the counter never goes down, so within one mount a number names one path. lookup always replies with generation 0 (fuse.rs).

How a number becomes a path

get_path(ino) holds the lock while it walks from the inode up to ROOT_INODE, collecting each entry's name; an unknown number on the way is NotFound. It then sizes a PathBuf for the names and separators and pushes the names root-first. The cost is one hash lookup per level. The root's empty path is what AllCommitsPathMapper treats as the top directory.

get_parent_ino(ino) returns the entry's parent. Because new makes the root its own parent (L28), the root's .. is the root.

Who calls what

FUSE opMap callsSource
lookup(parent, name)get_path(parent) through reply_async!, then get_ino(parent, name); the child number goes into the reply's FileAttr.fuse.rs
getattr(ino), read(ino, …), readlink(ino)get_path(ino) only.L98, L176
readdir(ino, offset)get_path(ino); get_parent_ino(ino) for .. when the offset is below 2; get_ino(ino, name) for each entry it sends.fuse.rs
forget, batch_forgetNone. JjFuse doesn't implement them, and fuser's default forget does nothing.fuser source

Every get_path failure is logged at error level and answered with ENOENT, the errno for NotFound (L44, L231). With nothing ever removed, that can only happen for a number the map never handed out.

Worked example

Derived from the code, not recorded from a mount: say the kernel's first requests after mounting are a listing of the root, then a lookup of commits, then a lookup of a name that doesn't exist. The root lists commits then workspaces (source), so readdir(1, 0) sends . as 1, .. as 1 and allocates 2 and 3. The lookup of commits finds 2 already in the root's children. The lookup of nope allocates 4 and then fails with ENOENT, but entry 4 stays.

InodeMap after listing the root and two lookups The map holds four entries. Inode 1 is the root, with the empty name and itself as parent; its children map names commits to 2, workspaces to 3 and nope to 4. Inodes 2, 3 and 4 each have parent 1 and no children. Inodes 2 and 3 were allocated by readdir of the root, and inode 4 by a lookup that then failed with ENOENT. get_path(2) walks from 2 to its parent 1 and returns the path commits. The counter next_inode is 5. ino 1 · ROOT_INODE · name "" parent: 1 (itself) children: commits→2, workspaces→3, nope→4 ino 2 · "commits" from readdir(1); parent 1 ino 3 · "workspaces" from readdir(1); parent 1 ino 4 · "nope" from a lookup that got ENOENT get_path(2): 2 → parent 1 = root names collected leaf first, pushed root first: "commits" next_inode = 5 fetch_add(1) under the lock; never decremented children

Lifetime: nothing is forgotten

The kernel sends forget when it drops an inode from its cache, and fuser's documentation says a file system "may ignore forget calls, if the inodes don't need to have a limited lifetime" (fuser source). JjFuse relies on that default, and InodeMap has no removal method at all. So the map, and the interned names, grow with every distinct (parent, name) pair seen during the mount: each entry listed by readdir, each successful lookup and each failed one. Listing commits/ allocates a number for every commit it sends.

Because entries are never removed, a number the kernel holds always still resolves. Because the repository snapshot is fixed when the mount starts (see namespace), a path keeps naming the same content for the life of the mount, so the map never needs invalidating.

Concurrency

  • JjFuse shares the map as an Arc<InodeMap> and clones it into every task reply_async! spawns on the tokio runtime (fuse.rs), so calls can come from several threads at once.
  • Every method takes the single Mutex around the whole HashMap and releases it before returning. They are synchronous, so the lock is never held across an await.
  • The counter is only incremented inside get_ino while the lock is held (L72), so two threads looking up the same new name get the same number. The atomic adds nothing the lock doesn't already give.
  • Each method calls lock().unwrap(), so a poisoned lock panics instead of returning an error (L45, L62, L72).

Tests

TestWhat it pins downSource
test_get_pathThe root's path is empty; entries inserted by hand as 2 (foo) and 3 (bar.txt under 2) give foo and foo/bar.txt.L103
test_get_inoThe first name gets 2, asking again returns 2, a child of it gets 3, and an unknown parent is NotFound.L138
test_inode_map_is_sync, test_inode_map_is_sendCompile-time checks that InodeMap is Sync and Send.L164

No test covers concurrent use or get_parent_ino. The mounted integration test exercises the map only indirectly; it is described on the testing page.

Limitations

LimitationEffectSource
No forget, no removal.Memory grows with every distinct path seen, including failed lookups and every listed commit.inode_map.rs
Inodes are per path.The same content under two paths has two inode numbers; hard links aren't modelled.L69
Numbers depend on request order.They aren't stable across mounts.fuse.rs
One lock for the whole map.All map calls are serialized. Each holds it briefly; contention wasn't measured.L21