Skip to content
Open
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
12 changes: 12 additions & 0 deletions Changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@

.. currentmodule:: pyfuse3

Unreleased Changes
==================

* pyfuse3 no longer aborts with "Kernel too old" when the FUSE connection lacks
``FUSE_CAP_READDIRPLUS``, and now answers plain ``FUSE_READDIR`` requests in
addition to ``FUSE_READDIRPLUS``. This lets filesystems run under FUSE clients
that implement only plain readdir, such as gVisor's sentry (gVisor issue
#3404). Filesystems that track lookup counts must consult the new read-only
`ReaddirToken.plus` attribute: entries returned for a plain ``FUSE_READDIR``
take no kernel lookup reference and must not increase the lookup count. See
`~Operations.readdir`.

pyfuse 3.5.0 (2026-05-13)
=========================

Expand Down
5 changes: 4 additions & 1 deletion examples/passthroughfs.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,10 @@ async def readdir(self, fh: FileHandleT, start_id: int, token: ReaddirToken) ->
continue
if not pyfuse3.readdir_reply(token, fsencode(name), attr, ino):
break
self._add_path(attr.st_ino, os.path.join(path, name))
# A plain FUSE_READDIR (token.plus is False) takes no kernel lookup
# reference, so we must not bump the lookup count for it.
if token.plus:
self._add_path(attr.st_ino, os.path.join(path, name))

async def unlink(self, parent_inode: InodeT, name: bytes, ctx: RequestContext) -> None:
name_str = fsdecode(name)
Expand Down
7 changes: 7 additions & 0 deletions rst/data.rst
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,13 @@

An identifier for a particular `~Operations.readdir` invocation.

.. attribute:: plus

A read-only boolean indicating how the client requested the listing. If
True, the request was ``FUSE_READDIRPLUS`` and reported entries take a
kernel lookup reference; if False, it was a plain ``FUSE_READDIR`` and
they do not. See `~Operations.readdir` for the effect on lookup counts.

.. autoclass:: PollHandle

.. automethod:: notify
2 changes: 1 addition & 1 deletion src/pyfuse3/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ StatDict = Mapping[str, int]
default_options: frozenset[str]

class ReaddirToken:
pass
plus: bool

class RequestContext:
@property
Expand Down
8 changes: 6 additions & 2 deletions src/pyfuse3/__init__.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -1120,8 +1120,12 @@ def readdir_reply(ReaddirToken token, name, EntryAttributes attr, off_t next_id)
token.buf = token.buf_start

cname = PyBytes_AsString(name)
len_ = fuse_add_direntry_plus(token.req, token.buf, token.size,
cname, &attr.fuse_param, next_id)
if token.plus:
len_ = fuse_add_direntry_plus(token.req, token.buf, token.size,
cname, &attr.fuse_param, next_id)
else:
len_ = fuse_add_direntry(token.req, token.buf, token.size,
cname, &attr.fuse_param.attr, next_id)
if len_ > token.size:
return False

Expand Down
26 changes: 21 additions & 5 deletions src/pyfuse3/_pyfuse3.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,11 +509,27 @@ async def readdir(self, fh: FileHandleT, start_id: int, token: "ReaddirToken") -

Instead of returning the directory entries directly, the method must
call `readdir_reply` for each directory entry. If `readdir_reply`
returns True, the file system must increase the lookup count for the
provided directory entry by one and call `readdir_reply` again for the
next entry (if any). If `readdir_reply` returns False, the lookup count
must *not* be increased and the method should return without further
calls to `readdir_reply`.
returns True, the file system should call `readdir_reply` again for the
next entry (if any). If `readdir_reply` returns False, the method should
return without further calls to `readdir_reply`.

Whether reporting an entry takes a kernel lookup reference (and so
requires the file system to increase that inode's lookup count) depends
on how the client requested the listing. This is exposed as the
read-only `ReaddirToken.plus` attribute:

- If *token* ``.plus`` is True (the request was ``FUSE_READDIRPLUS``,
which is what a normal Linux kernel always sends), then each entry for
which `readdir_reply` returns True takes an implicit lookup reference,
and the file system *must* increase that inode's lookup count by one.
- If *token* ``.plus`` is False (the request was a plain
``FUSE_READDIR``, which some clients such as gVisor's sentry send),
the kernel takes no lookup reference for the reported entries, so the
file system must *not* increase the lookup count. Increasing it would
leak the inode, as no `forget` will ever balance it.

In either case, if `readdir_reply` returns False the lookup count must
*not* be increased for that entry.

The *start_id* parameter will be either zero (in which case listing
should begin with the first entry) or it will correspond to a value that
Expand Down
21 changes: 19 additions & 2 deletions src/pyfuse3/handlers.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,14 @@ cdef class _Container:
cdef size_t size
cdef struct_stat stat
cdef uint64_t fh
cdef bint readdir_plain

cdef void fuse_init (void *userdata, fuse_conn_info *conn):
if not conn.capable & FUSE_CAP_READDIRPLUS:
raise RuntimeError('Kernel too old, pyfuse3 requires kernel 3.9 or newer!')
# A client that supports FUSE_CAP_READDIRPLUS still issues plain
# FUSE_READDIR when it wants entries without attributes, and some clients
# (e.g. gVisor's sentry) implement only plain FUSE_READDIR. We register
# both operations and never require READDIRPLUS, so there is nothing to
# negotiate here beyond disabling the "auto" heuristic below.
conn.want &= ~(<unsigned> FUSE_CAP_READDIRPLUS_AUTO)

if (operations.supports_dot_lookup and
Expand Down Expand Up @@ -564,6 +568,7 @@ cdef class ReaddirToken:
cdef char *buf_start
cdef char *buf
cdef size_t size
cdef readonly bint plus

cdef void fuse_readdirplus (fuse_req_t req, fuse_ino_t ino, size_t size, off_t off,
fuse_file_info *fi):
Expand All @@ -575,12 +580,24 @@ cdef void fuse_readdirplus (fuse_req_t req, fuse_ino_t ino, size_t size, off_t o
c.fh = fi.fh
save_retval(fuse_readdirplus_async(c))

cdef void fuse_readdir (fuse_req_t req, fuse_ino_t ino, size_t size, off_t off,
fuse_file_info *fi):
global py_retval
cdef _Container c = _Container()
c.req = req
c.size = size
c.off = off
c.fh = fi.fh
c.readdir_plain = True
save_retval(fuse_readdirplus_async(c))

async def fuse_readdirplus_async (_Container c):
cdef int ret
cdef ReaddirToken token = ReaddirToken()
token.buf_start = NULL
token.size = c.size
token.req = c.req
token.plus = not c.readdir_plain

try:
await operations.readdir(c.fh, c.off, token)
Expand Down
1 change: 1 addition & 0 deletions src/pyfuse3/internal.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ cdef void init_fuse_ops():
fuse_ops.release = fuse_release
fuse_ops.fsync = fuse_fsync
fuse_ops.opendir = fuse_opendir
fuse_ops.readdir = fuse_readdir
fuse_ops.readdirplus = fuse_readdirplus
fuse_ops.releasedir = fuse_releasedir
fuse_ops.fsyncdir = fuse_fsyncdir
Expand Down
13 changes: 13 additions & 0 deletions test/test_fs.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ def _mount_fs(tmpdir, fs_class):
umount(mount_process, mnt_dir)


def test_readdir(testfs):
(mnt_dir, fs_state) = testfs
# Exercises opendir + readdir inside the mounted daemon. This also runs the
# ``assert token.plus is True`` invariant in Fs.readdir, confirming that a
# normal kernel negotiates FUSE_READDIRPLUS and that ReaddirToken.plus is
# readable from Python.
assert os.listdir(mnt_dir) == ['message']


def test_invalidate_entry(testfs):
(mnt_dir, fs_state) = testfs
path = os.path.join(mnt_dir, 'message')
Expand Down Expand Up @@ -275,6 +284,10 @@ async def opendir(self, inode, ctx):

async def readdir(self, fh: FileHandleT, start_id: int, token: ReaddirToken) -> None:
assert fh == pyfuse3.ROOT_INODE
# A normal Linux kernel always negotiates FUSE_READDIRPLUS, so token.plus
# must be True here. The plain-readdir path (token.plus is False) can only
# be reached with a client that issues FUSE_READDIR, such as gVisor.
assert token.plus is True
if start_id == 0:
pyfuse3.readdir_reply(token, self.hello_name, await self.getattr(self.hello_inode), 1)
return
Expand Down