fix(thumbnails): let an idle preview worker leave and fit case-insensitive filesystems

The capture worker exits once its queue is empty, so no thread outlives
its work; the next output starts a fresh one. The mixed-case source test
expects no folder listing where the exact name already resolves (macOS,
Windows).
This commit is contained in:
Vito Sansevero
2026-10-24 07:53:13 -07:00
parent 44c2fc4133
commit e43157e684
3 changed files with 44 additions and 25 deletions
+2 -1
View File
@@ -2275,7 +2275,8 @@ workspace never asks; previews are a cache the server keeps:
`exclusive` job runs (`_auto_thumbnail_blocker`), checked when a write starts (after
its slot) and again just before its rename.
- Shutdown is bounded and logged: the worker stops at a file boundary, ffmpeg is
killed, the view pool shuts down, and after the app's cleanup a late monitor event
killed, an idle worker leaves (the next output starts a fresh one), the view pool
shuts down, and after the app's cleanup a late monitor event
starts nothing. A package reload stops the workers the previous import left.
- Cards and strips load previews only: Images in every layout and size, Library cards,
Needs review cards and the inspector's cover (a video's cover is its preview frame).
+8 -8
View File
@@ -290,6 +290,9 @@ class AutoThumbnailer:
self._queued = set()
self._processes = []
self._worker = None
# True from a worker's start until it leaves (under the lock), so an
# idle worker can exit and the next enqueue starts a fresh one.
self._worker_running = False
self._closed = False
self._busy = False
self._batch = 0
@@ -396,9 +399,8 @@ class AutoThumbnailer:
self._queued.add(key)
self._queue.append((key, Path(src), Path(dst), bool(is_video)))
self._busy = True
if self._start_worker and (
self._worker is None or not self._worker.is_alive()
):
if self._start_worker and not self._worker_running:
self._worker_running = True
worker = threading.Thread(
target=self._run, name=WORKER_NAME, daemon=True
)
@@ -412,12 +414,10 @@ class AutoThumbnailer:
def _run(self):
while True:
with self._changed:
while not self._queue and not self._closed:
self._busy = False
self._changed.notify_all()
self._changed.wait()
if self._closed:
if self._closed or not self._queue:
# Idle or closed: leave, so no thread outlives its work.
self._busy = False
self._worker_running = False
self._changed.notify_all()
return
take = min(len(self._queue), max(1, int(self._workers() or 1)))
+34 -16
View File
@@ -359,7 +359,10 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
with patch.object(auto.os, "scandir", counting):
resp = await self.client.get(f"{SERVE}thumbnails/Shot_thumb.png?root=0")
self.assertEqual(resp.status, 200)
self.assertEqual(len(listings), 1)
# 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.png").is_file())
async def test_broken_file_falls_back_to_the_original_and_is_remembered(self):
@@ -649,44 +652,59 @@ class TestWriteSafety(AutoThumbnailCase):
class TestShutdown(AutoThumbnailCase):
def _worker_threads(self):
return [
t
for t in threading.enumerate()
if t.name == auto.WORKER_NAME and t.is_alive()
]
async def test_no_thread_remains_after_cleanup_and_capture_cannot_restart(self):
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, "a.png")["thumbnail_url"]
_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 pool_threads:
for thread in [worker, *pool_threads]:
await loop.run_in_executor(None, thread.join, WAIT)
self.assertEqual(self._worker_threads(), [])
self.assertFalse(any(t.is_alive() for t in pool_threads))
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")
self.assertEqual(self._worker_threads(), [])
restarted = self.api._auto_thumbnailer
self.assertTrue(restarted is None or restarted._worker is None)
async def test_a_reload_stops_the_old_worker(self):
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()
(old,) = self._worker_threads()
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.png").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()
await loop.run_in_executor(None, auto._stop_stale_workers)
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