tessl install tessl/pypi-pyfuse3@3.4.0Python 3 bindings for libfuse 3 with async I/O support
Understanding and correctly implementing lookup count tracking is critical for filesystem stability.
The kernel maintains lookup counts to track how many references it has to each inode. This prevents the filesystem from deleting inodes that the kernel still knows about.
EntryAttributes (except getattr/setattr) increase the count by 1forget() to decrease countsnlink is 0| Operation | Increment | Notes |
|---|---|---|
lookup() | +1 | Per successful lookup |
mknod() | +1 | New file node |
mkdir() | +1 | New directory |
symlink() | +1 | New symlink |
link() | +1 | New hard link |
create() | +1 | Create and open |
readdir() | +1 per entry | Via readdir_reply() (except . and ..) |
class MyFS(pyfuse3.Operations):
def __init__(self):
super().__init__()
self.lookup_counts = {} # inode -> count
self.inodes = {} # inode -> data
async def lookup(self, parent_inode, name, ctx):
"""Lookup with count tracking."""
inode = self._find_entry(parent_inode, name)
# CRITICAL: Increase lookup count
self.lookup_counts[inode] = self.lookup_counts.get(inode, 0) + 1
return await self.getattr(inode, ctx)
async def forget(self, inode_list):
"""Decrease lookup counts - MUST NOT raise exceptions."""
for inode, nlookup in inode_list:
try:
if inode in self.lookup_counts:
self.lookup_counts[inode] -= nlookup
# When count reaches zero
if self.lookup_counts[inode] <= 0:
del self.lookup_counts[inode]
# Only delete if also unlinked
if inode in self.inodes and self.inodes[inode]['nlink'] == 0:
self._delete_inode(inode)
except Exception as e:
logger.error(f"Error in forget: {e}", exc_info=True)import pyfuse3
import errno
import stat
import time
class LookupCountFS(pyfuse3.Operations):
def __init__(self):
super().__init__()
self.next_inode = pyfuse3.ROOT_INODE + 1
self.inodes = {}
self.lookup_counts = {}
def init(self):
"""Initialize with root inode."""
self.inodes[pyfuse3.ROOT_INODE] = {
'mode': stat.S_IFDIR | 0o755,
'nlink': 2,
'entries': {},
}
self.lookup_counts[pyfuse3.ROOT_INODE] = 1
async def create(self, parent_inode, name, mode, flags, ctx):
"""Create file with lookup count tracking."""
# Allocate inode
inode = self.next_inode
self.next_inode += 1
# Create inode data
self.inodes[inode] = {
'mode': stat.S_IFREG | (mode & ~ctx.umask),
'nlink': 1,
'data': b'',
}
# Add to directory
self.inodes[parent_inode]['entries'][name] = inode
# CRITICAL: Set initial lookup count to 1
self.lookup_counts[inode] = 1
fi = pyfuse3.FileInfo(fh=inode)
entry = await self.getattr(inode, ctx)
return (fi, entry)
async def unlink(self, parent_inode, name, ctx):
"""Remove file - don't delete inode yet."""
inode = self.inodes[parent_inode]['entries'][name]
# Remove from directory
del self.inodes[parent_inode]['entries'][name]
# Decrement link count
self.inodes[inode]['nlink'] -= 1
# DON'T delete inode here - kernel may still reference it!
# It will be deleted in forget() when lookup count reaches 0
async def forget(self, inode_list):
"""Decrease lookup counts."""
for inode, nlookup in inode_list:
try:
if inode in self.lookup_counts:
self.lookup_counts[inode] -= nlookup
if self.lookup_counts[inode] <= 0:
del self.lookup_counts[inode]
# Safe to delete when both conditions met:
# 1. lookup_count == 0 (kernel doesn't know about it)
# 2. nlink == 0 (no directory entries reference it)
if inode in self.inodes and self.inodes[inode]['nlink'] == 0:
del self.inodes[inode]
except Exception as e:
logger.error(f"Error in forget: {e}", exc_info=True)# WRONG - No lookup count tracking
async def lookup(self, parent_inode, name, ctx):
inode = self._find_entry(parent_inode, name)
return await self.getattr(inode, ctx) # Missing count increment!
# CORRECT
async def lookup(self, parent_inode, name, ctx):
inode = self._find_entry(parent_inode, name)
self.lookup_counts[inode] = self.lookup_counts.get(inode, 0) + 1 # Track it!
return await self.getattr(inode, ctx)# WRONG - Deletes inode immediately
async def unlink(self, parent_inode, name, ctx):
inode = self.inodes[parent_inode]['entries'][name]
del self.inodes[parent_inode]['entries'][name]
del self.inodes[inode] # WRONG - kernel may still reference it!
# CORRECT - Only decrement nlink
async def unlink(self, parent_inode, name, ctx):
inode = self.inodes[parent_inode]['entries'][name]
del self.inodes[parent_inode]['entries'][name]
self.inodes[inode]['nlink'] -= 1 # Decrement, don't delete
# Deletion happens in forget() when safe# WRONG - Doesn't track readdir counts
async def readdir(self, fh, start_id, token):
for name, inode in self.inodes[fh]['entries'].items():
attr = await self.getattr(inode, None)
pyfuse3.readdir_reply(token, name, attr, next_id)
# Missing: readdir_reply increases lookup count!
# CORRECT - Counts tracked automatically by readdir_reply
async def readdir(self, fh, start_id, token):
for name, inode in self.inodes[fh]['entries'].items():
attr = await self.getattr(inode, None)
if not pyfuse3.readdir_reply(token, name, attr, next_id):
break # readdir_reply handles count trackingasync def unlink(self, parent_inode, name, ctx):
"""
Unlink may be called while file is open.
Don't delete inode - it's still in use.
"""
inode = self.inodes[parent_inode]['entries'][name]
# Remove directory entry
del self.inodes[parent_inode]['entries'][name]
# Decrement nlink
self.inodes[inode]['nlink'] -= 1
# File may still be open (fh exists)
# AND kernel may still have references (lookup_count > 0)
# Don't delete - will be cleaned up in forget() and release()async def rename(self, parent_old, name_old, parent_new, name_new, flags, ctx):
"""
Rename doesn't change lookup counts.
Kernel handles count management.
"""
inode = self.inodes[parent_old]['entries'][name_old]
# If replacing existing entry
if name_new in self.inodes[parent_new]['entries']:
replaced_inode = self.inodes[parent_new]['entries'][name_new]
self.inodes[replaced_inode]['nlink'] -= 1
# Don't delete - kernel will call forget()
# Move entry
del self.inodes[parent_old]['entries'][name_old]
self.inodes[parent_new]['entries'][name_new] = inode
# No lookup count changes neededasync def lookup(self, parent_inode, name, ctx):
"""Lookup with debug logging."""
inode = self._find_entry(parent_inode, name)
old_count = self.lookup_counts.get(inode, 0)
self.lookup_counts[inode] = old_count + 1
logger.debug(f"lookup: inode={inode}, count {old_count} -> {old_count + 1}")
return await self.getattr(inode, ctx)
async def forget(self, inode_list):
"""Forget with debug logging."""
for inode, nlookup in inode_list:
old_count = self.lookup_counts.get(inode, 0)
new_count = old_count - nlookup
logger.debug(f"forget: inode={inode}, count {old_count} -> {new_count}")
# ... rest of forget logic ...class MonitoredFS(pyfuse3.Operations):
def __init__(self):
super().__init__()
self.lookup_counts = {}
self.stats = {
'lookup_calls': 0,
'forget_calls': 0,
'max_lookup_count': 0,
}
async def lookup(self, parent_inode, name, ctx):
self.stats['lookup_calls'] += 1
inode = self._find_entry(parent_inode, name)
self.lookup_counts[inode] = self.lookup_counts.get(inode, 0) + 1
self.stats['max_lookup_count'] = max(
self.stats['max_lookup_count'],
self.lookup_counts[inode]
)
return await self.getattr(inode, ctx)
async def forget(self, inode_list):
self.stats['forget_calls'] += 1
# ... forget logic ...nlinklookup_count == 0 AND nlink == 0lookup_counts[ROOT_INODE] = 1 in init(). and ..: readdir_reply() handles these specially