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
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:
+67
-5
@@ -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,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)
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user