fix(r3): judge each rel once per request — a side or review item that vanishes mid-request never 500s

booth-dev's race note after the merge: the compare route resolved each side
in _compare_side and again in _compare_ring, then ring.index(a) raised if the
file vanished (or was relinked outside the booth) between the two; the review
did the same through cring.index(f). The compare ring is now built once and
the sides are judged by membership of it. The review re-judges its item and
scans forward for the next comparable one (usually one step, no longer a
resolve of the whole ring per render); an item no longer comparable renders
the review without a Compare control, and C does nothing.

The contract records the once-per-request rule and that the phone-width wrap
covers doc.html's bar too. r3.toml: 59 rows, four re-anchored.
This commit is contained in:
vh
2026-09-24 15:59:43 -07:00
parent cf08ae3f33
commit 8633b1dded
5 changed files with 118 additions and 32 deletions
+25 -19
View File
@@ -1971,7 +1971,14 @@ def create_app(
members = [r for r in ring if by_rel[r].group == item.group]
group = {"key": item.group, "k": members.index(f) + 1, "n": len(members)}
open_now = open_marks(marks)
cring = _compare_ring(booth, items)
# The compare partner: the next ring item a compare can open,
# scanning forward (usually one step) rather than resolving the
# whole ring per render. Each rel is judged once; this item is
# re-judged first, so a race costs the control, never the page.
partner = None
if _in_booth(booth, f):
partner = next((r for r in (ring[(pos + k) % len(ring)] for k in range(1, len(ring) + 1))
if r == f or _in_booth(booth, r)), None)
return templates.TemplateResponse(
request, "view.html", {
**common,
@@ -1993,12 +2000,12 @@ def create_app(
"is_last": pos == len(ring) - 1,
"tray": [x for x in film if x["flagged"]],
"back_url": f"/b/{quote(name, safe='')}/#item-{item.url}",
# R3 C2: this item against the NEXT in the compare ring
# (itself in a ring of one), keyed by rel like every compare
# URL. This item passed the containment check above, so it
# is in the compare ring.
# R3 C2: this item against the NEXT comparable item in the
# ring (itself in a ring of one), keyed by rel like every
# compare URL; None, and no control, when it is not itself
# comparable any more (it vanished after the check above).
"compare_url": (f"/b/{quote(name, safe='')}/compare?a={quote(f, safe='/')}"
f"&b={quote(cring[(cring.index(f) + 1) % len(cring)], safe='/')}"),
f"&b={quote(partner, safe='/')}") if partner else None,
})
# .md renders, .txt/.log show as text — viewable in-booth, no download
@@ -2039,16 +2046,15 @@ def create_app(
bug hunt, 3 of 4)."""
return [r for r in review_chain(items) if _in_booth(booth, r)]
def _compare_side(booth: Path, ring: list[str], rel) -> str:
"""One side of a compare, or a 404 (R3 C1). A CONJUNCTION: the view
route's resolve / containment / is_file check, AND membership of the
review ring. The ring alone is not enough — `booth_items` follows
symlinks, so a link pointing outside the booth is IN the ring and only
containment refuses it. The view's check alone is not enough — it
renders a doc, and compare takes media only."""
if not _in_booth(booth, rel):
raise HTTPException(status_code=404, detail="no such item")
if rel not in ring:
def _compare_side(ring: list[str], rel) -> str:
"""One side of a compare, or a 404 (R3 C1): membership of the COMPARE
ring, which is already the conjunction — the review ring (a doc is not
in it) filtered by the view route's containment (an outside symlink is
in the review ring and not in this one). Judged against the ONE ring
the route builds per request: resolving a rel twice lets a file that
vanishes between the two reach a `.index()` that raises, a 500
(booth-dev, after the merge)."""
if not isinstance(rel, str) or rel not in ring:
raise HTTPException(status_code=404, detail="no such item")
return rel
@@ -2073,9 +2079,9 @@ def create_app(
"""
booth = resolve_booth(name)
items = booth_items(booth)
a = _compare_side(booth, review_chain(items), a)
b = _compare_side(booth, review_chain(items), b)
ring = _compare_ring(booth, items)
ring = _compare_ring(booth, items) # built ONCE; every rel judged once
a = _compare_side(ring, a)
b = _compare_side(ring, b)
# A look records both — below the 404s, so only a real pair counts.
record_view(booth)
record_seen(booth, a, items)