diff --git a/booth/app.py b/booth/app.py index c1e434c..2fe3b3d 100644 --- a/booth/app.py +++ b/booth/app.py @@ -1036,7 +1036,11 @@ def create_app( insertion order, so walking `shown` once IS the rule. ⚠ THE RAIL IS ABSENT UNLESS GROUPING IS INFORMATIVE: two or more - groups, and the middle group holding more than one item. TWO + groups, and the UPPER MEDIAN group holding more than one item — for an + even count that is the larger of the two central sizes, so `[1, 2]` + renders and `[1, 1, 2]` does not. Named precisely because "the middle + group" admitted both readings and two panel arms flagged the ambiguity. + TWO degeneracies, not one. The contract named only the first -- `sindra` and `sc-iso-spread` put every file in ONE group, and a rail with a single row cannot navigate. The second is the one the live set @@ -1051,13 +1055,33 @@ def create_app( if it["group"] is not None: by_group.setdefault(it["group"], []).append(it) sizes = sorted(len(v) for v in by_group.values()) + # `sizes[len(sizes) // 2]` is the UPPER median; the `< 2` term + # short-circuits, so the index is always valid. No upper bound on the + # ROW COUNT: 1,000 groups of two pass this and render a 1,000-row rail. + # Accepted known risk — the largest live booth is 66 items and picking + # a cap without a booth that needs one is the invented work the roadmap + # gate exists to prevent. Raised 2-of-4 by the panel, 2026-09-22. if len(sizes) < 2 or sizes[len(sizes) // 2] <= 1: return [] return [ - # The anchor is the FIRST member's existing tile id. The template - # already stamps `id="item-"` on every figure; minting a - # parallel `#group-` would be a second identity for one tile. - {"key": k, "n": len(v), "anchor": f"item-{v[0]['name']}"} + # THE ANCHOR IS BUILT FROM `url`, NOT `name`, and the template + # stamps the tile id from `url` too. Both sides must use the same + # percent-encoded string or the jump lands on the wrong artifact. + # + # A browser matches a fragment against ids RAW FIRST and only then + # percent-decoded, so a raw rel on both sides is not merely + # "unencoded" — it is AMBIGUOUS. With `a b.png` and `a%20b.png` in + # one booth, the first's href resolves to the fragment + # `item-a%20b.png` and the raw pass matches the SECOND file's id. + # `Item.url` is `quote(rel, safe="/")`, which is injective here + # (`a b` -> `a%20b`, `a%20b` -> `a%2520b`), and it is the convention + # `booth_flag` has always used for exactly this reason. + # + # Found 4-of-4 by the heid bug-hunt panel, 2026-09-22. The original + # anchor test could not see it: it asserted the href occurred as + # SOME id on the page, which stayed true while pointing at the wrong + # one. + {"key": k, "n": len(v), "anchor": f"item-{v[0]['url']}"} for k, v in by_group.items() ] diff --git a/booth/items.py b/booth/items.py index 77b428a..4f33621 100644 --- a/booth/items.py +++ b/booth/items.py @@ -197,7 +197,18 @@ def _resolve_captions(by_rel: dict[str, Path]) -> tuple[dict[str, str], set[str] if target is not None: try: - caption[target] = p.read_text(errors="replace").strip()[:CAPTION_MAX] + # BOUNDED AT THE READ. `read_text()` pulled the whole sidecar + # into memory before the slice trimmed it, so a pathological + # file was a MemoryError — which the OSError handler below does + # not catch — rather than a missing caption. + # + # Deliberately NOT bounded by st_size: a FIFO reports 0 and a + # bound that trusts it inherits what it does not mean, which is + # the hang in persistent-memory.d/2026-09-22-size-cap-opened-a-hang.md. + # The factor of 4 is UTF-8's worst case, so CAPTION_MAX + # characters always survive the byte bound. + with p.open("r", errors="replace") as fh: + caption[target] = fh.read(CAPTION_MAX * 4).strip()[:CAPTION_MAX] except OSError: pass sidecars.add(rel) @@ -222,7 +233,22 @@ def booth_items(booth: Path) -> list[Item]: continue if is_ask_file(p.name) or is_answer_file(p.name): continue - by_rel[p.relative_to(booth).as_posix()] = p + rel = p.relative_to(booth).as_posix() + try: + quote(rel, safe="/") + except UnicodeEncodeError: + # A non-UTF-8 filename reaches CPython as a surrogate escape, and + # `quote` raises on it. This used to happen at Item construction, + # OUTSIDE any per-item handler — so one 0xff byte in one filename + # took out that booth's page AND the index for every booth, because + # `list_booths` calls this too. The repo's posture is that a damaged + # file costs its own tile and never the page. + # + # Skipped rather than rescued: a name that cannot be percent-encoded + # cannot be linked, served or zipped either, so there is no item to + # render. Found by the heid bug-hunt panel (hulda), 2026-09-22. + continue + by_rel[rel] = p caption, sidecars = _resolve_captions(by_rel) blurred = read_blurred(booth) # ONE read per call, not one per item diff --git a/booth/templates/booth.html b/booth/templates/booth.html index 2ea19df..9faef1e 100644 --- a/booth/templates/booth.html +++ b/booth/templates/booth.html @@ -237,9 +237,19 @@ {% endif %} -{% if not items and not board and not marks %} +{# ⚠ THE RAIL IS GATED ON `all_items`, NOT `items`, AND THAT IS THE WHOLE + POINT. `items` is the FILTERED list, so gating on it meant a valid filter + with zero hits removed the rail, the filter links and the only way back to + `all` — while the empty-booth branch below announced the booth was empty + with `rail.total` still holding the real count. No recovery without editing + the address bar, and it failed the same way with JavaScript off, on the + surface the operator actually reviews on. + + Found by the heid bug-hunt panel (gróa, 2026-09-22), whose own note called + it the finding most likely to bite users this week. #} +{% if not all_items and not board and not marks %}
This booth is empty.
-{% elif items %} +{% elif all_items %} {# `elif items` and not a bare `else`: a board booth has NO gallery items (its links.md is rendered as the board above and filtered out), so a plain else would emit an empty + {% if not items %} + {# An empty FILTER, not an empty booth. The rail above is still rendered, so + the way back to `all` is one click. #} +
No items match the {{ filter }} filter. + show all {{ rail.total }}
+ {% endif %}