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
+4 -1
View File
@@ -13,6 +13,7 @@ checks one path and writes nothing.
"""
import contextlib
import functools
import os
from aiohttp import web
@@ -311,7 +312,9 @@ class SettingsRoutesMixin:
if updates.get("THUMBNAIL_FORMAT") == "jxl":
# #317: JPEG XL only with an encoder (a cached probe).
encoder = await self._run_in_executor(
thumbnail_encode.detect_jxl_encoder
functools.partial(
thumbnail_encode.detect_jxl_encoder, recheck_missing=True
)
)
if encoder is None:
return web.json_response(
+17
View File
@@ -110,6 +110,22 @@ def _group_targets(targets):
return ordered
def _same_file(a, b):
"""Whether *a* and *b* name one file (re-review N1: on a case-insensitive
disk ``x_thumb.JPG`` is the ``x_thumb.jpg`` just written). Without inode
numbers (st_ino 0 on some SMB/FAT mounts) the resolved, normcased paths
are compared."""
try:
first, second = os.stat(a), os.stat(b)
except OSError:
return False
if first.st_ino and second.st_ino:
return (first.st_dev, first.st_ino) == (second.st_dev, second.st_ino)
return os.path.normcase(os.path.realpath(a)) == os.path.normcase(
os.path.realpath(b)
)
def _format_changed(settings):
"""Whether previews are written in another format than *settings* now."""
return writer_settings().extension != settings.extension
@@ -297,6 +313,7 @@ class ThumbnailConvertMixin:
return {"action": "error", "error": SOURCE_GONE}
if _format_changed(settings):
return {"action": "stopped"}
old = [(path, size) for path, size in old if not _same_file(path, dst)]
removed, kept = self._remove_old_previews(old)
if kept:
return {"action": "error", "error": OLD_NOT_REMOVED}
+47 -15
View File
@@ -20,10 +20,13 @@ Quality maps to a JPEG XL distance with libjxl's own
(q80 → d1.9, q85 → d1.45, q90 → d1.0), the quadratic below 30, and q100 →
d0.1 (a preview is never lossless). Pillow's plugin applies the same mapping
inside libjxl. Detection is cached (``detect_jxl_encoder(refresh=True)``
probes again). An encoder that fails (its binary gone, or a non-zero exit)
makes one write detect again: another encoder found is used for that file,
none at all writes it as WebP (logged once) and refreshes what Appearance
reads (``encoder_status``). The same encoder found again raises.
probes again); "none found" is probed again on the settings read
(``encoder_status``) and after MISSING_ENCODER_TTL, so one slow probe never
turns JPEG XL off until a restart. An encoder that fails because its binary
or module is gone makes that write detect again: another encoder found is
used for that file, none at all writes it as WebP (logged once) and
refreshes what Appearance reads. Any other failure (one bad file) raises
without a probe.
Encoder processes (and ffmpeg's video frames, images.py) go through
``run_encoder``: tracked, bounded by a timeout, and killed by
@@ -108,6 +111,9 @@ def replace_unless_held(tmp, dst, guard=None):
_TEMP_NAME = re.compile(r"^\..+\.(?:part|src)-(\d+)-\d+\.[A-Za-z0-9]+$")
# A temp at least this old (seconds) outlived its write: no encode runs longer.
STRAY_TEMP_AGE = 4 * ENCODE_TIMEOUT
# When this process started (this module's import): a temp carrying its PID
# that is older was left by an earlier run with the same PID (containers).
PROCESS_STARTED = time.time()
def sweep_stray_temps(folder, min_age=None):
@@ -116,8 +122,9 @@ def sweep_stray_temps(folder, min_age=None):
Only ``temp_sibling`` names are touched, never a link. With *min_age*
(seconds, the startup sweep) a temp younger than that, or one this
process may still be writing, is left; ``min_age=0`` (the clear, which
holds every preview write) removes them all.
process wrote since it started (it may still be writing it), is left;
``min_age=0`` (the clear, which holds every preview write) removes them
all.
"""
min_age = STRAY_TEMP_AGE if min_age is None else min_age
now = time.time()
@@ -133,7 +140,10 @@ def sweep_stray_temps(folder, min_age=None):
stat = path.lstat()
if path.is_symlink() or not path.is_file():
continue
if min_age and (match.group(1) == own or now - stat.st_mtime < min_age):
if min_age and (
now - stat.st_mtime < min_age
or (match.group(1) == own and stat.st_mtime >= PROCESS_STARTED)
):
continue
path.unlink()
except OSError as exc:
@@ -229,6 +239,9 @@ def reset_encoders():
_UNSET = object()
_detect_lock = threading.Lock()
_detected = _UNSET
_detected_at = 0.0
# Seconds after which "no JPEG XL encoder" is probed again.
MISSING_ENCODER_TTL = 60
_fallback_logged = False
@@ -290,18 +303,34 @@ def _detect():
return None
def detect_jxl_encoder(refresh=False):
"""``pillow``, ``ffmpeg``, ``cjxl`` or None (cached; blocking on a probe)."""
global _detected
def detect_jxl_encoder(refresh=False, recheck_missing=False):
"""``pillow``, ``ffmpeg``, ``cjxl`` or None (cached; blocking on a probe).
A cached None is probed again with *recheck_missing* or once it is older
than MISSING_ENCODER_TTL."""
global _detected, _detected_at
with _detect_lock:
if refresh or _detected is _UNSET:
now = time.monotonic()
stale_missing = _detected is None and (
recheck_missing or now - _detected_at >= MISSING_ENCODER_TTL
)
if refresh or _detected is _UNSET or stale_missing:
_detected = _detect()
_detected_at = now
return _detected
def _encoder_gone(encoder):
"""Whether *encoder*'s binary or module is no longer there."""
if encoder == "pillow":
return not _pillow_can_save_jxl()
return shutil.which(encoder) is None
def encoder_status():
"""Which formats can be written now (``GET /prompt_manager/settings``)."""
encoder = detect_jxl_encoder()
"""Which formats can be written now (``GET /prompt_manager/settings``;
a "none found" is probed again here)."""
encoder = detect_jxl_encoder(recheck_missing=True)
return {
"webp": {"available": True},
"jpeg": {"available": True},
@@ -367,13 +396,16 @@ class _EncoderGone(RuntimeError):
def _write_jxl(img, tmp, settings, track):
"""Encode with the detected encoder; one that fails makes the write
detect again once (another one is used, none raises _EncoderGone)."""
"""Encode with the detected encoder. One that failed because it is gone
makes the write detect again once (another one is used, none raises
_EncoderGone); any other failure raises without a probe."""
encoder = detect_jxl_encoder()
try:
_encode_jxl(encoder, img, tmp, settings, track)
return
except (OSError, EncoderFailed) as exc:
if encoder is not None and not _encoder_gone(encoder):
raise
again = detect_jxl_encoder(refresh=True)
if again is None:
raise _EncoderGone(str(exc)) from exc