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