fix(lora): a cached layout needs the same home folder; give the rebuild poll its time (#362)

Final review follow-ups: the folder guard compares against the home folder,
so a cache hit now also needs Path.home() unchanged. TESTING.md says the
configured-path rule runs on the cache's result and that a non-UTF-8
sidecar is skipped.

CI: 'Rebuild all is explicit...' (webkit) polled the job for the default
5 s while it regenerated 24 previews; the failure screenshot shows 12 of 24
done at the deadline on the busy runner (0.17 s locally). The poll now
allows 30 s for that real work.
This commit is contained in:
Vito Sansevero
2026-10-26 23:09:51 -07:00
parent f6898699ba
commit 150d1d63df
4 changed files with 31 additions and 9 deletions
+6 -3
View File
@@ -3440,7 +3440,8 @@ preview (`preview_url`, else LoraManager's `PREVIEW_EXTENSIONS` order), then eve
`<example-images root>[/<library>]/<sha256>/` 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: <source> settings,
<mode> storage, <n> 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
+11 -5
View File
@@ -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
@@ -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 }) => {
+11
View File
@@ -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 ")