* fix(threads): stop background work at exit, on cleanup and on the tests' unload (#301) - __init__.py: _stop_background_work() detaches the monitor's callbacks, stops the monitor, closes the preview worker and shuts the prompt cleanup thread down against one 10 s deadline, once. It runs from atexit (ComfyUI never runs aiohttp's cleanup hooks), from on_cleanup where a host runs it, and from the tests' package unload; it removes its own atexit callback. Registered after the history hook, so at exit it runs before the history dispose. - AutoThumbnailer.close/close_auto_thumbnails take a timeout and return whether the worker ended. - Tests: teardown result, once-only, on_cleanup, atexit order, a failing step or a second Ctrl+C never skipping the tracker, one shared deadline, three repeated loads leaving no thread, and a child process that exits normally with the image worker alive. * fix(threads): refuse new previews before the monitor stops; isolate the exit child (#301) Review findings: - The teardown makes the API refuse new previews before it detaches the monitor's callbacks: an image in hand that already read the preview queue no longer starts a preview worker during exit (Python 3.12+ refuses that thread and logs a traceback). New refuse_auto_thumbnails(). - The teardown imports its bounds inside each step and falls back to the standard logger, so a failing import never skips the other steps. - The exit child keeps its library and log in its own folder (PROMPT_MANAGER_DATA_DIR), reports watchdog's threads too, fails with the child's output on a hang, and rejects "Error while stopping". - TESTING.md: "Background threads at exit".
1151 lines
50 KiB
Python
1151 lines
50 KiB
Python
"""Automatic thumbnails (#249): previews are a cache the server keeps itself.
|
|
|
|
The approved mock (docs/concepts/workspace/README.md) has no repeated global
|
|
"generate thumbnails" prompt, so the workspace never asks:
|
|
|
|
- every listing gives an image or video a thumbnail URL, existing or not;
|
|
- the serve route makes a missing preview on first view (bounded, single
|
|
flight per file) and falls back to the original when it cannot, remembering
|
|
the failure so a broken file is not retried on every view;
|
|
- the image monitor queues each new output's preview on one background worker;
|
|
- a scan that leaves many previews missing starts the visible thumbnail job.
|
|
|
|
Ordering is driven by threading/asyncio events, never by timers.
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
import threading
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
try:
|
|
from tests.test_api_images import ThumbnailTestCase, make_png
|
|
except ImportError: # discovered with tests/ as the top-level directory
|
|
from test_api_images import ThumbnailTestCase, make_png
|
|
|
|
from PIL import Image # noqa: E402
|
|
|
|
import py.api.images as images_module # noqa: E402
|
|
import py.api.thumbnail_auto as auto # noqa: E402
|
|
from tests.background_stops import close_handler, stop_monitor # noqa: E402
|
|
from py.api.jobs import THUMBNAILS_JOB_KIND # noqa: E402
|
|
|
|
SERVE = "/prompt_manager/images/serve/"
|
|
WAIT = 10
|
|
|
|
|
|
def _entry(api, root, rel):
|
|
return images_module._output_image_entry(root / rel, root, 0)
|
|
|
|
|
|
class AutoThumbnailCase(ThumbnailTestCase):
|
|
async def asyncSetUp(self):
|
|
await super().asyncSetUp()
|
|
self.addCleanup(self.api.close_auto_thumbnails)
|
|
|
|
def _count_writes(self):
|
|
"""Count every preview write (and its thread) through _generate_one."""
|
|
calls = []
|
|
lock = threading.Lock()
|
|
original = self.api._generate_one
|
|
|
|
def counting(src, *args, **kwargs):
|
|
with lock:
|
|
calls.append(Path(src).name)
|
|
return original(src, *args, **kwargs)
|
|
|
|
self.api._generate_one = counting
|
|
return calls
|
|
|
|
def _gate_writes(self):
|
|
"""Hold every preview write until released: (entered, release, state).
|
|
|
|
``state`` counts the writers in hand (``active``), their ``peak`` and
|
|
the ``threads`` they ran on; ``state["changed"]`` is notified at each
|
|
change, so a test waits for a writer's own state (never a timer).
|
|
"""
|
|
entered = threading.Event()
|
|
release = threading.Event()
|
|
changed = threading.Condition()
|
|
state = {"active": 0, "peak": 0, "threads": set(), "changed": changed}
|
|
original = self.api._generate_one
|
|
|
|
def gated(*args, **kwargs):
|
|
with changed:
|
|
state["active"] += 1
|
|
state["peak"] = max(state["peak"], state["active"])
|
|
state["threads"].add(threading.current_thread().name)
|
|
changed.notify_all()
|
|
entered.set()
|
|
try:
|
|
release.wait(WAIT)
|
|
return original(*args, **kwargs)
|
|
finally:
|
|
with changed:
|
|
state["active"] -= 1
|
|
changed.notify_all()
|
|
|
|
self.api._generate_one = gated
|
|
self.addCleanup(release.set)
|
|
return entered, release, state
|
|
|
|
async def _writers(self, state, count):
|
|
"""Wait until exactly ``count`` writers are in hand."""
|
|
changed = state["changed"]
|
|
|
|
def wait():
|
|
with changed:
|
|
return changed.wait_for(lambda: state["active"] == count, WAIT)
|
|
|
|
loop = asyncio.get_running_loop()
|
|
self.assertTrue(await loop.run_in_executor(None, wait))
|
|
|
|
async def _waiting(self, count):
|
|
"""Wait until ``count`` view requests wait on the preview gate."""
|
|
gate = self.api._preview_gate()
|
|
await asyncio.wait_for(gate.settled(lambda: gate.waiting >= count), WAIT)
|
|
|
|
async def _wait(self, event):
|
|
loop = asyncio.get_running_loop()
|
|
self.assertTrue(await loop.run_in_executor(None, event.wait, WAIT))
|
|
|
|
async def _idle(self):
|
|
loop = asyncio.get_running_loop()
|
|
thumbnailer = self.api._auto_thumbnails()
|
|
self.assertTrue(await loop.run_in_executor(None, thumbnailer.wait_idle, WAIT))
|
|
|
|
def _hold(self, kind, title="Database restore (admin page)", admission="records"):
|
|
"""A running, non-cancellable registry hold of ``kind`` until cleanup."""
|
|
gate = asyncio.Event()
|
|
|
|
async def runner(job):
|
|
await gate.wait()
|
|
|
|
job = self.api._jobs.start(
|
|
kind, title, admission=admission, cancellable=False, runner=runner
|
|
)
|
|
|
|
async def release():
|
|
gate.set()
|
|
await asyncio.wait_for(job.task, 5)
|
|
|
|
self.addAsyncCleanup(release)
|
|
return job
|
|
|
|
|
|
class TestListingsAlwaysCarryAThumbnailUrl(AutoThumbnailCase):
|
|
def test_missing_image_preview_still_gets_a_thumbnail_url(self):
|
|
make_png(self.output_dir / "sub" / "new.png", size=(64, 64))
|
|
entry = _entry(self.api, self.output_dir, "sub/new.png")
|
|
self.assertEqual(
|
|
entry["thumbnail_url"],
|
|
f"{SERVE}thumbnails/sub/new_thumb.webp?root=0",
|
|
)
|
|
self.assertFalse((self.output_dir / "thumbnails").exists(), "no write")
|
|
|
|
def test_missing_video_preview_url_is_in_the_configured_format(self):
|
|
(self.output_dir / "clip.mp4").write_bytes(b"not really a video")
|
|
entry = _entry(self.api, self.output_dir, "clip.mp4")
|
|
self.assertEqual(
|
|
entry["thumbnail_url"], f"{SERVE}thumbnails/clip_thumb.webp?root=0"
|
|
)
|
|
|
|
def test_listing_says_whether_the_preview_file_exists(self):
|
|
# M9: the legacy gallery's dot reads thumbnail_ready.
|
|
make_png(self.output_dir / "a.png", size=(64, 64))
|
|
self.assertIs(
|
|
_entry(self.api, self.output_dir, "a.png")["thumbnail_ready"], False
|
|
)
|
|
make_png(self.output_dir / "thumbnails" / "a_thumb.webp")
|
|
self.assertIs(
|
|
_entry(self.api, self.output_dir, "a.png")["thumbnail_ready"], True
|
|
)
|
|
|
|
def test_media_inside_a_nested_thumbnails_folder_keeps_its_original(self):
|
|
# M10: such a file is never previewed; its card shows the original.
|
|
make_png(self.output_dir / "project" / "thumbnails" / "x.png", size=(64, 64))
|
|
entry = _entry(self.api, self.output_dir, "project/thumbnails/x.png")
|
|
self.assertIsNone(entry["thumbnail_url"])
|
|
self.assertIs(entry["thumbnail_ready"], False)
|
|
|
|
def test_existing_preview_keeps_its_mtime_version(self):
|
|
make_png(self.output_dir / "a.png", size=(64, 64))
|
|
make_png(self.output_dir / "thumbnails" / "a_thumb.webp")
|
|
entry = _entry(self.api, self.output_dir, "a.png")
|
|
self.assertRegex(
|
|
entry["thumbnail_url"],
|
|
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.webp\?v=\d+&root=0$",
|
|
)
|
|
|
|
async def test_workspace_listing_never_returns_a_null_thumbnail(self):
|
|
for name in ("one.png", "two.png", "three.png"):
|
|
make_png(self.output_dir / name, size=(64, 64))
|
|
resp = await self.client.get("/prompt_manager/images/output?limit=10")
|
|
data = await resp.json()
|
|
self.assertTrue(data["success"], data)
|
|
urls = [image["thumbnail_url"] for image in data["images"]]
|
|
self.assertEqual(len(urls), 3)
|
|
for url in urls:
|
|
self.assertIn("_thumb.webp", url)
|
|
|
|
|
|
class TestThumbnailOnFirstView(AutoThumbnailCase):
|
|
async def test_missing_preview_is_generated_and_served(self):
|
|
make_png(self.output_dir / "sub" / "big.png", size=(1200, 900))
|
|
url = _entry(self.api, self.output_dir, "sub/big.png")["thumbnail_url"]
|
|
|
|
resp = await self.client.get(url)
|
|
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertEqual(resp.headers["Content-Type"], "image/webp")
|
|
body = await resp.read()
|
|
thumb = self.output_dir / "thumbnails" / "sub" / "big_thumb.webp"
|
|
self.assertTrue(thumb.is_file())
|
|
self.assertEqual(body, thumb.read_bytes())
|
|
with Image.open(thumb) as img:
|
|
self.assertEqual(max(img.size), 512)
|
|
# No temp sibling is ever left next to the preview.
|
|
self.assertEqual(os.listdir(thumb.parent), ["big_thumb.webp"])
|
|
|
|
async def test_upper_case_extension_source_is_found(self):
|
|
make_png(self.output_dir / "Shot.PNG", size=(64, 64))
|
|
url = _entry(self.api, self.output_dir, "Shot.PNG")["thumbnail_url"]
|
|
self.assertIn("thumbnails/Shot_thumb.webp", url)
|
|
resp = await self.client.get(url)
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertTrue((self.output_dir / "thumbnails" / "Shot_thumb.webp").is_file())
|
|
|
|
async def test_video_preview_is_generated_in_the_configured_format(self):
|
|
# #317: a video's preview is written like an image's (WebP default);
|
|
# an undecodable clip's preview is the placeholder frame.
|
|
(self.output_dir / "clip.mp4").write_bytes(b"not really a video")
|
|
url = _entry(self.api, self.output_dir, "clip.mp4")["thumbnail_url"]
|
|
resp = await self.client.get(url)
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertEqual(resp.headers["Content-Type"], "image/webp")
|
|
self.assertTrue((self.output_dir / "thumbnails" / "clip_thumb.webp").is_file())
|
|
|
|
async def test_concurrent_views_generate_once(self):
|
|
make_png(self.output_dir / "a.png", size=(256, 256))
|
|
url = _entry(self.api, self.output_dir, "a.png")["thumbnail_url"]
|
|
calls = self._count_writes()
|
|
entered, release, _state = self._gate_writes()
|
|
|
|
first = asyncio.ensure_future(self.client.get(url))
|
|
await self._wait(entered)
|
|
others = [asyncio.ensure_future(self.client.get(url)) for _ in range(4)]
|
|
# Every follower waits on the leader's flight before it is released.
|
|
await self._waiting(5)
|
|
release.set()
|
|
responses = [await first] + [await task for task in others]
|
|
|
|
self.assertEqual([r.status for r in responses], [200] * 5)
|
|
self.assertEqual(calls, ["a.png"])
|
|
|
|
async def test_on_demand_generation_is_bounded(self):
|
|
names = [f"p{i}.png" for i in range(auto.ON_DEMAND_LIMIT + 2)]
|
|
for name in names:
|
|
make_png(self.output_dir / name, size=(64, 64))
|
|
entered, release, state = self._gate_writes()
|
|
tasks = [
|
|
asyncio.ensure_future(
|
|
self.client.get(_entry(self.api, self.output_dir, n)["thumbnail_url"])
|
|
)
|
|
for n in names
|
|
]
|
|
# M8: the third writer's own state, then the two queued requests.
|
|
await self._writers(state, auto.ON_DEMAND_LIMIT)
|
|
await self._waiting(len(names))
|
|
self.assertEqual(state["active"], auto.ON_DEMAND_LIMIT)
|
|
release.set()
|
|
responses = [await task for task in tasks]
|
|
self.assertEqual([r.status for r in responses], [200] * len(names))
|
|
self.assertLessEqual(state["peak"], auto.ON_DEMAND_LIMIT)
|
|
|
|
async def test_a_burst_never_holds_default_executor_threads(self):
|
|
# I2: waits are coroutines and writes run on the preview pool, so a
|
|
# burst of distinct missing previews leaves every other route free.
|
|
names = [f"b{i}.png" for i in range(auto.ON_DEMAND_LIMIT + 4)]
|
|
for name in names:
|
|
make_png(self.output_dir / name, size=(64, 64))
|
|
entered, release, state = self._gate_writes()
|
|
default_calls = []
|
|
original = self.api._run_in_executor
|
|
|
|
async def spy(func, *args, **kwargs):
|
|
default_calls.append(getattr(func, "__name__", repr(func)))
|
|
return await original(func, *args, **kwargs)
|
|
|
|
self.api._run_in_executor = spy
|
|
tasks = [
|
|
asyncio.ensure_future(
|
|
self.client.get(_entry(self.api, self.output_dir, n)["thumbnail_url"])
|
|
)
|
|
for n in names
|
|
]
|
|
await self._writers(state, auto.ON_DEMAND_LIMIT)
|
|
await self._waiting(len(names))
|
|
self.assertEqual(default_calls, [], "no view waits on a default thread")
|
|
self.assertTrue(all(name.startswith("pm-preview") for name in state["threads"]))
|
|
status = await self.client.get("/prompt_manager/images/thumbnails/status")
|
|
self.assertEqual(status.status, 200, "another route answers meanwhile")
|
|
await status.read()
|
|
release.set()
|
|
responses = [await task for task in tasks]
|
|
self.assertEqual([r.status for r in responses], [200] * len(names))
|
|
for name in names:
|
|
stem = name[:-4]
|
|
self.assertTrue(
|
|
(self.output_dir / "thumbnails" / f"{stem}_thumb.webp").is_file()
|
|
)
|
|
|
|
async def test_requests_beyond_the_cap_get_the_fallback_at_once(self):
|
|
names = [f"c{i}.png" for i in range(auto.ON_DEMAND_LIMIT)]
|
|
for name in names + ["late.png"]:
|
|
make_png(self.output_dir / name, size=(64, 64))
|
|
# Another stem: same-stem files share one preview name (#317 R2).
|
|
(self.output_dir / "latev.mp4").write_bytes(b"not a video")
|
|
entered, release, state = self._gate_writes()
|
|
listings = []
|
|
real_scandir = auto.os.scandir
|
|
|
|
def counting(path):
|
|
listings.append(str(path))
|
|
return real_scandir(path)
|
|
|
|
with (
|
|
patch.object(auto, "MAX_WAITING", auto.ON_DEMAND_LIMIT),
|
|
patch.object(auto.os, "scandir", counting),
|
|
):
|
|
tasks = [
|
|
asyncio.ensure_future(
|
|
self.client.get(
|
|
_entry(self.api, self.output_dir, n)["thumbnail_url"]
|
|
)
|
|
)
|
|
for n in names
|
|
]
|
|
await self._writers(state, auto.ON_DEMAND_LIMIT)
|
|
image = await self.client.get(f"{SERVE}thumbnails/late_thumb.webp?root=0")
|
|
self.assertEqual(image.status, 200)
|
|
self.assertEqual(image.headers["Cache-Control"], "no-store")
|
|
self.assertEqual(
|
|
await image.read(), (self.output_dir / "late.png").read_bytes()
|
|
)
|
|
video = await self.client.get(f"{SERVE}thumbnails/latev_thumb.webp?root=0")
|
|
self.assertEqual(video.status, 200)
|
|
self.assertEqual(video.headers["Content-Type"], "image/jpeg")
|
|
self.assertEqual(video.headers["Cache-Control"], "no-store")
|
|
await video.read()
|
|
missing = await self.client.get(f"{SERVE}thumbnails/Nope_thumb.webp?root=0")
|
|
self.assertEqual(missing.status, 404)
|
|
# M2: no folder is listed for a request the cap turned away.
|
|
self.assertEqual(listings, [])
|
|
release.set()
|
|
responses = [await task for task in tasks]
|
|
self.assertEqual([r.status for r in responses], [200] * len(names))
|
|
self.assertFalse((self.output_dir / "thumbnails" / "late_thumb.webp").exists())
|
|
|
|
async def test_mixed_case_folder_listing_runs_once_inside_admission(self):
|
|
make_png(self.output_dir / "Shot.Png", size=(64, 64))
|
|
listings = []
|
|
real_scandir = auto.os.scandir
|
|
|
|
def counting(path):
|
|
listings.append(str(path))
|
|
return real_scandir(path)
|
|
|
|
with patch.object(auto.os, "scandir", counting):
|
|
resp = await self.client.get(f"{SERVE}thumbnails/Shot_thumb.webp?root=0")
|
|
self.assertEqual(resp.status, 200)
|
|
# A case-insensitive filesystem (macOS, Windows) finds it by its exact
|
|
# name; a case-sensitive one lists the folder once.
|
|
found_by_name = (self.output_dir / "Shot.png").exists()
|
|
self.assertEqual(len(listings), 0 if found_by_name else 1)
|
|
self.assertTrue((self.output_dir / "thumbnails" / "Shot_thumb.webp").is_file())
|
|
|
|
async def test_broken_file_falls_back_to_the_original_and_is_remembered(self):
|
|
broken = self.output_dir / "broken.png"
|
|
broken.write_bytes(b"\x89PNG\r\n\x1a\n not an image")
|
|
url = _entry(self.api, self.output_dir, "broken.png")["thumbnail_url"]
|
|
calls = self._count_writes()
|
|
|
|
first = await self.client.get(url)
|
|
second = await self.client.get(url)
|
|
|
|
for resp in (first, second):
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertEqual(resp.headers["Content-Type"], "image/png")
|
|
self.assertEqual(resp.headers["Cache-Control"], "no-store")
|
|
self.assertEqual(await resp.read(), broken.read_bytes())
|
|
self.assertEqual(calls, ["broken.png"], "a known failure is not retried")
|
|
self.assertFalse(
|
|
(self.output_dir / "thumbnails" / "broken_thumb.webp").exists()
|
|
)
|
|
|
|
status = await (
|
|
await self.client.get("/prompt_manager/images/thumbnails/status")
|
|
).json()
|
|
self.assertEqual(status["failed"], 1)
|
|
|
|
# A changed file is tried again.
|
|
later = broken.stat().st_mtime + 5
|
|
make_png(broken, size=(64, 64))
|
|
os.utime(broken, (later, later))
|
|
third = await self.client.get(url)
|
|
self.assertEqual(third.status, 200)
|
|
self.assertEqual(calls, ["broken.png", "broken.png"])
|
|
self.assertTrue(
|
|
(self.output_dir / "thumbnails" / "broken_thumb.webp").is_file()
|
|
)
|
|
|
|
async def test_locked_preview_is_not_remembered_as_a_failure(self):
|
|
make_png(self.output_dir / "a.png", size=(64, 64))
|
|
url = _entry(self.api, self.output_dir, "a.png")["thumbnail_url"]
|
|
locked = PermissionError(13, "in use")
|
|
locked.winerror = 32
|
|
|
|
with patch.object(images_module.os, "replace", side_effect=locked):
|
|
resp = await self.client.get(url)
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertEqual(resp.headers["Cache-Control"], "no-store")
|
|
await resp.read()
|
|
self.assertEqual(self.api._auto_thumbnails().failure_count, 0)
|
|
|
|
resp = await self.client.get(url)
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertTrue((self.output_dir / "thumbnails" / "a_thumb.webp").is_file())
|
|
|
|
async def test_no_source_is_a_plain_uncacheable_404(self):
|
|
resp = await self.client.get(f"{SERVE}thumbnails/nope_thumb.webp?root=0")
|
|
self.assertEqual(resp.status, 404)
|
|
self.assertEqual(resp.headers["Cache-Control"], "no-store")
|
|
text = await resp.text()
|
|
self.assertNotIn(str(self.output_dir), text)
|
|
self.assertFalse((self.output_dir / "thumbnails").exists())
|
|
|
|
def test_drive_unc_and_absolute_segments_are_refused_as_text(self):
|
|
# M1: a drive or UNC segment would reset the anchor of a joined path.
|
|
for path in (
|
|
"thumbnails/D:/x_thumb.webp",
|
|
"thumbnails/D:x_thumb.webp",
|
|
"thumbnails/\\\\server\\share/x_thumb.webp",
|
|
"thumbnails//server/share/x_thumb.webp",
|
|
"/thumbnails/x_thumb.webp",
|
|
"thumbnails/a/./x_thumb.webp",
|
|
"thumbnails/C:\\Users\\x_thumb.webp",
|
|
):
|
|
with self.subTest(path=path):
|
|
self.assertFalse(auto._is_preview_request(path))
|
|
|
|
async def test_refused_segments_never_reach_the_filesystem(self):
|
|
with (
|
|
patch.object(auto, "_thumbnail_source") as source,
|
|
patch.object(auto, "_quick_source") as quick,
|
|
patch.object(auto.os, "stat") as stat,
|
|
):
|
|
for path in (
|
|
"thumbnails/D:/x_thumb.webp",
|
|
"thumbnails/\\\\server\\share/x_thumb.webp",
|
|
"/thumbnails/x_thumb.webp",
|
|
):
|
|
answer = await self.api._serve_missing_thumbnail(
|
|
[self.output_dir], path
|
|
)
|
|
self.assertIsNone(answer)
|
|
source.assert_not_called()
|
|
quick.assert_not_called()
|
|
stat.assert_not_called()
|
|
|
|
async def test_traversal_is_refused_before_any_filesystem_access(self):
|
|
with patch.object(auto, "_thumbnail_source") as source:
|
|
for path in (
|
|
"thumbnails/../../etc/passwd_thumb.webp",
|
|
"thumbnails/..%2F..%2Fx_thumb.webp",
|
|
):
|
|
resp = await self.client.get(f"{SERVE}{path}?root=0")
|
|
self.assertIn(resp.status, (403, 404))
|
|
source.assert_not_called()
|
|
|
|
async def test_symlinked_source_is_never_read(self):
|
|
outside = Path(self._output_tmp.name) / "outside.png"
|
|
make_png(outside, size=(64, 64))
|
|
self._make_symlink(outside, self.output_dir / "link.png")
|
|
resp = await self.client.get(f"{SERVE}thumbnails/link_thumb.webp?root=0")
|
|
self.assertEqual(resp.status, 404)
|
|
self.assertFalse((self.output_dir / "thumbnails").exists())
|
|
|
|
async def test_thumbnails_folder_linked_out_of_the_root_is_never_written(self):
|
|
make_png(self.output_dir / "a.png", size=(64, 64))
|
|
elsewhere = Path(self._output_tmp.name) / "elsewhere"
|
|
elsewhere.mkdir()
|
|
self._make_symlink(elsewhere, self.output_dir / "thumbnails")
|
|
resp = await self.client.get(f"{SERVE}thumbnails/a_thumb.webp?root=0")
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertEqual(resp.headers["Cache-Control"], "no-store")
|
|
self.assertEqual(os.listdir(elsewhere), [])
|
|
|
|
async def test_restore_hold_serves_the_original_and_writes_nothing(self):
|
|
from py.api.jobs import RESTORE_JOB_KIND
|
|
|
|
make_png(self.output_dir / "a.png", size=(64, 64))
|
|
url = _entry(self.api, self.output_dir, "a.png")["thumbnail_url"]
|
|
self._hold(RESTORE_JOB_KIND)
|
|
resp = await self.client.get(url)
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertEqual(resp.headers["Cache-Control"], "no-store")
|
|
self.assertEqual(await resp.read(), (self.output_dir / "a.png").read_bytes())
|
|
self.assertFalse((self.output_dir / "thumbnails").exists())
|
|
self.assertEqual(self.api._auto_thumbnails().failure_count, 0)
|
|
|
|
async def test_duplicate_removal_and_source_change_hold_writes(self):
|
|
from py.api.jobs import DUPLICATE_CLEANUP_JOB_KIND
|
|
from py.api.settings_routes import SOURCE_CHANGE_JOB_KIND
|
|
|
|
make_png(self.output_dir / "a.png", size=(64, 64))
|
|
for kind in (DUPLICATE_CLEANUP_JOB_KIND, SOURCE_CHANGE_JOB_KIND):
|
|
with self.subTest(kind=kind):
|
|
job = self._hold(kind, title=kind)
|
|
self.assertIs(self.api._auto_thumbnail_blocker(), job)
|
|
job.finish("finished")
|
|
self.assertIsNone(self.api._auto_thumbnail_blocker())
|
|
|
|
async def test_scan_and_thumbnail_jobs_do_not_hold_writes(self):
|
|
# A scan writes records only and the thumbnail job writes the same
|
|
# previews atomically, so a view during either still gets its preview.
|
|
holds = (
|
|
("scan", "Scan for metadata", "records"),
|
|
("image-tags", "Add tags", "records"),
|
|
(THUMBNAILS_JOB_KIND, "Thumbnail generation", "guarded"),
|
|
)
|
|
for index, (kind, title, admission) in enumerate(holds):
|
|
with self.subTest(kind=kind):
|
|
name = f"a{index}.png"
|
|
make_png(self.output_dir / name, size=(64, 64))
|
|
job = self._hold(kind, title=title, admission=admission)
|
|
resp = await self.client.get(
|
|
_entry(self.api, self.output_dir, name)["thumbnail_url"]
|
|
)
|
|
self.assertEqual(resp.status, 200)
|
|
thumb = self.output_dir / "thumbnails" / f"a{index}_thumb.webp"
|
|
self.assertTrue(thumb.is_file())
|
|
job.finish("finished")
|
|
|
|
async def test_legacy_clear_holds_writes(self):
|
|
make_png(self.output_dir / "a.png", size=(64, 64))
|
|
self._hold(
|
|
THUMBNAILS_JOB_KIND,
|
|
title=images_module.THUMBNAIL_CLEAR_TITLE,
|
|
admission="guarded",
|
|
)
|
|
resp = await self.client.get(
|
|
_entry(self.api, self.output_dir, "a.png")["thumbnail_url"]
|
|
)
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertFalse((self.output_dir / "thumbnails").exists())
|
|
|
|
|
|
class TestVideoPreviewsNeverFallBackToTheVideo(AutoThumbnailCase):
|
|
"""I1: a video preview that cannot be made is the placeholder frame."""
|
|
|
|
def _video(self, name="clip.webm"):
|
|
path = self.output_dir / name
|
|
path.write_bytes(b"AV1 stream no decoder here")
|
|
return path
|
|
|
|
async def _assert_placeholder(self, url):
|
|
resp = await self.client.get(url)
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertEqual(resp.headers["Content-Type"], "image/jpeg")
|
|
body = await resp.read()
|
|
self.assertTrue(body.startswith(b"\xff\xd8"))
|
|
return resp
|
|
|
|
async def test_opencv_that_cannot_open_the_file_falls_through_to_a_frame(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
self._video()
|
|
cv2 = MagicMock()
|
|
cv2.VideoCapture.return_value.isOpened.return_value = False
|
|
with patch.dict(sys.modules, {"cv2": cv2}):
|
|
resp = await self.client.get(f"{SERVE}thumbnails/clip_thumb.webp?root=0")
|
|
# The placeholder frame became the preview file, in the format (#317).
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertEqual(resp.headers["Content-Type"], "image/webp")
|
|
made = self.output_dir / "thumbnails" / "clip_thumb.webp"
|
|
self.assertEqual(await resp.read(), made.read_bytes())
|
|
|
|
async def test_a_held_video_preview_is_the_placeholder_and_writes_nothing(self):
|
|
from py.api.jobs import RESTORE_JOB_KIND
|
|
|
|
self._video()
|
|
self._hold(RESTORE_JOB_KIND)
|
|
resp = await self._assert_placeholder(
|
|
f"{SERVE}thumbnails/clip_thumb.webp?root=0"
|
|
)
|
|
self.assertEqual(resp.headers["Cache-Control"], "no-store")
|
|
self.assertFalse((self.output_dir / "thumbnails").exists())
|
|
|
|
async def test_a_linked_out_folder_gives_a_video_the_placeholder(self):
|
|
self._video()
|
|
elsewhere = Path(self._output_tmp.name) / "elsewhere"
|
|
elsewhere.mkdir()
|
|
self._make_symlink(elsewhere, self.output_dir / "thumbnails")
|
|
resp = await self._assert_placeholder(
|
|
f"{SERVE}thumbnails/clip_thumb.webp?root=0"
|
|
)
|
|
self.assertEqual(resp.headers["Cache-Control"], "no-store")
|
|
self.assertEqual(os.listdir(elsewhere), [])
|
|
|
|
async def test_a_failed_video_preview_is_the_placeholder(self):
|
|
self._video()
|
|
with patch.object(self.api, "_generate_video_thumbnail", return_value=False):
|
|
resp = await self._assert_placeholder(
|
|
f"{SERVE}thumbnails/clip_thumb.webp?root=0"
|
|
)
|
|
self.assertEqual(resp.headers["Cache-Control"], "no-store")
|
|
|
|
|
|
class TestWriteSafety(AutoThumbnailCase):
|
|
async def test_a_hold_that_starts_while_waiting_for_a_slot_stops_the_write(self):
|
|
# M3: the holds are checked again once the slot is taken.
|
|
from py.api.jobs import RESTORE_JOB_KIND
|
|
|
|
names = [f"h{i}.png" for i in range(auto.ON_DEMAND_LIMIT + 1)]
|
|
for name in names:
|
|
make_png(self.output_dir / name, size=(64, 64))
|
|
calls = self._count_writes()
|
|
entered, release, state = self._gate_writes()
|
|
tasks = [
|
|
asyncio.ensure_future(
|
|
self.client.get(_entry(self.api, self.output_dir, n)["thumbnail_url"])
|
|
)
|
|
for n in names
|
|
]
|
|
await self._writers(state, auto.ON_DEMAND_LIMIT)
|
|
await self._waiting(len(names))
|
|
self._hold(RESTORE_JOB_KIND)
|
|
release.set()
|
|
responses = [await task for task in tasks]
|
|
self.assertEqual([r.status for r in responses], [200] * len(names))
|
|
self.assertEqual(len(calls), auto.ON_DEMAND_LIMIT, "the late one never wrote")
|
|
# ... and once more before the rename: the gated writers wrote nothing.
|
|
thumbs = self.output_dir / "thumbnails"
|
|
self.assertEqual(sorted(os.listdir(thumbs)) if thumbs.exists() else [], [])
|
|
|
|
async def test_disk_errors_are_not_remembered_but_decode_errors_are(self):
|
|
# M4: only a file that cannot be decoded is remembered.
|
|
import errno
|
|
|
|
make_png(self.output_dir / "full.png", size=(64, 64))
|
|
url = _entry(self.api, self.output_dir, "full.png")["thumbnail_url"]
|
|
with patch.object(
|
|
images_module,
|
|
"_write_image_thumbnail",
|
|
side_effect=OSError(errno.ENOSPC, "No space left on device"),
|
|
):
|
|
resp = await self.client.get(url)
|
|
self.assertEqual(resp.status, 200)
|
|
await resp.read()
|
|
self.assertEqual(self.api._auto_thumbnails().failure_count, 0)
|
|
truncated = self.output_dir / "cut.png"
|
|
data = make_png(self.output_dir / "whole.png", size=(256, 256)).read_bytes()
|
|
truncated.write_bytes(data[: len(data) // 2])
|
|
resp = await self.client.get(
|
|
_entry(self.api, self.output_dir, "cut.png")["thumbnail_url"]
|
|
)
|
|
self.assertEqual(await resp.read(), truncated.read_bytes())
|
|
self.assertEqual(self.api._auto_thumbnails().failure_count, 1)
|
|
|
|
|
|
class TestShutdown(AutoThumbnailCase):
|
|
async def test_no_thread_remains_after_cleanup_and_capture_cannot_restart(self):
|
|
from py.api import thumbnail_encode
|
|
|
|
self.addCleanup(thumbnail_encode.reset_encoders)
|
|
path = make_png(self.output_dir / "a.png", size=(64, 64))
|
|
self.assertEqual(self.api.queue_capture_thumbnail(str(path)), "queued")
|
|
thumbnailer = self.api._auto_thumbnails()
|
|
worker = thumbnailer._worker
|
|
await self._idle()
|
|
make_png(self.output_dir / "viewed.png", size=(64, 64))
|
|
resp = await self.client.get(
|
|
_entry(self.api, self.output_dir, "viewed.png")["thumbnail_url"]
|
|
)
|
|
await resp.read()
|
|
pool_threads = list(self.api._preview_pool_threads())
|
|
self.assertTrue(pool_threads)
|
|
loop = asyncio.get_running_loop()
|
|
await loop.run_in_executor(
|
|
None, lambda: self.api.close_auto_thumbnails(final=True)
|
|
)
|
|
for thread in [worker, *pool_threads]:
|
|
await loop.run_in_executor(None, thread.join, WAIT)
|
|
self.assertFalse(thread.is_alive(), thread.name)
|
|
other = make_png(self.output_dir / "b.png", size=(64, 64))
|
|
self.assertEqual(self.api.queue_capture_thumbnail(str(other)), "skipped")
|
|
restarted = self.api._auto_thumbnailer
|
|
self.assertTrue(restarted is None or restarted._worker is None)
|
|
# #317: no encoder process (ffmpeg, cjxl) starts after the cleanup.
|
|
with self.assertRaises(thumbnail_encode.EncoderClosed):
|
|
thumbnail_encode.run_encoder([sys.executable, "-c", "pass"])
|
|
|
|
async def test_an_idle_worker_leaves(self):
|
|
path = make_png(self.output_dir / "a.png", size=(64, 64))
|
|
self.api.queue_capture_thumbnail(str(path))
|
|
worker = self.api._auto_thumbnails()._worker
|
|
await self._idle()
|
|
loop = asyncio.get_running_loop()
|
|
await loop.run_in_executor(None, worker.join, WAIT)
|
|
self.assertFalse(worker.is_alive())
|
|
# The next output starts a fresh worker.
|
|
other = make_png(self.output_dir / "b.png", size=(64, 64))
|
|
self.assertEqual(self.api.queue_capture_thumbnail(str(other)), "queued")
|
|
await self._idle()
|
|
self.assertTrue((self.output_dir / "thumbnails" / "b_thumb.webp").is_file())
|
|
|
|
async def test_a_reload_stops_the_old_worker(self):
|
|
entered, release, _state = self._gate_writes()
|
|
path = make_png(self.output_dir / "a.png", size=(64, 64))
|
|
self.api.queue_capture_thumbnail(str(path))
|
|
await self._wait(entered)
|
|
old = self.api._auto_thumbnails()._worker
|
|
old.pm_preview_token = object() # as the module before a reload left it
|
|
loop = asyncio.get_running_loop()
|
|
stopping = loop.run_in_executor(None, auto._stop_stale_workers)
|
|
release.set()
|
|
await stopping
|
|
await loop.run_in_executor(None, old.join, WAIT)
|
|
self.assertFalse(old.is_alive())
|
|
self.assertTrue(self.api._auto_thumbnails().closed)
|
|
|
|
async def test_close_kills_ffmpeg_and_logs_a_join_that_timed_out(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
entered, release, _state = self._gate_writes()
|
|
path = make_png(self.output_dir / "a.png", size=(64, 64))
|
|
self.api.queue_capture_thumbnail(str(path))
|
|
await self._wait(entered)
|
|
thumbnailer = self.api._auto_thumbnails()
|
|
process = MagicMock()
|
|
thumbnailer._track(process)
|
|
with (
|
|
patch.object(auto, "CLOSE_WAIT_SECONDS", 0),
|
|
self.assertLogs("prompt_manager.api", "WARNING") as logs,
|
|
):
|
|
self.assertFalse(self.api.close_auto_thumbnails())
|
|
process.kill.assert_called_once()
|
|
self.assertIn("still running", "\n".join(logs.output))
|
|
release.set()
|
|
|
|
async def test_close_reports_an_ended_worker(self):
|
|
path = make_png(self.output_dir / "a.png", size=(64, 64))
|
|
self.api.queue_capture_thumbnail(str(path))
|
|
worker = self.api._auto_thumbnails()._worker
|
|
await self._idle()
|
|
loop = asyncio.get_running_loop()
|
|
closed = await loop.run_in_executor(
|
|
None, lambda: self.api.close_auto_thumbnails(timeout=WAIT)
|
|
)
|
|
self.assertTrue(closed)
|
|
self.assertFalse(worker.is_alive())
|
|
|
|
def test_close_without_a_thumbnailer_returns_true(self):
|
|
self.assertIsNone(self.api._auto_thumbnailer)
|
|
self.assertTrue(self.api.close_auto_thumbnails())
|
|
|
|
|
|
class TestHistorySharesTheAdmission(AutoThumbnailCase):
|
|
async def _shared_flight(self, name, is_video, first):
|
|
"""Start the history route's or the serve route's flight (``first``),
|
|
let the other one join it, release, and return both answers."""
|
|
src = self.output_dir / name
|
|
if is_video:
|
|
src.write_bytes(b"AV1 stream no decoder here")
|
|
else:
|
|
make_png(src, size=(256, 256))
|
|
url = _entry(self.api, self.output_dir, name)["thumbnail_url"]
|
|
dst = self.output_dir / "thumbnails" / Path(url.split("?")[0]).name
|
|
calls = self._count_writes()
|
|
entered, release, _state = self._gate_writes()
|
|
|
|
def history():
|
|
return asyncio.ensure_future(self.api._admit_preview(src, dst, is_video))
|
|
|
|
def view():
|
|
return asyncio.ensure_future(self.client.get(url))
|
|
|
|
leader = history() if first == "history" else view()
|
|
await self._wait(entered)
|
|
follower = view() if first == "history" else history()
|
|
await self._waiting(2)
|
|
release.set()
|
|
answers = {first: await leader}
|
|
answers["view" if first == "history" else "history"] = await follower
|
|
self.assertEqual(len(calls), 1, "one flight, one write")
|
|
return answers, dst
|
|
|
|
async def test_a_view_joining_a_history_flight_gets_the_thumbnail(self):
|
|
answers, dst = await self._shared_flight("a.png", False, "history")
|
|
self.assertEqual(answers["history"], auto.READY)
|
|
resp = answers["view"]
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertEqual(resp.headers["Content-Type"], "image/webp")
|
|
self.assertEqual(await resp.read(), dst.read_bytes())
|
|
|
|
async def test_history_joining_a_view_flight_gets_the_outcome(self):
|
|
answers, dst = await self._shared_flight("b.png", False, "view")
|
|
self.assertEqual(answers["history"], auto.READY)
|
|
self.assertEqual(await answers["view"].read(), dst.read_bytes())
|
|
|
|
async def test_a_held_shared_flight_answers_each_route_in_its_own_type(self):
|
|
from py.api.jobs import RESTORE_JOB_KIND
|
|
|
|
make_png(self.output_dir / "c.png", size=(64, 64))
|
|
# Another stem: same-stem files share one preview name (#317 R2).
|
|
(self.output_dir / "cv.mp4").write_bytes(b"AV1 stream no decoder here")
|
|
self._hold(RESTORE_JOB_KIND)
|
|
for name, is_video in (("c.png", False), ("cv.mp4", True)):
|
|
with self.subTest(name=name):
|
|
src = self.output_dir / name
|
|
url = _entry(self.api, self.output_dir, name)["thumbnail_url"]
|
|
dst = self.output_dir / "thumbnails" / Path(url.split("?")[0]).name
|
|
history = asyncio.ensure_future(
|
|
self.api._admit_preview(src, dst, is_video)
|
|
)
|
|
resp = await self.client.get(url)
|
|
self.assertEqual(await history, auto.DEFERRED)
|
|
self.assertEqual(resp.status, 200)
|
|
self.assertEqual(resp.headers["Cache-Control"], "no-store")
|
|
body = await resp.read()
|
|
if is_video:
|
|
self.assertEqual(resp.headers["Content-Type"], "image/jpeg")
|
|
self.assertTrue(body.startswith(b"\xff\xd8"))
|
|
else:
|
|
self.assertEqual(resp.headers["Content-Type"], "image/png")
|
|
self.assertEqual(body, src.read_bytes())
|
|
|
|
def test_history_and_serve_route_share_one_flight_key(self):
|
|
root = self.output_dir
|
|
preview = root / "thumbnails" / "sub" / "a_thumb.webp"
|
|
self.assertEqual(
|
|
auto.flight_key(root, ("thumbnails", "sub", "a_thumb.webp")),
|
|
auto.flight_key(
|
|
Path(os.path.normcase(os.path.realpath(root))),
|
|
preview.relative_to(root).parts,
|
|
),
|
|
)
|
|
|
|
|
|
class TestThumbnailOnCapture(AutoThumbnailCase):
|
|
async def test_monitor_output_is_queued_and_generated_in_the_background(self):
|
|
path = make_png(self.output_dir / "run" / "ComfyUI_0001.png", size=(640, 480))
|
|
outcome = self.api.queue_capture_thumbnail(str(path))
|
|
self.assertEqual(outcome, "queued")
|
|
await self._idle()
|
|
thumb = self.output_dir / "thumbnails" / "run" / "ComfyUI_0001_thumb.webp"
|
|
self.assertTrue(thumb.is_file())
|
|
self.assertIn(
|
|
"v=",
|
|
_entry(self.api, self.output_dir, "run/ComfyUI_0001.png")["thumbnail_url"],
|
|
)
|
|
|
|
async def test_duplicates_coalesce_and_fresh_previews_are_skipped(self):
|
|
path = make_png(self.output_dir / "a.png", size=(64, 64))
|
|
calls = self._count_writes()
|
|
entered, release, _state = self._gate_writes()
|
|
self.assertEqual(self.api.queue_capture_thumbnail(str(path)), "queued")
|
|
await self._wait(entered)
|
|
# While it is being written a second event for it is not queued again.
|
|
other = make_png(self.output_dir / "b.png", size=(64, 64))
|
|
self.assertEqual(self.api.queue_capture_thumbnail(str(other)), "queued")
|
|
self.assertEqual(self.api.queue_capture_thumbnail(str(other)), "skipped")
|
|
release.set()
|
|
await self._idle()
|
|
self.assertEqual(sorted(calls), ["a.png", "b.png"])
|
|
self.assertEqual(self.api.queue_capture_thumbnail(str(path)), "fresh")
|
|
|
|
async def test_files_outside_the_roots_and_previews_are_ignored(self):
|
|
outside = make_png(Path(self._output_tmp.name) / "x.png")
|
|
preview = make_png(self.output_dir / "thumbnails" / "y_thumb.webp")
|
|
text = self.output_dir / "notes.txt"
|
|
text.write_text("x")
|
|
for path in (outside, preview, text, self.output_dir / "missing.png"):
|
|
self.assertEqual(self.api.queue_capture_thumbnail(str(path)), "skipped")
|
|
|
|
async def test_worker_uses_the_worker_threads_setting(self):
|
|
from py.config import PromptManagerConfig
|
|
|
|
original = PromptManagerConfig.WORKER_THREADS
|
|
PromptManagerConfig.WORKER_THREADS = 2
|
|
self.addCleanup(setattr, PromptManagerConfig, "WORKER_THREADS", original)
|
|
batches = []
|
|
real_map = auto.map_parallel
|
|
|
|
def recording(func, items, workers):
|
|
batches.append((len(items), workers))
|
|
return real_map(func, items, workers)
|
|
|
|
patcher = patch.object(auto, "map_parallel", recording)
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
entered, release, state = self._gate_writes()
|
|
paths = [
|
|
make_png(self.output_dir / f"w{i}.png", size=(32, 32)) for i in range(5)
|
|
]
|
|
self.api.queue_capture_thumbnail(str(paths[0]))
|
|
await self._wait(entered) # the worker holds a batch of one
|
|
for path in paths[1:]:
|
|
self.api.queue_capture_thumbnail(str(path))
|
|
release.set()
|
|
await self._idle()
|
|
self.assertEqual(batches, [(1, 1), (2, 2), (2, 2)])
|
|
self.assertLessEqual(state["peak"], 2)
|
|
for i in range(5):
|
|
self.assertTrue(
|
|
(self.output_dir / "thumbnails" / f"w{i}_thumb.webp").is_file()
|
|
)
|
|
|
|
async def test_worker_survives_a_failing_write(self):
|
|
bad = self.output_dir / "bad.png"
|
|
bad.write_bytes(b"broken")
|
|
good = make_png(self.output_dir / "good.png", size=(64, 64))
|
|
self.api.queue_capture_thumbnail(str(bad))
|
|
await self._idle()
|
|
self.api.queue_capture_thumbnail(str(good))
|
|
await self._idle()
|
|
self.assertTrue((self.output_dir / "thumbnails" / "good_thumb.webp").is_file())
|
|
self.assertEqual(self.api._auto_thumbnails().failure_count, 1)
|
|
|
|
async def test_worker_writes_nothing_during_a_restore(self):
|
|
from py.api.jobs import RESTORE_JOB_KIND
|
|
|
|
path = make_png(self.output_dir / "a.png", size=(64, 64))
|
|
self._hold(RESTORE_JOB_KIND)
|
|
self.api.queue_capture_thumbnail(str(path))
|
|
await self._idle()
|
|
self.assertFalse((self.output_dir / "thumbnails").exists())
|
|
|
|
async def test_a_worker_that_raises_never_blocks_the_next_one(self):
|
|
# A raising config read stops the worker; the next enqueue starts one.
|
|
thumbnailer = self.api._auto_thumbnails()
|
|
real_workers = thumbnailer._workers
|
|
failures = iter([RuntimeError("config unreadable")])
|
|
|
|
def workers():
|
|
failure = next(failures, None)
|
|
if failure is not None:
|
|
raise failure
|
|
return real_workers()
|
|
|
|
thumbnailer._workers = workers
|
|
first = make_png(self.output_dir / "first.png", size=(32, 32))
|
|
with self.assertLogs("prompt_manager.api", "WARNING"):
|
|
self.assertEqual(self.api.queue_capture_thumbnail(str(first)), "queued")
|
|
await self._idle()
|
|
self.assertFalse(thumbnailer._worker_running)
|
|
second = make_png(self.output_dir / "second.png", size=(32, 32))
|
|
self.assertEqual(self.api.queue_capture_thumbnail(str(second)), "queued")
|
|
await self._idle()
|
|
for name in ("first", "second"):
|
|
self.assertTrue(
|
|
(self.output_dir / "thumbnails" / f"{name}_thumb.webp").is_file()
|
|
)
|
|
|
|
async def test_a_worker_that_cannot_start_is_started_by_the_next_enqueue(self):
|
|
thumbnailer = self.api._auto_thumbnails()
|
|
first = make_png(self.output_dir / "first.png", size=(32, 32))
|
|
with (
|
|
patch.object(
|
|
threading.Thread,
|
|
"start",
|
|
side_effect=RuntimeError("can't start new thread"),
|
|
),
|
|
self.assertLogs("prompt_manager.api", "WARNING"),
|
|
):
|
|
self.assertEqual(self.api.queue_capture_thumbnail(str(first)), "queued")
|
|
self.assertFalse(thumbnailer._worker_running)
|
|
second = make_png(self.output_dir / "second.png", size=(32, 32))
|
|
self.assertEqual(self.api.queue_capture_thumbnail(str(second)), "queued")
|
|
await self._idle()
|
|
for name in ("first", "second"):
|
|
self.assertTrue(
|
|
(self.output_dir / "thumbnails" / f"{name}_thumb.webp").is_file()
|
|
)
|
|
|
|
def test_queue_is_bounded(self):
|
|
thumbnailer = auto.AutoThumbnailer(
|
|
lambda *a: {"action": "generated"},
|
|
lambda: True,
|
|
self.api.logger,
|
|
max_queued=2,
|
|
start_worker=False,
|
|
)
|
|
paths = [Path(f"/x/{i}.png") for i in range(3)]
|
|
added = [thumbnailer.enqueue(p, p.with_suffix(".t.png"), False) for p in paths]
|
|
self.assertEqual(added, [True, True, False])
|
|
thumbnailer.close()
|
|
|
|
|
|
class TestMonitorQueuesThumbnails(AutoThumbnailCase):
|
|
def _handler(self):
|
|
from utils.image_monitor import ImageGenerationHandler
|
|
|
|
handler = ImageGenerationHandler(self.api.db, unittest.mock.MagicMock())
|
|
self.addCleanup(close_handler, self, handler)
|
|
handler.processing_delay = 0
|
|
handler.settle_interval = 0
|
|
return handler
|
|
|
|
def test_settled_output_is_handed_to_the_thumbnail_queue(self):
|
|
path = make_png(self.output_dir / "n.png", size=(32, 32))
|
|
handler = self._handler()
|
|
calls = []
|
|
handler.thumbnail_queue = calls.append
|
|
with patch.object(handler, "process_new_image"):
|
|
handler._pending[str(path)] = (None, 0.0)
|
|
handler._process_pending(str(path))
|
|
self.assertEqual(calls, [str(path)])
|
|
|
|
def test_a_failing_queue_never_breaks_the_monitor(self):
|
|
path = make_png(self.output_dir / "n.png", size=(32, 32))
|
|
handler = self._handler()
|
|
|
|
def broken(_path):
|
|
raise RuntimeError("boom")
|
|
|
|
handler.thumbnail_queue = broken
|
|
with patch.object(handler, "process_new_image"):
|
|
handler._pending[str(path)] = (None, 0.0)
|
|
with self.assertLogs("prompt_manager.image_monitor", "WARNING") as logs:
|
|
handler._process_pending(str(path)) # no exception
|
|
self.assertIn("could not be queued", "\n".join(logs.output))
|
|
self.assertEqual(handler.pending_count(), 0)
|
|
|
|
def test_monitor_wiring_reaches_new_and_existing_handlers(self):
|
|
from utils.image_monitor import ImageMonitor
|
|
from utils.prompt_tracker import PromptTracker
|
|
|
|
monitor = ImageMonitor(self.api.db, PromptTracker(self.api.db))
|
|
queue_fn = self.api.queue_capture_thumbnail
|
|
monitor.set_thumbnail_queue(queue_fn)
|
|
monitor.start_monitoring([str(self.output_dir)])
|
|
try:
|
|
self.assertEqual(monitor.handler.thumbnail_queue, queue_fn)
|
|
monitor.set_thumbnail_queue(None)
|
|
self.assertIsNone(monitor.handler.thumbnail_queue)
|
|
finally:
|
|
stop_monitor(self, monitor)
|
|
|
|
|
|
class TestScanHandsOffThumbnails(AutoThumbnailCase):
|
|
def _scan_job(self, added):
|
|
from py.api.jobs import Job, SCAN_GROUP_NAMES
|
|
|
|
job = Job(
|
|
"scan", "Scan for metadata", admission="records", groups=SCAN_GROUP_NAMES
|
|
)
|
|
job.counters["added"] = added
|
|
job.finish("finished")
|
|
return job
|
|
|
|
async def test_many_missing_previews_start_the_visible_thumbnail_job(self):
|
|
for i in range(3):
|
|
make_png(self.output_dir / f"s{i}.png", size=(32, 32))
|
|
with patch.object(auto, "SCAN_THUMBNAIL_JOB_THRESHOLD", 3):
|
|
outcome = await self.api._thumbnails_after_scan(self._scan_job(3))
|
|
self.assertEqual(outcome, "job")
|
|
job = self.api._jobs.active(THUMBNAILS_JOB_KIND)
|
|
self.assertIsNotNone(job)
|
|
self.assertTrue(job.cancellable)
|
|
self.assertEqual(job.scope["mode"], "missing")
|
|
await asyncio.wait_for(job.task, WAIT)
|
|
self.assertEqual(job.counters["generated"], 3)
|
|
|
|
async def test_few_missing_previews_go_through_the_worker(self):
|
|
for i in range(2):
|
|
make_png(self.output_dir / f"s{i}.png", size=(32, 32))
|
|
with patch.object(auto, "SCAN_THUMBNAIL_JOB_THRESHOLD", 3):
|
|
outcome = await self.api._thumbnails_after_scan(self._scan_job(2))
|
|
self.assertEqual(outcome, "queued")
|
|
self.assertIsNone(self.api._jobs.active(THUMBNAILS_JOB_KIND))
|
|
await self._idle()
|
|
for i in range(2):
|
|
self.assertTrue(
|
|
(self.output_dir / "thumbnails" / f"s{i}_thumb.webp").is_file()
|
|
)
|
|
|
|
async def test_a_scan_that_added_nothing_does_nothing(self):
|
|
make_png(self.output_dir / "s.png", size=(32, 32))
|
|
self.assertIsNone(await self.api._thumbnails_after_scan(self._scan_job(0)))
|
|
self.assertFalse((self.output_dir / "thumbnails").exists())
|
|
|
|
async def _run_scan(self, final):
|
|
calls = []
|
|
|
|
async def events(job, output_dirs=None):
|
|
job.counters["added"] = 1
|
|
yield {"type": final}
|
|
|
|
async def hand_off(job, output_dirs=None):
|
|
calls.append((job.state, output_dirs))
|
|
|
|
self.api._scan_images_events = events
|
|
self.api._hand_off_scan_thumbnails = hand_off
|
|
job = self.api._start_scan_job("new")
|
|
await asyncio.wait_for(job.task, WAIT)
|
|
return calls
|
|
|
|
async def test_a_finished_scan_hands_its_previews_on(self):
|
|
self.assertEqual(await self._run_scan("complete"), [("finished", None)])
|
|
|
|
async def test_a_cancelled_or_failed_scan_does_not(self):
|
|
self.assertEqual(await self._run_scan("cancelled"), [])
|
|
self.assertEqual(await self._run_scan("error"), [])
|
|
|
|
async def test_hand_off_failures_are_logged_not_raised(self):
|
|
async def broken(job, output_dirs=None):
|
|
raise OSError("walk failed")
|
|
|
|
self.api._thumbnails_after_scan = broken
|
|
with self.assertLogs("prompt_manager.api", "WARNING"):
|
|
self.assertIsNone(
|
|
await self.api._hand_off_scan_thumbnails(self._scan_job(3))
|
|
)
|
|
|
|
async def test_a_scoped_scan_counts_only_its_sources(self):
|
|
other = self.output_dir.parent / "other"
|
|
make_png(other / "o.png", size=(32, 32))
|
|
make_png(self.output_dir / "s.png", size=(32, 32))
|
|
job = self._scan_job(1)
|
|
job.scope["roots"] = [1]
|
|
outcome = await self.api._thumbnails_after_scan(job, [self.output_dir, other])
|
|
self.assertEqual(outcome, "queued")
|
|
await self._idle()
|
|
self.assertTrue((other / "thumbnails" / "o_thumb.webp").is_file())
|
|
self.assertFalse((self.output_dir / "thumbnails").exists())
|
|
|
|
async def test_nothing_missing_is_none(self):
|
|
make_png(self.output_dir / "s.png", size=(32, 32))
|
|
make_png(self.output_dir / "thumbnails" / "s_thumb.webp")
|
|
self.assertEqual(
|
|
await self.api._thumbnails_after_scan(self._scan_job(1)), "none"
|
|
)
|
|
|
|
async def test_a_busy_registry_is_reported_not_raised(self):
|
|
for i in range(3):
|
|
make_png(self.output_dir / f"s{i}.png", size=(32, 32))
|
|
self._hold("restore")
|
|
with patch.object(auto, "SCAN_THUMBNAIL_JOB_THRESHOLD", 3):
|
|
outcome = await self.api._thumbnails_after_scan(self._scan_job(3))
|
|
self.assertEqual(outcome, "busy")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|