From 35e41d500c976fcd71060a1175475a5c51e30d5b Mon Sep 17 00:00:00 2001 From: Christopher Obbard Date: Tue, 5 May 2026 10:11:33 +0100 Subject: [PATCH 1/5] add poll notification support Expose libfuse low-level poll support through pyfuse3 so filesystems can implement poll(2), select(2) and epoll readiness notifications. Add bindings for struct fuse_pollhandle, fuse_reply_poll(), fuse_lowlevel_notify_poll() and fuse_pollhandle_destroy(). Introduce a Python PollHandle wrapper and a notify_poll() helper, allowing a filesystem to retain the poll handle provided by Operations.poll() and notify it later when readiness changes. Wire the low-level FUSE poll callback into Operations.poll(), returning the current readiness mask to the kernel. The default implementation continues to raise ENOSYS so existing filesystems keep the previous fallback behaviour unless they opt in. This is needed by filesystems that emulate pollable kernel interfaces, such as sysfs GPIO value files, where edge events must wake userspace processes waiting for POLLPRI. Fixes: #139 Signed-off-by: Christopher Obbard --- Include/fuse_common.pxd | 5 ++++ Include/fuse_lowlevel.pxd | 4 +++ src/pyfuse3/__init__.pyi | 4 +++ src/pyfuse3/__init__.pyx | 62 +++++++++++++++++++++++++++++++++++++++ src/pyfuse3/_pyfuse3.py | 34 +++++++++++++++++++++ src/pyfuse3/handlers.pxi | 34 +++++++++++++++++++++ src/pyfuse3/internal.pxi | 1 + 7 files changed, 144 insertions(+) diff --git a/Include/fuse_common.pxd b/Include/fuse_common.pxd index cddbc33..f315fa8 100644 --- a/Include/fuse_common.pxd +++ b/Include/fuse_common.pxd @@ -42,6 +42,11 @@ cdef extern from * nogil: # fuse_common.h should not be included struct fuse_chan: pass + struct fuse_pollhandle: + pass + + void fuse_pollhandle_destroy(fuse_pollhandle *ph) + struct fuse_loop_config: int clone_fd unsigned max_idle_threads diff --git a/Include/fuse_lowlevel.pxd b/Include/fuse_lowlevel.pxd index fe21193..099d6f3 100644 --- a/Include/fuse_lowlevel.pxd +++ b/Include/fuse_lowlevel.pxd @@ -119,6 +119,8 @@ cdef extern from "" nogil: off_t offset, off_t length, fuse_file_info *fi) except * void (*readdirplus) (fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, fuse_file_info *fi) except * + void (*poll) (fuse_req_t req, fuse_ino_t ino, fuse_file_info *fi, + fuse_pollhandle *ph) except * # Reply functions @@ -137,6 +139,7 @@ cdef extern from "" nogil: fuse_buf_copy_flags flags) int fuse_reply_statfs(fuse_req_t req, statvfs *stbuf) int fuse_reply_xattr(fuse_req_t req, size_t count) + int fuse_reply_poll(fuse_req_t req, unsigned revents) size_t fuse_add_direntry(fuse_req_t req, const_char *buf, size_t bufsize, const_char *name, struct_stat *stbuf, @@ -157,6 +160,7 @@ cdef extern from "" nogil: fuse_buf_copy_flags flags) int fuse_lowlevel_notify_retrieve(fuse_session *se, fuse_ino_t ino, size_t size, off_t offset, void *cookie) + int fuse_lowlevel_notify_poll(fuse_pollhandle *ph) # Utility functions void *fuse_req_userdata(fuse_req_t req) diff --git a/src/pyfuse3/__init__.pyi b/src/pyfuse3/__init__.pyi index dfefd2b..8cb3d22 100644 --- a/src/pyfuse3/__init__.pyi +++ b/src/pyfuse3/__init__.pyi @@ -129,6 +129,9 @@ class FUSEError(Exception): def __init__(self, errno: int) -> None: ... def __str__(self) -> str: ... +class PollHandle: + def __getstate__(self) -> None: ... + def listdir(path: str) -> List[str]: ... def syncfs(path: str) -> str: ... def setxattr(path: str, name: str, value: bytes, namespace: NamespaceT = ...) -> None: ... @@ -143,6 +146,7 @@ def invalidate_entry_async( inode_p: InodeT, name: FileNameT, deleted: InodeT = ..., ignore_enoent: bool = ... ) -> None: ... def notify_store(inode: InodeT, offset: int, data: bytes) -> None: ... +def notify_poll(handle: PollHandle) -> None: ... def get_sup_groups(pid: int) -> set[int]: ... def readdir_reply( token: ReaddirToken, name: FileNameT, attr: EntryAttributes, next_id: int diff --git a/src/pyfuse3/__init__.pyx b/src/pyfuse3/__init__.pyx index 2a1e193..113548e 100644 --- a/src/pyfuse3/__init__.pyx +++ b/src/pyfuse3/__init__.pyx @@ -504,6 +504,40 @@ cdef class FUSEError(Exception): return strerror(self.errno_) +@cython.freelist(10) +cdef class PollHandle: + '''Opaque handle for delivering poll(2) readiness notifications. + + Instances of this class are created by pyfuse3 and passed to + `Operations.poll`. The filesystem may keep a reference and later + pass the handle to `notify_poll` to wake up any process currently + blocked in :manpage:`poll(2)`, :manpage:`select(2)` or + :manpage:`epoll_wait(2)` for the corresponding file descriptor. + + A single notification is sufficient to clear all pending waiters; + further notifications on the same handle are harmless but redundant. + + The underlying ``fuse_pollhandle`` is automatically destroyed when + the Python object is garbage collected, so filesystems should simply + drop the reference (e.g. by overwriting it with a fresh handle from + a subsequent ``poll`` call) when the notification is no longer + needed. + ''' + + cdef fuse_pollhandle *_ph + + def __cinit__(self): + self._ph = NULL + + def __dealloc__(self): + if self._ph is not NULL: + fuse_pollhandle_destroy(self._ph) + self._ph = NULL + + def __getstate__(self): + raise PicklingError("PollHandle instances can't be pickled") + + def listdir(path): '''Like `os.listdir`, but releases the GIL. @@ -952,6 +986,34 @@ def invalidate_entry_async(inode_p, name, deleted=0, ignore_enoent=False): _notify_queue.put((inode_p, name, deleted, ignore_enoent)) +def notify_poll(PollHandle handle not None): + '''Notify IO readiness for *handle*. + + *handle* must be a `PollHandle` instance that was previously received + by an `Operations.poll` call. After this returns, any process waiting + in :manpage:`poll(2)`, :manpage:`select(2)` or :manpage:`epoll_wait(2)` + on the corresponding file descriptor will be woken so it can re-poll + the filesystem for the current readiness mask. + + A single notification is enough to clear all pending waiters; calling + this function again on the same handle is harmless but redundant. + The handle remains valid (and may be notified again) until its Python + reference is dropped, at which point the underlying ``fuse_pollhandle`` + is destroyed. + ''' + + cdef int ret + + if handle._ph is NULL: + raise RuntimeError('PollHandle has been invalidated') + + with nogil: + ret = fuse_lowlevel_notify_poll(handle._ph) + + if ret != 0: + raise OSError(-ret, 'fuse_lowlevel_notify_poll returned: ' + strerror(-ret)) + + def notify_store(inode, offset, data): '''Store data in kernel page cache diff --git a/src/pyfuse3/_pyfuse3.py b/src/pyfuse3/_pyfuse3.py index 48cb1e5..489dd45 100644 --- a/src/pyfuse3/_pyfuse3.py +++ b/src/pyfuse3/_pyfuse3.py @@ -35,6 +35,7 @@ EntryAttributes, FileInfo, FUSEError, + PollHandle, ReaddirToken, RequestContext, SetattrFields, @@ -451,6 +452,39 @@ async def fsync(self, fh: FileHandleT, datasync: bool) -> None: raise FUSEError(errno.ENOSYS) + async def poll( + self, + inode: InodeT, + fh: FileHandleT, + poll_handle: Optional["PollHandle"], + ctx: "RequestContext", + ) -> int: + '''Check IO readiness on an open file. + + This method is called when a process performs :manpage:`poll(2)`, + :manpage:`select(2)` or :manpage:`epoll_wait(2)` on a file descriptor + backed by *fh* (returned by a prior `open` or `create` call). *inode* + identifies the inode that *fh* refers to. + + The method must return the bitwise-or of the currently active poll + events (e.g. ``select.POLLIN``, ``select.POLLOUT``, ``select.POLLPRI``). + If no events are currently ready, return ``0``. + + If *poll_handle* is not ``None``, the kernel is requesting to be + notified the next time readiness changes. The filesystem should + store the handle and later call `notify_poll` exactly once when + a relevant event becomes available. Each `~Operations.poll` call + produces a fresh handle; storing a new handle implicitly drops + any previously held one (which destroys the underlying libfuse + object). + + If this method raises ``FUSEError(errno.ENOSYS)`` (the default), + the kernel will fall back to a default poll implementation and + will not call this handler again for the lifetime of the mount. + ''' + + raise FUSEError(errno.ENOSYS) + async def opendir(self, inode: InodeT, ctx: "RequestContext") -> FileHandleT: '''Open the directory with inode *inode*. diff --git a/src/pyfuse3/handlers.pxi b/src/pyfuse3/handlers.pxi index a424be2..aa7d8c4 100644 --- a/src/pyfuse3/handlers.pxi +++ b/src/pyfuse3/handlers.pxi @@ -836,6 +836,40 @@ async def fuse_access_async (_Container c): +cdef void fuse_poll (fuse_req_t req, fuse_ino_t ino, fuse_file_info *fi, + fuse_pollhandle *ph): + cdef _Container c = _Container() + cdef PollHandle py_ph + c.req = req + c.ino = ino + if fi is NULL: + c.fh = 0 + else: + c.fh = fi.fh + if ph is NULL: + py_ph = None + else: + py_ph = PollHandle.__new__(PollHandle) + py_ph._ph = ph + save_retval(fuse_poll_async(c, py_ph)) + +async def fuse_poll_async (_Container c, PollHandle py_ph): + cdef int ret + cdef unsigned revents + + ctx = get_request_context(c.req) + try: + result = await operations.poll(c.ino, c.fh, py_ph, ctx) + except FUSEError as e: + ret = fuse_reply_err(c.req, e.errno) + else: + revents = (result if result is not None else 0) + ret = fuse_reply_poll(c.req, revents) + + if ret != 0: + log.error('fuse_poll(): fuse_reply_* failed with %s', strerror(-ret)) + + cdef void fuse_create (fuse_req_t req, fuse_ino_t parent, const_char *name, mode_t mode, fuse_file_info *fi): cdef _Container c = _Container() diff --git a/src/pyfuse3/internal.pxi b/src/pyfuse3/internal.pxi index 336c0b1..08a7dd5 100644 --- a/src/pyfuse3/internal.pxi +++ b/src/pyfuse3/internal.pxi @@ -69,6 +69,7 @@ cdef void init_fuse_ops(): fuse_ops.create = fuse_create fuse_ops.forget_multi = fuse_forget_multi fuse_ops.write_buf = fuse_write_buf + fuse_ops.poll = fuse_poll cdef make_fuse_args(args, fuse_args* f_args): cdef char* arg From 66c617da34c27d8ae4dfb873c7d45d6657eefed8 Mon Sep 17 00:00:00 2001 From: Christopher Obbard Date: Tue, 5 May 2026 10:19:36 +0100 Subject: [PATCH 2/5] test: cover poll notification support Add a pollable file to the test filesystem and exercise the new Operations.poll() and notify_poll() APIs. The test opens the synthetic file, starts a userspace poll(2) waiter, and waits until the filesystem has received and stored a PollHandle. It then triggers readiness through the existing setxattr command channel. The filesystem marks the file ready, calls notify_poll() and the test verifies that the blocked poller wakes with POLLPRI. This covers the notification path from the low-level FUSE poll callback, through the Python PollHandle wrapper, to fuse_lowlevel_notify_poll(). Signed-off-by: Christopher Obbard --- test/test_fs.py | 103 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 96 insertions(+), 7 deletions(-) diff --git a/test/test_fs.py b/test/test_fs.py index ed1bbe0..4f27243 100755 --- a/test/test_fs.py +++ b/test/test_fs.py @@ -21,6 +21,7 @@ import logging import multiprocessing import os +import select import stat import threading import time @@ -34,6 +35,7 @@ FileInfo, FUSEError, InodeT, + PollHandle, ReaddirToken, RequestContext, ) @@ -118,6 +120,38 @@ def test_notify_store(testfs): assert not fs_state.read_called +def test_notify_poll(testfs): + (mnt_dir, fs_state) = testfs + path = os.path.join(mnt_dir, 'pollable') + + with open(path, 'rb', buffering=0) as fh: + poller = select.poll() + poller.register(fh.fileno(), select.POLLPRI) + + events = [] + + def poll_wait(): + events.extend(poller.poll(5000)) + + thread = threading.Thread(target=poll_wait) + thread.start() + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and not fs_state.poll_handle_received: + time.sleep(0.01) + + assert fs_state.poll_called + assert fs_state.poll_handle_received + assert not events + + pyfuse3.setxattr(mnt_dir, 'command', b'poll_ready') + thread.join(5) + assert not thread.is_alive() + assert events + assert events[0][0] == fh.fileno() + assert events[0][1] & select.POLLPRI + + def test_entry_timeout(testfs): (mnt_dir, fs_state) = testfs fs_state.entry_timeout = 1 @@ -175,11 +209,17 @@ def __init__(self, cross_process): self.hello_name = b"message" self.hello_inode = cast(InodeT, pyfuse3.ROOT_INODE + 1) self.hello_data = b"hello world\n" + self.poll_name = b"pollable" + self.poll_inode = cast(InodeT, pyfuse3.ROOT_INODE + 2) + self.poll_handle: PollHandle | None = None self.status = cross_process self.lookup_cnt = 0 self.status.getattr_called = False self.status.lookup_called = False self.status.read_called = False + self.status.poll_called = False + self.status.poll_handle_received = False + self.status.poll_ready = False self.status.entry_timeout = 99999 self.status.attr_timeout = 99999 @@ -191,6 +231,9 @@ async def getattr(self, inode: InodeT, ctx: RequestContext | None = None) -> Ent elif inode == self.hello_inode: entry.st_mode = stat.S_IFREG | 0o644 entry.st_size = len(self.hello_data) + elif inode == self.poll_inode: + entry.st_mode = stat.S_IFREG | 0o644 + entry.st_size = 0 else: raise pyfuse3.FUSEError(errno.ENOENT) @@ -212,17 +255,25 @@ async def forget(self, inode_list): if inode == self.hello_inode: self.lookup_cnt -= 1 assert self.lookup_cnt >= 0 + elif inode == self.poll_inode: + pass else: assert inode == pyfuse3.ROOT_INODE async def lookup( self, parent_inode: InodeT, name: bytes, ctx: RequestContext ) -> EntryAttributes: - if parent_inode != pyfuse3.ROOT_INODE or name != self.hello_name: + if parent_inode != pyfuse3.ROOT_INODE: raise pyfuse3.FUSEError(errno.ENOENT) - self.lookup_cnt += 1 + self.status.lookup_called = True - return await self.getattr(self.hello_inode, ctx) + if name == self.hello_name: + self.lookup_cnt += 1 + return await self.getattr(self.hello_inode, ctx) + if name == self.poll_name: + return await self.getattr(self.poll_inode, ctx) + + raise pyfuse3.FUSEError(errno.ENOENT) async def opendir(self, inode, ctx): if inode != pyfuse3.ROOT_INODE: @@ -232,12 +283,19 @@ async def opendir(self, inode, ctx): async def readdir(self, fh: FileHandleT, start_id: int, token: ReaddirToken) -> None: assert fh == pyfuse3.ROOT_INODE - if start_id == 0: - pyfuse3.readdir_reply(token, self.hello_name, await self.getattr(self.hello_inode), 1) - return + entries = ( + (self.hello_name, self.hello_inode), + (self.poll_name, self.poll_inode), + ) + + for idx, (name, inode) in enumerate(entries): + if idx < start_id: + continue + if not pyfuse3.readdir_reply(token, name, await self.getattr(inode), idx + 1): + break async def open(self, inode, flags, ctx): - if inode != self.hello_inode: + if inode not in (self.hello_inode, self.poll_inode): raise pyfuse3.FUSEError(errno.ENOENT) if flags & os.O_RDWR or flags & os.O_WRONLY: raise pyfuse3.FUSEError(errno.EACCES) @@ -245,10 +303,33 @@ async def open(self, inode, flags, ctx): return FileInfo(fh=FileHandleT(inode)) async def read(self, fh, off, size): + if fh == self.poll_inode: + return b'' + assert fh == self.hello_inode self.status.read_called = True return self.hello_data[off : off + size] + async def poll( + self, + inode: InodeT, + fh: FileHandleT, + poll_handle: PollHandle | None, + ctx: RequestContext, + ) -> int: + assert inode == self.poll_inode + assert fh == self.poll_inode + + self.status.poll_called = True + if poll_handle is not None: + self.poll_handle = poll_handle + self.status.poll_handle_received = True + + if self.status.poll_ready: + return select.POLLPRI + + return 0 + async def setxattr(self, inode, name, value, ctx): if inode != pyfuse3.ROOT_INODE or name != b'command': raise FUSEError(errno.ENOTSUP) @@ -267,6 +348,14 @@ async def setxattr(self, inode, name, value, ctx): elif value == b'terminate': pyfuse3.terminate() + + elif value == b'poll_ready': + self.status.poll_ready = True + if self.poll_handle is None: + raise FUSEError(errno.EINVAL) + pyfuse3.notify_poll(self.poll_handle) + self.poll_handle = None + else: raise FUSEError(errno.EINVAL) From cec75a45de577e3d8054b54b978c48ebc091dcf1 Mon Sep 17 00:00:00 2001 From: Christopher Obbard Date: Tue, 5 May 2026 10:56:54 +0100 Subject: [PATCH 3/5] fixup: add poll notification support Expose libfuse low-level poll support through pyfuse3 so filesystems can implement poll(2), select(2) and epoll readiness notifications. Add bindings for struct fuse_pollhandle, fuse_reply_poll(), fuse_lowlevel_notify_poll() and fuse_pollhandle_destroy(). Introduce a Python PollHandle wrapper with a notify() method, allowing a filesystem to retain the handle provided by Operations.poll() and wake waiters later when readiness changes. Wire the low-level FUSE poll callback into Operations.poll(), returning the current readiness mask to the kernel. The default implementation continues to raise ENOSYS so existing filesystems keep the previous fallback behaviour unless they opt in. This is needed by filesystems that emulate pollable kernel interfaces, such as sysfs GPIO value files, where edge events must wake userspace processes waiting for POLLPRI. Signed-off-by: Christopher Obbard --- src/pyfuse3/__init__.pyi | 2 +- src/pyfuse3/__init__.pyx | 64 +++++++++++++++++++--------------------- 2 files changed, 32 insertions(+), 34 deletions(-) diff --git a/src/pyfuse3/__init__.pyi b/src/pyfuse3/__init__.pyi index 8cb3d22..5c4b7ae 100644 --- a/src/pyfuse3/__init__.pyi +++ b/src/pyfuse3/__init__.pyi @@ -131,6 +131,7 @@ class FUSEError(Exception): class PollHandle: def __getstate__(self) -> None: ... + def notify(self) -> None: ... def listdir(path: str) -> List[str]: ... def syncfs(path: str) -> str: ... @@ -146,7 +147,6 @@ def invalidate_entry_async( inode_p: InodeT, name: FileNameT, deleted: InodeT = ..., ignore_enoent: bool = ... ) -> None: ... def notify_store(inode: InodeT, offset: int, data: bytes) -> None: ... -def notify_poll(handle: PollHandle) -> None: ... def get_sup_groups(pid: int) -> set[int]: ... def readdir_reply( token: ReaddirToken, name: FileNameT, attr: EntryAttributes, next_id: int diff --git a/src/pyfuse3/__init__.pyx b/src/pyfuse3/__init__.pyx index 113548e..999b8e6 100644 --- a/src/pyfuse3/__init__.pyx +++ b/src/pyfuse3/__init__.pyx @@ -506,11 +506,12 @@ cdef class FUSEError(Exception): @cython.freelist(10) cdef class PollHandle: - '''Opaque handle for delivering poll(2) readiness notifications. + ''' + Opaque handle for delivering poll(2) readiness notifications. Instances of this class are created by pyfuse3 and passed to `Operations.poll`. The filesystem may keep a reference and later - pass the handle to `notify_poll` to wake up any process currently + call `PollHandle.notify` on the handle to wake up any process currently blocked in :manpage:`poll(2)`, :manpage:`select(2)` or :manpage:`epoll_wait(2)` for the corresponding file descriptor. @@ -519,9 +520,7 @@ cdef class PollHandle: The underlying ``fuse_pollhandle`` is automatically destroyed when the Python object is garbage collected, so filesystems should simply - drop the reference (e.g. by overwriting it with a fresh handle from - a subsequent ``poll`` call) when the notification is no longer - needed. + drop the reference when the notification is no longer needed. ''' cdef fuse_pollhandle *_ph @@ -537,6 +536,33 @@ cdef class PollHandle: def __getstate__(self): raise PicklingError("PollHandle instances can't be pickled") + def notify(self): + ''' + Notify IO readiness for this poll handle. + + After this returns, any process waiting in :manpage:`poll(2)`, + :manpage:`select(2)` or :manpage:`epoll_wait(2)` on the + corresponding file descriptor will be woken so it can re-poll + the filesystem for the current readiness mask. + + A single notification is enough to clear all pending waiters; + calling this method again on the same handle is harmless but + redundant. The handle remains valid until its Python reference is + dropped, at which point the underlying ``fuse_pollhandle`` is + destroyed. + ''' + + cdef int ret + + if self._ph is NULL: + raise RuntimeError('PollHandle has been invalidated') + + with nogil: + ret = fuse_lowlevel_notify_poll(self._ph) + + if ret != 0: + raise OSError(-ret, 'fuse_lowlevel_notify_poll returned: ' + strerror(-ret)) + def listdir(path): '''Like `os.listdir`, but releases the GIL. @@ -986,34 +1012,6 @@ def invalidate_entry_async(inode_p, name, deleted=0, ignore_enoent=False): _notify_queue.put((inode_p, name, deleted, ignore_enoent)) -def notify_poll(PollHandle handle not None): - '''Notify IO readiness for *handle*. - - *handle* must be a `PollHandle` instance that was previously received - by an `Operations.poll` call. After this returns, any process waiting - in :manpage:`poll(2)`, :manpage:`select(2)` or :manpage:`epoll_wait(2)` - on the corresponding file descriptor will be woken so it can re-poll - the filesystem for the current readiness mask. - - A single notification is enough to clear all pending waiters; calling - this function again on the same handle is harmless but redundant. - The handle remains valid (and may be notified again) until its Python - reference is dropped, at which point the underlying ``fuse_pollhandle`` - is destroyed. - ''' - - cdef int ret - - if handle._ph is NULL: - raise RuntimeError('PollHandle has been invalidated') - - with nogil: - ret = fuse_lowlevel_notify_poll(handle._ph) - - if ret != 0: - raise OSError(-ret, 'fuse_lowlevel_notify_poll returned: ' + strerror(-ret)) - - def notify_store(inode, offset, data): '''Store data in kernel page cache From 28963f8ea432bbc4b22c9a460f3a6ae32bacc528 Mon Sep 17 00:00:00 2001 From: Christopher Obbard Date: Tue, 5 May 2026 19:38:08 +0100 Subject: [PATCH 4/5] wip Signed-off-by: Christopher Obbard --- src/pyfuse3/__init__.pyx | 6 +++--- src/pyfuse3/_pyfuse3.py | 15 ++++++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/pyfuse3/__init__.pyx b/src/pyfuse3/__init__.pyx index 999b8e6..e4a233c 100644 --- a/src/pyfuse3/__init__.pyx +++ b/src/pyfuse3/__init__.pyx @@ -528,6 +528,9 @@ cdef class PollHandle: def __cinit__(self): self._ph = NULL + def __init__(self): + raise TypeError('PollHandle cannot be instantiated directly') + def __dealloc__(self): if self._ph is not NULL: fuse_pollhandle_destroy(self._ph) @@ -554,9 +557,6 @@ cdef class PollHandle: cdef int ret - if self._ph is NULL: - raise RuntimeError('PollHandle has been invalidated') - with nogil: ret = fuse_lowlevel_notify_poll(self._ph) diff --git a/src/pyfuse3/_pyfuse3.py b/src/pyfuse3/_pyfuse3.py index 489dd45..24de0b0 100644 --- a/src/pyfuse3/_pyfuse3.py +++ b/src/pyfuse3/_pyfuse3.py @@ -470,13 +470,18 @@ async def poll( events (e.g. ``select.POLLIN``, ``select.POLLOUT``, ``select.POLLPRI``). If no events are currently ready, return ``0``. + If *poll_handle* is ``None``, the kernel is only asking for the + current readiness mask -- no process is queued waiting for a + notification. The filesystem should just return the current event + bitmask without storing anything. + If *poll_handle* is not ``None``, the kernel is requesting to be notified the next time readiness changes. The filesystem should - store the handle and later call `notify_poll` exactly once when - a relevant event becomes available. Each `~Operations.poll` call - produces a fresh handle; storing a new handle implicitly drops - any previously held one (which destroys the underlying libfuse - object). + store the handle and later call `PollHandle.notify` exactly once + when a relevant event becomes available. Each `~Operations.poll` + call produces a fresh handle; storing a new handle implicitly + drops any previously held one (which destroys the underlying + libfuse object). If this method raises ``FUSEError(errno.ENOSYS)`` (the default), the kernel will fall back to a default poll implementation and From 06ba886b04fc3f32bfe7fb74efb22c6334bb9413 Mon Sep 17 00:00:00 2001 From: Christopher Obbard Date: Tue, 5 May 2026 19:38:13 +0100 Subject: [PATCH 5/5] wip test Signed-off-by: Christopher Obbard --- test/test_fs.py | 168 +++++++++++++++++++++++++++++++----------------- 1 file changed, 109 insertions(+), 59 deletions(-) diff --git a/test/test_fs.py b/test/test_fs.py index 4f27243..0fab05a 100755 --- a/test/test_fs.py +++ b/test/test_fs.py @@ -61,11 +61,22 @@ def get_mp(): @pytest.fixture() def testfs(tmpdir): + yield from _mount_fs(tmpdir, Fs) + + +@pytest.fixture() +def pollfs(tmpdir): + yield from _mount_fs(tmpdir, PollTestFs) + + +def _mount_fs(tmpdir, fs_class): mnt_dir = str(tmpdir) mp = get_mp() with mp.Manager() as mgr: cross_process = mgr.Namespace() - mount_process = mp.Process(target=run_fs, args=(mnt_dir, cross_process)) + mount_process = mp.Process( + target=run_fs, args=(mnt_dir, cross_process, fs_class) + ) mount_process.start() try: @@ -120,8 +131,8 @@ def test_notify_store(testfs): assert not fs_state.read_called -def test_notify_poll(testfs): - (mnt_dir, fs_state) = testfs +def test_notify_poll(pollfs): + (mnt_dir, fs_state) = pollfs path = os.path.join(mnt_dir, 'pollable') with open(path, 'rb', buffering=0) as fh: @@ -209,17 +220,11 @@ def __init__(self, cross_process): self.hello_name = b"message" self.hello_inode = cast(InodeT, pyfuse3.ROOT_INODE + 1) self.hello_data = b"hello world\n" - self.poll_name = b"pollable" - self.poll_inode = cast(InodeT, pyfuse3.ROOT_INODE + 2) - self.poll_handle: PollHandle | None = None self.status = cross_process self.lookup_cnt = 0 self.status.getattr_called = False self.status.lookup_called = False self.status.read_called = False - self.status.poll_called = False - self.status.poll_handle_received = False - self.status.poll_ready = False self.status.entry_timeout = 99999 self.status.attr_timeout = 99999 @@ -231,9 +236,6 @@ async def getattr(self, inode: InodeT, ctx: RequestContext | None = None) -> Ent elif inode == self.hello_inode: entry.st_mode = stat.S_IFREG | 0o644 entry.st_size = len(self.hello_data) - elif inode == self.poll_inode: - entry.st_mode = stat.S_IFREG | 0o644 - entry.st_size = 0 else: raise pyfuse3.FUSEError(errno.ENOENT) @@ -255,25 +257,17 @@ async def forget(self, inode_list): if inode == self.hello_inode: self.lookup_cnt -= 1 assert self.lookup_cnt >= 0 - elif inode == self.poll_inode: - pass else: assert inode == pyfuse3.ROOT_INODE async def lookup( self, parent_inode: InodeT, name: bytes, ctx: RequestContext ) -> EntryAttributes: - if parent_inode != pyfuse3.ROOT_INODE: + if parent_inode != pyfuse3.ROOT_INODE or name != self.hello_name: raise pyfuse3.FUSEError(errno.ENOENT) - + self.lookup_cnt += 1 self.status.lookup_called = True - if name == self.hello_name: - self.lookup_cnt += 1 - return await self.getattr(self.hello_inode, ctx) - if name == self.poll_name: - return await self.getattr(self.poll_inode, ctx) - - raise pyfuse3.FUSEError(errno.ENOENT) + return await self.getattr(self.hello_inode, ctx) async def opendir(self, inode, ctx): if inode != pyfuse3.ROOT_INODE: @@ -282,12 +276,90 @@ async def opendir(self, inode, ctx): return FileHandleT(inode) async def readdir(self, fh: FileHandleT, start_id: int, token: ReaddirToken) -> None: + assert fh == pyfuse3.ROOT_INODE + if start_id == 0: + pyfuse3.readdir_reply(token, self.hello_name, await self.getattr(self.hello_inode), 1) + return + + async def open(self, inode, flags, ctx): + if inode != self.hello_inode: + raise pyfuse3.FUSEError(errno.ENOENT) + if flags & os.O_RDWR or flags & os.O_WRONLY: + raise pyfuse3.FUSEError(errno.EACCES) + # For simplicity, we use the inode as file handle + return FileInfo(fh=FileHandleT(inode)) + + async def read(self, fh, off, size): + assert fh == self.hello_inode + self.status.read_called = True + return self.hello_data[off : off + size] + + async def setxattr(self, inode, name, value, ctx): + if inode != pyfuse3.ROOT_INODE or name != b'command': + raise FUSEError(errno.ENOTSUP) + + if value == b'forget_entry': + pyfuse3.invalidate_entry_async(pyfuse3.ROOT_INODE, self.hello_name) + + # Make sure that the request is pending before we return + await trio.sleep(0.1) + + elif value == b'forget_inode': + pyfuse3.invalidate_inode(self.hello_inode) + + elif value == b'store': + pyfuse3.notify_store(self.hello_inode, offset=0, data=self.hello_data) + + elif value == b'terminate': + pyfuse3.terminate() + + else: + raise FUSEError(errno.EINVAL) + + +class PollTestFs(Fs): + def __init__(self, cross_process): + super().__init__(cross_process) + self.poll_name = b"pollable" + self.poll_inode = cast(InodeT, pyfuse3.ROOT_INODE + 2) + self.poll_handle: PollHandle | None = None + self.status.poll_called = False + self.status.poll_handle_received = False + self.status.poll_ready = False + + async def getattr(self, inode, ctx=None): + if inode != self.poll_inode: + return await super().getattr(inode, ctx) + + entry = EntryAttributes() + entry.st_mode = stat.S_IFREG | 0o644 + entry.st_size = 0 + stamp = int(1438467123.985654 * 1e9) + entry.st_atime_ns = stamp + entry.st_ctime_ns = stamp + entry.st_mtime_ns = stamp + entry.st_gid = os.getgid() + entry.st_uid = os.getuid() + entry.st_ino = inode + entry.entry_timeout = self.status.entry_timeout + entry.attr_timeout = self.status.attr_timeout + self.status.getattr_called = True + return entry + + async def lookup(self, parent_inode, name, ctx): + if name != self.poll_name: + return await super().lookup(parent_inode, name, ctx) + if parent_inode != pyfuse3.ROOT_INODE: + raise pyfuse3.FUSEError(errno.ENOENT) + self.status.lookup_called = True + return await self.getattr(self.poll_inode, ctx) + + async def readdir(self, fh, start_id, token): assert fh == pyfuse3.ROOT_INODE entries = ( (self.hello_name, self.hello_inode), (self.poll_name, self.poll_inode), ) - for idx, (name, inode) in enumerate(entries): if idx < start_id: continue @@ -295,20 +367,16 @@ async def readdir(self, fh: FileHandleT, start_id: int, token: ReaddirToken) -> break async def open(self, inode, flags, ctx): - if inode not in (self.hello_inode, self.poll_inode): - raise pyfuse3.FUSEError(errno.ENOENT) + if inode != self.poll_inode: + return await super().open(inode, flags, ctx) if flags & os.O_RDWR or flags & os.O_WRONLY: raise pyfuse3.FUSEError(errno.EACCES) - # For simplicity, we use the inode as file handle return FileInfo(fh=FileHandleT(inode)) async def read(self, fh, off, size): - if fh == self.poll_inode: - return b'' - - assert fh == self.hello_inode - self.status.read_called = True - return self.hello_data[off : off + size] + if fh != self.poll_inode: + return await super().read(fh, off, size) + return b'' async def poll( self, @@ -331,36 +399,18 @@ async def poll( return 0 async def setxattr(self, inode, name, value, ctx): + if value != b'poll_ready': + return await super().setxattr(inode, name, value, ctx) if inode != pyfuse3.ROOT_INODE or name != b'command': raise FUSEError(errno.ENOTSUP) - - if value == b'forget_entry': - pyfuse3.invalidate_entry_async(pyfuse3.ROOT_INODE, self.hello_name) - - # Make sure that the request is pending before we return - await trio.sleep(0.1) - - elif value == b'forget_inode': - pyfuse3.invalidate_inode(self.hello_inode) - - elif value == b'store': - pyfuse3.notify_store(self.hello_inode, offset=0, data=self.hello_data) - - elif value == b'terminate': - pyfuse3.terminate() - - elif value == b'poll_ready': - self.status.poll_ready = True - if self.poll_handle is None: - raise FUSEError(errno.EINVAL) - pyfuse3.notify_poll(self.poll_handle) - self.poll_handle = None - - else: + self.status.poll_ready = True + if self.poll_handle is None: raise FUSEError(errno.EINVAL) + self.poll_handle.notify() + self.poll_handle = None -def run_fs(mountpoint, cross_process): +def run_fs(mountpoint, cross_process, fs_class=Fs): # Logging (note that we run in a new process, so we can't # rely on direct log capture and instead print to stdout) root_logger = logging.getLogger() @@ -374,7 +424,7 @@ def run_fs(mountpoint, cross_process): root_logger.addHandler(handler) root_logger.setLevel(logging.DEBUG) - testfs = Fs(cross_process) + testfs = fs_class(cross_process) fuse_options = set(pyfuse3.default_options) fuse_options.add('fsname=pyfuse3_testfs') pyfuse3.init(testfs, mountpoint, fuse_options)