fix(watcher): auto-polling sur mounts reseau (NFS/SMB) pour maj temps reel des recents
CI / lint (push) Failing after 25s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 28s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

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.
This commit is contained in:
2026-09-02 08:16:21 -04:00
parent 2115cb9085
commit ef7a3ff1ed
2 changed files with 141 additions and 5 deletions
+67 -5
View File
@@ -1,7 +1,8 @@
import asyncio import asyncio
import logging import logging
import os
import time import time
from collections.abc import Callable from collections.abc import Callable, Iterable
from pathlib import Path from pathlib import Path
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
@@ -12,6 +13,55 @@ from backend.indexer import IGNORED_DIRS, SUPPORTED_EXTENSIONS
logger = logging.getLogger("obsigate.watcher") 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): class VaultEventHandler(FileSystemEventHandler):
"""Gestionnaire d'événements filesystem pour une vault Obsidian. """Gestionnaire d'événements filesystem pour une vault Obsidian.
@@ -114,7 +164,12 @@ class VaultWatcher:
vault_path: str, vault_path: str,
loop: asyncio.AbstractEventLoop, 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) path = Path(vault_path)
if not path.exists(): if not path.exists():
logger.warning(f"Vault '{vault_name}' path not found: {vault_path}") 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) 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: try:
observer = ObserverClass( 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.schedule(handler, str(path), recursive=True)
observer.daemon = True observer.daemon = True
observer.start() observer.start()
self.observers[vault_name] = observer 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}") logger.info(f"Watching ({mode}): {vault_name} -> {vault_path}")
except Exception as e: except Exception as e:
logger.error(f"Failed to start watcher for '{vault_name}': {e}") logger.error(f"Failed to start watcher for '{vault_name}': {e}")
+74
View File
@@ -74,6 +74,80 @@ class TestIsRelevant:
assert handler._is_relevant("/vault/Makefile") is True 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) # VaultWatcher (unit tests with mocks)
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════