diff --git a/TESTING.md b/TESTING.md index dba58614..493030e1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -3440,7 +3440,8 @@ preview (`preview_url`, else LoraManager's `PREVIEW_EXTENSIONS` order), then eve `[/]//` in natural order; a leading video yields to the first still. A file counts only with a servable extension and inside the guarded media roots. Only an absolute `preview_url` is used; a relative one is skipped with a DEBUG line -naming the sidecar. CivitAI downloads run only for a LoRA without local media; a record's +naming the sidecar. A sidecar that is not valid UTF-8 JSON is skipped like any unreadable +one (#362). CivitAI downloads run only for a LoRA without local media; a record's images are linked in reverse order, so its first image (the cover) is the newest link and the card image. The folder guard keeps a folder from the settings, and each of ComfyUI's default LoRA folders (`models/loras`, `extra_model_paths.yaml`, `folder_paths`), only when @@ -3480,8 +3481,10 @@ during the load, LoraManager's in-place write, FAT/HFS+ coarse times), every wat could be checked (any `stat` error but not-found, and an offline Windows share, is "cannot validate"), the settings were readable, and no `clear()` ran meanwhile (generation counter). Hits take no lock; concurrent misses each load. A finished import (System job or -legacy scan, both through `replace_lora_imports`) clears it; the integration-off check and -the configured-path rule run before it. `serve_image` builds the allow-list in the +legacy scan, both through `replace_lora_imports`) clears it. The integration-off check runs +before it, and the configured-path rule (a configured path that is not an install under +`custom_nodes` adds no folders) on its result; a hit also needs the same home folder, which +the guard compares against. `serve_image` builds the allow-list in the executor. The cache writes one INFO line, "LoraManager layout changed: settings, storage, media folders", when a stored layout differs from the last one logged. The review, Start, the legacy scan and the trigger cache read the layout fresh. Image route diff --git a/py/lora_layout_cache.py b/py/lora_layout_cache.py index 41143715..ce35dbba 100644 --- a/py/lora_layout_cache.py +++ b/py/lora_layout_cache.py @@ -6,7 +6,8 @@ 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 key (the configured install path, stripped) and the home folder the + folder guard compares against are 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; @@ -34,6 +35,7 @@ import os import threading import time from dataclasses import dataclass +from pathlib import Path from typing import Callable, Optional, Tuple from .lora_manager_layout import UNREADABLE, LoraManagerLayout, _canonical @@ -56,6 +58,7 @@ logger = logging.getLogger("prompt_manager.lora_layout_cache") @dataclass(frozen=True) class _Entry: key: str + home: str install: Optional[str] layout: LoraManagerLayout signatures: Tuple[Tuple[str, object], ...] @@ -78,6 +81,7 @@ class LayoutCache: monotonic: Callable[[], float] = time.monotonic, stat=os.stat, pathmod=os.path, + home: Callable[[], str] = lambda: str(Path.home()), ): self._resolve = resolve self._load = load @@ -85,6 +89,7 @@ class LayoutCache: self._monotonic = monotonic self._stat = stat self._pathmod = pathmod + self._home = home self._entry: Optional[_Entry] = None self._generation = 0 self._lock = threading.Lock() @@ -96,8 +101,9 @@ class LayoutCache: def get(self, configured: str) -> Tuple[Optional[str], LoraManagerLayout]: """``(install, layout)`` for the configured path ("" auto-detects).""" key = (configured or "").strip() + home = self._home() entry = self._entry - if entry is not None and self._valid(entry, key): + if entry is not None and self._valid(entry, key, home): return entry.install, entry.layout with self._lock: if entry is not None and self._entry is entry: @@ -114,7 +120,7 @@ class LayoutCache: 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()) + stored = _Entry(key, home, install, layout, signatures, self._monotonic()) changed = False with self._lock: if self._generation == generation: @@ -152,8 +158,8 @@ class LayoutCache: # ── validation ────────────────────────────────────────────────── - def _valid(self, entry: _Entry, key: str) -> bool: - if entry.key != key: + def _valid(self, entry: _Entry, key: str, home: str) -> bool: + if entry.key != key or entry.home != home: return False if self._monotonic() - entry.stored_at >= MAX_AGE_SECONDS: return False diff --git a/tests/browser/workspace-system-sources.spec.mjs b/tests/browser/workspace-system-sources.spec.mjs index 80cbb563..556ff4e8 100644 --- a/tests/browser/workspace-system-sources.spec.mjs +++ b/tests/browser/workspace-system-sources.spec.mjs @@ -429,7 +429,9 @@ test('Rebuild all is explicit and the plan line says every preview is regenerate await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/); expect(seen.starts).toEqual([{ mode: 'rebuild' }]); const id = page.url().split('/').pop(); - await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).toBe('finished'); + // Rebuild regenerates all 24 previews with real image work: on a busy CI + // runner that took over 5 s (12 of 24 done at the default poll budget). + await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state, { timeout: 30000 }).toBe('finished'); }); test('a plan that cannot be read says so; Start still starts', async ({ page }) => { diff --git a/tests/test_lora_layout_cache.py b/tests/test_lora_layout_cache.py index e1287ba4..bbe2305b 100644 --- a/tests/test_lora_layout_cache.py +++ b/tests/test_lora_layout_cache.py @@ -164,6 +164,17 @@ class ValidationTests(CacheTestCase): self.assertEqual(self.resolved, ["", "other"]) self.assertEqual(len(self.loaded), 2) + def test_a_different_home_folder_reloads(self): + # The folder guard compares against the home folder: a cached layout + # must not outlive a change of it. + home = {"value": str(self.tmp / "home-a")} + cache = self.cache(home=lambda: home["value"]) + cache.get("") + cache.get("") + home["value"] = str(self.tmp / "home-b") + cache.get("") + self.assertEqual(len(self.loaded), 2) + def test_configured_path_is_stripped_for_the_key_and_the_resolver(self): cache = self.cache() cache.get(" x ")