diff --git a/booth/app.py b/booth/app.py index ad293b9..d9e8498 100644 --- a/booth/app.py +++ b/booth/app.py @@ -144,7 +144,7 @@ def set_blurred(booth: Path, rel: str, on: bool) -> set[str]: # The link-board logic lives in booth/links.py (stdlib only) so the `booth` CLI # can use it without pulling FastAPI in. Re-exported here because call sites and # tests already reference these names through app. -from booth.thumbs import ensure_thumb +from booth.thumbs import THUMB_DIR, ensure_thumb from booth.asks import ( # noqa: E402 ANSWER_SUFFIX, ASK_SUFFIX, @@ -248,6 +248,15 @@ def _newest_mtime(path: Path) -> float: for p in path.rglob("*"): if p.name.startswith(".") and p.name.endswith(".lock"): continue + # ⚠ AND THE THUMBNAIL CACHE, for the same reason as the locks: it is + # MACHINERY, not the operator doing something, and it is written by the + # SERVER on a mere view. Counting it made looking at a booth age it — + # and once the Desk's preview strip pulls a thumbnail per booth, one + # index load would push EVERY booth's expiry out and the TTL would + # never fire again. `.viewed` counting is different and deliberate: + # that is a record of a person looking, which U4 says is activity. + if THUMB_DIR in p.relative_to(path).parts: + continue try: m = p.stat().st_mtime except FileNotFoundError: @@ -674,7 +683,12 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> "viewed_at": _viewed_at(child), # The first four images in item order, as the originals shown # small. Blurred ones stay blurred, the cover's rule. - "preview": [(it.url, it.blurred) for it in items if it.kind == "image"][:4], + # THE THUMBNAIL, not the original: this strip is four small + # images per booth on the page he opens FIRST, so on 28 booths + # it was the heaviest surface in the service — heavier than the + # gallery it previews. `thumb` already carries `?thumb=1`. + "preview": [(it.thumb or it.url, it.blurred) + for it in items if it.kind == "image"][:4], } ) # Newest first, NAME as the tie-break. Sorting on mtime alone left equal-mtime diff --git a/booth/templates/_marks.html b/booth/templates/_marks.html index 6a10c2a..d9a3791 100644 --- a/booth/templates/_marks.html +++ b/booth/templates/_marks.html @@ -145,7 +145,7 @@
diff --git a/booth/thumbs.py b/booth/thumbs.py index 872dfd0..043cfe7 100644 --- a/booth/thumbs.py +++ b/booth/thumbs.py @@ -82,7 +82,24 @@ def ensure_thumb(booth: Path, rel: str) -> Path | None: im.thumbnail((THUMB_MAX, THUMB_MAX)) if im.mode not in ("RGB", "RGBA"): im = im.convert("RGBA" if "A" in im.getbands() else "RGB") + # ⚠ CREATING THE CACHE DIR TOUCHES THE BOOTH DIRECTORY'S OWN + # MTIME, and `_newest_mtime` seeds from exactly that — so merely + # LOOKING at a booth aged it, and once the Desk pulls a thumbnail + # per booth, one index load would push every expiry out and the TTL + # would never fire again. Excluding the cache's CONTENTS is not + # enough; the directory entry is the leak. + # + # Restoring the booth's mtime cannot hide real activity: any file an + # agent adds is counted by its OWN mtime in the same walk, and the + # directory stamp is only the seed. + made = not out.parent.exists() + booth_stat = booth.stat() if made else None out.parent.mkdir(parents=True, exist_ok=True) + if booth_stat is not None: + try: + os.utime(booth, ns=(booth_stat.st_atime_ns, booth_stat.st_mtime_ns)) + except OSError: + pass # Atomic, like every other sidecar this service writes: a reader # polling the cache never sees a half-encoded image. tmp = out.with_name(out.name + f".{os.getpid()}.tmp") diff --git a/tests/test_lifetime.py b/tests/test_lifetime.py index 6c2bf6e..cf17819 100644 --- a/tests/test_lifetime.py +++ b/tests/test_lifetime.py @@ -1096,3 +1096,42 @@ def test_a_booth_name_cannot_reach_a_js_string_context(client): assert 'data-confirm="wipe"' in html, "the name travels as data, where escaping is escaping" assert ">'+xssCanary7+'<" in html, "and still renders as the name it is" + + +def test_generating_a_thumbnail_does_not_age_a_booth(tmp_path): + """⚠ A VIEW-DRIVEN WRITE MUST NOT RESET THE EXPIRY CLOCK, and the thumbnail + cache is the first thing in this repo that writes without the operator + doing anything. + + `.viewed` counts as activity ON PURPOSE — U4's "viewing is activity" — but + that is a DELIBERATE look. A derived cache is machinery, exactly like the + `.lock` sidecars already excluded here, and it is written by the SERVER. + + The failure this prevents is not small. Once the Desk's preview strip pulls + a thumbnail for every booth, loading the index would touch every booth's + cache and push every expiry out — the TTL would never fire again and + nothing would ever sweep. Caught by design-dev before the strip landed; + the bug was already live for the gallery. + + Defeating change: dropping the THUMB_DIR arm of the exclusion.""" + import os + import time + + from booth.app import _newest_mtime + from booth.thumbs import ensure_thumb + + pytest.importorskip("PIL.Image") + from PIL import Image + + b = tmp_path / "g" + b.mkdir() + Image.new("RGB", (1024, 1024), (9, 9, 9)).save(b / "a.png") + old = time.time() - 86400 * 3 + for p in b.rglob("*"): + os.utime(p, (old, old)) + os.utime(b, (old, old)) + + before = _newest_mtime(b) + assert ensure_thumb(b, "a.png") is not None, "nothing was generated to test" + assert _newest_mtime(b) == pytest.approx(before, abs=2), \ + "generating a thumbnail reset the booth's expiry clock" diff --git a/tests/test_thumbs.py b/tests/test_thumbs.py index 7c77683..22c7660 100644 --- a/tests/test_thumbs.py +++ b/tests/test_thumbs.py @@ -161,3 +161,19 @@ def test_the_filmstrip_and_tray_use_thumbnails_but_the_stage_does_not(tmp_path): assert 'src="b.png?thumb=1"' in html, "the filmstrip still pulls full images" assert 'src="b.png"' not in html.replace('src="b.png?thumb=1"', ""), \ "a full-size frame survived in the strip" + + +def test_the_desk_preview_strip_uses_thumbnails(tmp_path): + """The heaviest surface in the service, on the page he opens FIRST: four + small images per booth, across every booth. design-dev measured 28 + originals / 24.1 MB on a 12-booth copy; live has 28 booths. + + Defeating change: `it.url` in the preview tuple.""" + for name in ("one", "two"): + b = tmp_path / name + for n in ("a.png", "b.png"): + _img(b / n, 1024, 1024) + c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False)) + html = c.get("/").text + assert 'src="/b/one/a.png?thumb=1"' in html, "the Desk strip still pulls full images" + assert 'src="/b/one/a.png"' not in html