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
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
ROOT_INODE | const INodeNo = INodeNo(1) | The mount's root directory. | inode_map.rs |
Entry | struct { 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 |
InodeMap | struct { inodes: Mutex<HashMap<INodeNo, Entry>>, next_inode: AtomicU64 } | Every entry by number, and the next number to hand out. | inode_map.rs |
new | fn new() -> Self | Inserts the root with itself as parent, the empty name and no children, and sets the counter to 2. Default calls it. | L26, L93 |
get_path | fn 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_ino | fn get_parent_ino(&self, ino: INodeNo) -> Result<INodeNo, JjError> | The parent's number; the root's is 1. | L58 |
get_ino | fn 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
JjFusecreates 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>/andworkspaces/<name>/has two inodes, andnlinkis always 1 for files (fuse.rs). - No checks against the file system.
get_inodoesn't know whether the name exists or whether the parent is a directory.lookupcalls it before asking for attributes (fuse.rs), so a lookup that fails withENOENTstill leaves an entry behind. - Never reused. Nothing is removed and the counter never goes down, so within one mount a
number names one path.
lookupalways 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 op | Map calls | Source |
|---|---|---|
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_forget | None. 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.
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
JjFuseshares the map as anArc<InodeMap>and clones it into every taskreply_async!spawns on the tokio runtime (fuse.rs), so calls can come from several threads at once.- Every method takes the single
Mutexaround the wholeHashMapand releases it before returning. They are synchronous, so the lock is never held across anawait. - The counter is only incremented inside
get_inowhile 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
| Test | What it pins down | Source |
|---|---|---|
test_get_path | The 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_ino | The 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_send | Compile-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
| Limitation | Effect | Source |
|---|---|---|
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 |