From 0dc28830478f61e7fb4680cfe643b80b49974f75 Mon Sep 17 00:00:00 2001 From: zeddybot Date: Tue, 7 Jul 2026 21:22:44 -0400 Subject: [PATCH] Support FUSE clients without READDIRPLUS by falling back to plain readdir pyfuse3 required FUSE_CAP_READDIRPLUS at init and registered only a readdirplus operation. A client that implements plain FUSE_READDIR but not FUSE_READDIRPLUS therefore could not use pyfuse3 at all: fuse_init raised "Kernel too old", leaving the mount unusable, and plain FUSE_READDIR requests hit a NULL operation and were answered with ENOSYS, so directory listings failed. gVisor's sentry is exactly such a client (gVisor issue #3404). Plain FUSE_READDIR is the mandatory baseline; READDIRPLUS (FUSE 7.21 / Linux 3.9) is a negotiated optimization that bundles attributes to save per-entry LOOKUPs. Register a plain readdir operation alongside readdirplus and stop aborting when READDIRPLUS is absent. The two share one async path; a read-only ReaddirToken.plus flag records which the client requested, and readdir_reply emits fuse_add_direntry (plain) or fuse_add_direntry_plus accordingly. Because plain FUSE_READDIR entries take no kernel lookup reference (only readdirplus entries do), filesystems must not increase the lookup count when token.plus is False, or the inode leaks with no balancing FORGET. This is documented on Operations.readdir and applied in the passthroughfs example. On a normal Linux kernel READDIRPLUS is always negotiated, so token.plus is always True and behaviour is unchanged. Signed-off-by: zeddybot --- Changes.rst | 12 ++++++++++++ examples/passthroughfs.py | 5 ++++- rst/data.rst | 7 +++++++ src/pyfuse3/__init__.pyi | 2 +- src/pyfuse3/__init__.pyx | 8 ++++++-- src/pyfuse3/_pyfuse3.py | 26 +++++++++++++++++++++----- src/pyfuse3/handlers.pxi | 21 +++++++++++++++++++-- src/pyfuse3/internal.pxi | 1 + test/test_fs.py | 13 +++++++++++++ 9 files changed, 84 insertions(+), 11 deletions(-) diff --git a/Changes.rst b/Changes.rst index 76344d0..d0728c2 100644 --- a/Changes.rst +++ b/Changes.rst @@ -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) ========================= diff --git a/examples/passthroughfs.py b/examples/passthroughfs.py index bf9b63a..2fe4350 100755 --- a/examples/passthroughfs.py +++ b/examples/passthroughfs.py @@ -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) diff --git a/rst/data.rst b/rst/data.rst index 85fbfd0..2975303 100644 --- a/rst/data.rst +++ b/rst/data.rst @@ -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 diff --git a/src/pyfuse3/__init__.pyi b/src/pyfuse3/__init__.pyi index 5c4b7ae..840e205 100644 --- a/src/pyfuse3/__init__.pyi +++ b/src/pyfuse3/__init__.pyi @@ -39,7 +39,7 @@ StatDict = Mapping[str, int] default_options: frozenset[str] class ReaddirToken: - pass + plus: bool class RequestContext: @property diff --git a/src/pyfuse3/__init__.pyx b/src/pyfuse3/__init__.pyx index 0bf2b68..0185434 100644 --- a/src/pyfuse3/__init__.pyx +++ b/src/pyfuse3/__init__.pyx @@ -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 diff --git a/src/pyfuse3/_pyfuse3.py b/src/pyfuse3/_pyfuse3.py index 6f9bfeb..5f0d5ff 100644 --- a/src/pyfuse3/_pyfuse3.py +++ b/src/pyfuse3/_pyfuse3.py @@ -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 diff --git a/src/pyfuse3/handlers.pxi b/src/pyfuse3/handlers.pxi index 50114e0..38e072e 100644 --- a/src/pyfuse3/handlers.pxi +++ b/src/pyfuse3/handlers.pxi @@ -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 &= ~( FUSE_CAP_READDIRPLUS_AUTO) if (operations.supports_dot_lookup and @@ -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): @@ -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) diff --git a/src/pyfuse3/internal.pxi b/src/pyfuse3/internal.pxi index 08a7dd5..3d5a52e 100644 --- a/src/pyfuse3/internal.pxi +++ b/src/pyfuse3/internal.pxi @@ -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 diff --git a/test/test_fs.py b/test/test_fs.py index 3ca636b..7e97d0d 100755 --- a/test/test_fs.py +++ b/test/test_fs.py @@ -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') @@ -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