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 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}")