fix(thumbnails): keep the new preview, probe encoders only when gone, sweep reused PIDs

- Convert never removes a path that is the preview it just wrote. It
  compares the device and inode, or the resolved and normcased paths when
  the disk has no inode numbers. This covers x_thumb.JPG on a
  case-insensitive disk.
- A JPEG XL encode failure re-runs encoder detection only when the
  encoder's binary or module is gone. A "no encoder" result is checked
  again on the settings read, on a jxl save, and after 60 s, so one slow
  probe no longer turns JPEG XL off until a restart.
- The startup sweep now also removes temp files that carry this process's
  PID but predate its start. Containers reuse PIDs.
This commit is contained in:
Vito Sansevero
2026-10-24 15:35:43 -07:00
parent 1e9ed87454
commit 7c82fdb180
6 changed files with 174 additions and 25 deletions
+47 -4
View File
@@ -341,7 +341,8 @@ class TestStrayTemps(TempCase):
self._touch(".c_thumb.part-x-2.webp"),
self._touch(".young_thumb.part-7-8.webp", age=0),
]
own = self._touch(f".mine_thumb.part-{os.getpid()}-9.webp")
# A temp of this process written since it started may be in use.
own = self._touch(f".mine_thumb.part-{os.getpid()}-9.webp", age=0)
count, size = te.sweep_stray_temps(self.tmp, min_age=60)
self.assertEqual((count, size), (3, 30))
for path in strays:
@@ -363,6 +364,17 @@ class TestStrayTemps(TempCase):
if link is not None:
self.assertTrue(link.is_symlink())
def test_an_own_pid_temp_older_than_this_process_is_swept(self):
# Re-review N3: under stable PIDs (Docker) the previous run's crash
# leftovers carry this process's PID; they predate its start.
before = te.PROCESS_STARTED - 3600
stale = self._touch(f".old_thumb.part-{os.getpid()}-9.webp")
os.utime(stale, (before, before))
live = self._touch(f".new_thumb.part-{os.getpid()}-9.webp", age=0)
self.assertEqual(te.sweep_stray_temps(self.tmp, min_age=60)[0], 1)
self.assertFalse(stale.exists())
self.assertTrue(live.exists())
def test_a_missing_folder_sweeps_nothing(self):
self.assertEqual(te.sweep_stray_temps(self.tmp / "none"), (0, 0))
@@ -373,9 +385,10 @@ class TestEncoderVanishes(TempCase):
availability Appearance reads is refreshed."""
def test_a_missing_binary_falls_back_to_webp_and_refreshes(self):
found = iter(["cjxl", None])
found = iter(["cjxl", None, None])
with (
patch.object(te, "_detect", side_effect=lambda: next(found)),
patch.object(te.shutil, "which", return_value=None),
patch.object(te, "run_encoder", side_effect=FileNotFoundError("cjxl")),
self.assertLogs("prompt_manager.thumbnails", logging.WARNING),
):
@@ -405,6 +418,11 @@ class TestEncoderVanishes(TempCase):
with (
patch.object(te, "_detect", side_effect=lambda: next(found)),
patch.object(
te.shutil,
"which",
side_effect=lambda name: None if name == "cjxl" else name,
),
patch.object(te, "run_encoder", side_effect=fake),
patch.object(te, "_pillow_decodes_jxl", return_value=False),
):
@@ -417,10 +435,13 @@ class TestEncoderVanishes(TempCase):
self.assertEqual(used, ["cjxl", "ffmpeg"])
self.assertEqual(self._leftovers(), [])
def test_the_same_encoder_failing_again_raises(self):
def test_a_failure_with_the_encoder_present_does_not_detect_again(self):
# Re-review N2: one bad file never runs a probe (which may time out
# under load and turn JPEG XL off).
failed = subprocess.CompletedProcess([], 1, b"", b"bad input")
with (
patch.object(te, "_detect", return_value="cjxl") as detect,
patch.object(te.shutil, "which", return_value="/usr/bin/cjxl"),
patch.object(te, "run_encoder", return_value=failed),
):
with self.assertRaises(te.EncoderFailed):
@@ -429,7 +450,29 @@ class TestEncoderVanishes(TempCase):
self.tmp / "x_thumb.jxl",
ThumbnailSettings("jxl", 80, 64),
)
self.assertEqual(detect.call_count, 2, "detected again once")
self.assertEqual(detect.call_count, 1, "no second probe")
def test_no_encoder_found_is_checked_again_on_the_settings_read(self):
# Re-review N2: a probe that found nothing (a timeout) recovers.
found = iter([None, "cjxl"])
with patch.object(te, "_detect", side_effect=lambda: next(found)):
self.assertIsNone(te.detect_jxl_encoder())
self.assertEqual(te.encoder_status()["jxl"]["encoder"], "cjxl")
self.assertEqual(te.detect_jxl_encoder(), "cjxl")
def test_no_encoder_found_is_checked_again_after_a_while(self):
found = iter([None, "ffmpeg"])
clock = [1000.0]
with (
patch.object(te, "_detect", side_effect=lambda: next(found)) as detect,
patch.object(te.time, "monotonic", side_effect=lambda: clock[0]),
):
self.assertIsNone(te.detect_jxl_encoder())
self.assertIsNone(te.detect_jxl_encoder(), "cached within the TTL")
clock[0] += te.MISSING_ENCODER_TTL + 1
self.assertEqual(te.detect_jxl_encoder(), "ffmpeg")
self.assertEqual(te.detect_jxl_encoder(), "ffmpeg")
self.assertEqual(detect.call_count, 2)
class TestEncoderProcesses(TempCase):