From ef7a3ff1ed88732d6c0826c2ef5ba66e1f74aafc Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 2 Sep 2026 08:16:21 -0400 Subject: [PATCH] fix(watcher): auto-polling sur mounts reseau (NFS/SMB) pour maj temps reel des recents inotify ne voit pas les ecritures faites depuis d'autres clients NFS (Obsidian sur Windows), donc index_updated n'etait jamais emis et la section Recent du sidebar ne se rafraichissait pas. Detection du fstype par vault via /proc/mounts -> PollingObserver (stat-based) sur mounts reseau, Observer natif conserve sur les mounts locaux. --- backend/watcher.py | 72 ++++++++++++++++++++++++++++++++++++++--- tests/test_watcher.py | 74 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/backend/watcher.py b/backend/watcher.py index 673f587..a840be6 100644 --- a/backend/watcher.py +++ b/backend/watcher.py @@ -1,7 +1,8 @@ import asyncio import logging +import os import time -from collections.abc import Callable +from collections.abc import Callable, Iterable from pathlib import Path from watchdog.events import FileSystemEventHandler @@ -12,6 +13,55 @@ from backend.indexer import IGNORED_DIRS, SUPPORTED_EXTENSIONS logger = logging.getLogger("obsigate.watcher") +# Filesystem types where inotify does NOT fire for changes made by other +# clients (the edit happens on another machine — e.g. Obsidian on Windows +# writing to an NFS/SMB export). For those mounts watchdog's native +# Observer silently misses everything and polling is the only option. +NETWORK_FSTYPES = { + "nfs", "nfs4", "cifs", "smbfs", "smb2", "smb3", + "fuse", "fuse.sshfs", "glusterfs", "9p", "virtiofs", "lustre", +} + + +def find_mount_fstype(resolved_path: str, mount_lines: Iterable[str]) -> str: + """Return the fstype of the longest /proc/mounts entry covering the path. + + Pure function (mount_lines are text lines) so it can be unit-tested + without /proc. Returns "" when no mount matches. + """ + best_point = "" + best_fstype = "" + for line in mount_lines: + parts = line.split() + if len(parts) < 3: + continue + mount_point, fstype = parts[1], parts[2] + # /proc/mounts escapes special chars in octal + mount_point = mount_point.replace("\\040", " ").replace("\\011", "\t") + root = mount_point.rstrip("/") or "/" + if resolved_path == root or resolved_path.startswith(root + "/"): + if len(mount_point) > len(best_point): + best_point = mount_point + best_fstype = fstype + return best_fstype + + +def is_network_mount(path: str) -> bool: + """True if *path* sits on a network/fuse filesystem (Linux only). + + inotify events are local to the client that made the write, so vaults + shared over NFS/SMB never see Obsidian's edits. On those mounts we + must fall back to PollingObserver, which detects changes by stat(). + """ + if os.name != "posix": + return False + try: + with open("/proc/mounts", "r", encoding="utf-8") as fh: + fstype = find_mount_fstype(os.path.realpath(path), fh) + except OSError: + return False + return fstype in NETWORK_FSTYPES + class VaultEventHandler(FileSystemEventHandler): """Gestionnaire d'événements filesystem pour une vault Obsidian. @@ -114,7 +164,12 @@ class VaultWatcher: vault_path: str, loop: asyncio.AbstractEventLoop, ): - """Créer et démarrer un observer pour une vault.""" + """Créer et démarrer un observer pour une vault. + + Le mode (natif inotify vs polling) est choisi par vault : + les mounts réseau (NFS/SMB/fuse) sont surveillés en polling car + inotify ne voit pas les écritures faites depuis d'autres clients. + """ path = Path(vault_path) if not path.exists(): logger.warning(f"Vault '{vault_name}' path not found: {vault_path}") @@ -122,16 +177,23 @@ class VaultWatcher: handler = VaultEventHandler(vault_name, self.event_queue, loop) - ObserverClass = PollingObserver if self.use_polling else Observer + polling = self.use_polling or is_network_mount(vault_path) + if polling and not self.use_polling: + logger.info( + f"Vault '{vault_name}' is on a network mount — " + "using polling watcher (inotify cannot see remote edits)" + ) + + ObserverClass = PollingObserver if polling else Observer try: observer = ObserverClass( - timeout=self.polling_interval if self.use_polling else 1 + timeout=self.polling_interval if polling else 1 ) observer.schedule(handler, str(path), recursive=True) observer.daemon = True observer.start() self.observers[vault_name] = observer - mode = "polling" if self.use_polling else "native" + mode = "polling" if polling else "native" logger.info(f"Watching ({mode}): {vault_name} -> {vault_path}") except Exception as e: logger.error(f"Failed to start watcher for '{vault_name}': {e}") diff --git a/tests/test_watcher.py b/tests/test_watcher.py index a094492..80d9db7 100644 --- a/tests/test_watcher.py +++ b/tests/test_watcher.py @@ -74,6 +74,80 @@ class TestIsRelevant: assert handler._is_relevant("/vault/Makefile") is True +# ═══════════════════════════════════════════════════════════════════ +# Network-mount detection (NFS/SMB → polling watcher required) +# ═══════════════════════════════════════════════════════════════════ + +class TestNetworkMountDetection: + MOUNTS = [ + "overlay / overlay rw 0 0", + "server:/export/NFS/OBSIDIAN_DOC /vaults/Obsidian_IT nfs4 rw 0 0", + "server:/export/home/bruno /vaults/bruno nfs4 rw 0 0", + "dev/sda1 /data ext4 rw 0 0", + "srv:/share /mnt/smb cifs rw 0 0", + ] + + def test_nfs_mount_detected(self): + from backend.watcher import find_mount_fstype + ft = find_mount_fstype("/vaults/Obsidian_IT/note.md", self.MOUNTS) + assert ft == "nfs4" + + def test_nested_nfs_mount(self): + from backend.watcher import find_mount_fstype + ft = find_mount_fstype("/vaults/bruno/sub/dir", self.MOUNTS) + assert ft == "nfs4" + + def test_smb_mount_detected(self): + from backend.watcher import find_mount_fstype + ft = find_mount_fstype("/mnt/smb/file.md", self.MOUNTS) + assert ft == "cifs" + + def test_local_fs_not_network(self): + from backend.watcher import find_mount_fstype + ft = find_mount_fstype("/data/file.md", self.MOUNTS) + assert ft == "ext4" + + def test_unmatched_path_returns_empty(self): + from backend.watcher import find_mount_fstype + assert find_mount_fstype("/nowhere/file.md", self.MOUNTS) == "" + + def test_longest_prefix_wins(self): + from backend.watcher import find_mount_fstype + lines = self.MOUNTS + ["tmpfs /vaults/Obsidian_IT/tmp tmpfs rw 0 0"] + assert find_mount_fstype("/vaults/Obsidian_IT/tmp/x", lines) == "tmpfs" + assert find_mount_fstype("/vaults/Obsidian_IT/other", lines) == "nfs4" + + def test_escaped_space_in_mount_point(self): + from backend.watcher import find_mount_fstype + lines = ["srv:/x /vaults/My\\040Vault nfs rw 0 0"] + assert find_mount_fstype("/vaults/My Vault/a.md", lines) == "nfs" + + def test_is_network_mount_false_on_windows(self, monkeypatch): + import backend.watcher as w + monkeypatch.setattr(w.os, "name", "nt") + assert w.is_network_mount("/vaults/x") is False + + def test_watch_vault_uses_polling_for_network_mount(self, monkeypatch): + """_watch_vault must pick PollingObserver when is_network_mount is True.""" + import asyncio + from unittest.mock import MagicMock + import backend.watcher as w + + watcher = w.VaultWatcher(on_file_change=MagicMock()) + monkeypatch.setattr(w, "is_network_mount", lambda p: True) + mock_poll = MagicMock() + monkeypatch.setattr(w, "PollingObserver", mock_poll) + + with tempfile.TemporaryDirectory() as tmpdir: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete( + watcher._watch_vault("NetVault", tmpdir, loop) + ) + mock_poll.assert_called_once() + assert "NetVault" in watcher.observers + + # ═══════════════════════════════════════════════════════════════════ # VaultWatcher (unit tests with mocks) # ═══════════════════════════════════════════════════════════════════