"""A per-process cache of LoraManager's layout for the image hot paths (#362). Every Library list, prompt detail, inspect, reveal and serve request needs the LoRA allow-list (``ImagesMixin._lora_image_dirs``). Building it means finding the LoraManager install, reading its plugin settings file and its settings file, and resolving every folder. ``LayoutCache`` keeps the last result and reuses it while all of these hold: - the key (the configured install path, stripped) is the same; - the entry is younger than ``MAX_AGE_SECONDS`` (monotonic clock); - the plugin settings file and the settings file have the same signature (``st_mtime_ns``, ``st_size``, or ``MISSING``) as when it was stored; - the install and every stored media root still resolve to themselves (a folder swapped for a symlink reloads, so the folder guard runs again). A result is stored only when no watched file is "racy" (modified within ``RACY_SECONDS`` of the load, git's rule for racily clean index entries), every watched file could be checked, the settings file was readable, and no ``clear()`` ran during the load. What each rule covers: signatures catch a settings change on the next request; the racy rule catches a write during the load, a half-written file and coarse timestamps; the maximum age bounds the inputs no signature sees (``extra_model_paths.yaml``, environment, temporary failures); ``clear()`` makes a finished import's new folders servable at once. Hits and loads take no lock: a slow ``os.stat`` on a network share holds up only its own request, and two threads that miss together both load (both loads only read). A short lock guards the compare-and-store and the logging state; no file I/O runs under it. Standard library only, so it is tested without ComfyUI or LoraManager. """ import logging import os import threading import time from dataclasses import dataclass from typing import Callable, Optional, Tuple from .lora_manager_layout import UNREADABLE, LoraManagerLayout, _canonical # A watched file modified this recently is not trusted: LoraManager writes its # settings in place, and FAT/exFAT store modification times in 2 s steps. RACY_SECONDS = 3 # Bounds what no signature sees (unwatched inputs, temporary failures). MAX_AGE_SECONDS = 30 # The signature of a watched file that does not exist (a valid state). MISSING = object() logger = logging.getLogger("prompt_manager.lora_layout_cache") @dataclass(frozen=True) class _Entry: key: str install: Optional[str] layout: LoraManagerLayout signatures: Tuple[Tuple[str, object], ...] stored_at: float class _CannotValidate(Exception): """A watched file could not be checked (not merely missing).""" class LayoutCache: """At most one ``(install, layout)`` for the active LoraManager config.""" def __init__( self, resolve: Callable[[str], Optional[str]], load: Callable[[Optional[str]], LoraManagerLayout], *, wall_ns: Callable[[], int] = time.time_ns, monotonic: Callable[[], float] = time.monotonic, stat=os.stat, pathmod=os.path, ): self._resolve = resolve self._load = load self._wall_ns = wall_ns self._monotonic = monotonic self._stat = stat self._pathmod = pathmod self._entry: Optional[_Entry] = None self._generation = 0 self._lock = threading.Lock() def get(self, configured: str) -> Tuple[Optional[str], LoraManagerLayout]: """``(install, layout)`` for the configured path ("" auto-detects).""" key = (configured or "").strip() entry = self._entry if entry is not None and self._valid(entry, key): return entry.install, entry.layout with self._lock: if entry is not None and self._entry is entry: self._entry = None generation = self._generation install = self._resolve(key) layout = self._load(install) try: signatures = tuple( (path, self._signature(path)) for path in self._watched(install, layout) ) except _CannotValidate: return install, layout if layout.error == UNREADABLE or self._racy(signatures, self._wall_ns()): return install, layout stored = _Entry(key, install, layout, signatures, self._monotonic()) with self._lock: if self._generation == generation: self._entry = stored return install, layout def clear(self) -> None: """Drop the entry; a load that started before this stores nothing.""" with self._lock: self._entry = None self._generation += 1 # ── validation ────────────────────────────────────────────────── def _valid(self, entry: _Entry, key: str) -> bool: if entry.key != key: return False if self._monotonic() - entry.stored_at >= MAX_AGE_SECONDS: return False try: for path, signature in entry.signatures: if self._signature(path) != signature: return False except _CannotValidate: return False folders = entry.layout.media_roots if entry.install is not None: folders = (entry.install,) + tuple(folders) for folder in folders: try: if _canonical(folder, self._pathmod) != folder: return False except (OSError, ValueError): return False return True def _watched(self, install, layout) -> Tuple[str, ...]: """The plugin settings file, then the settings file, each once.""" if install is None: return () paths = [self._pathmod.join(install, "settings.json")] if layout.settings_path is not None: paths.append(str(layout.settings_path)) watched, seen = [], set() for path in paths: identity = self._pathmod.normcase(self._pathmod.abspath(path)) if identity not in seen: seen.add(identity) watched.append(path) return tuple(watched) def _signature(self, path: str): try: result = self._stat(path) except (FileNotFoundError, NotADirectoryError): return MISSING except (OSError, ValueError) as error: raise _CannotValidate(path) from error return (result.st_mtime_ns, result.st_size) @staticmethod def _racy(signatures, now_ns: int) -> bool: threshold = now_ns - RACY_SECONDS * 10**9 return any( signature is not MISSING and signature[0] > threshold for _path, signature in signatures )