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
+48
View File
@@ -165,6 +165,54 @@ def test_a_planted_fifo_marker_cannot_hang_a_look(tmp_path):
assert got == [200], "a planted FIFO held the look open"
def _vanish_after_scan(monkeypatch, name: str, grace: int) -> None:
"""Make `name` stop being a file partway through a request: once
booth_items has scanned the booth, the first `grace` is_file checks of it
still pass and every later one fails — a file deleted or relinked outside
the booth mid-request, between two resolves of the same rel."""
import pathlib as _pl
import booth.app as app_mod
state = {"armed": False, "calls": 0}
real_items, real_is_file = app_mod.booth_items, _pl.Path.is_file
def items(booth):
out = real_items(booth)
state["armed"] = True
return out
def is_file(self):
if state["armed"] and self.name == name:
state["calls"] += 1
if state["calls"] > grace:
return False
return real_is_file(self)
monkeypatch.setattr(app_mod, "booth_items", items)
monkeypatch.setattr(_pl.Path, "is_file", is_file)
def test_a_side_that_vanishes_mid_request_never_500s(tmp_path, monkeypatch):
"""booth-dev's race: each rel must be judged ONCE per request. A side that
passes its check and then vanishes before a second resolve must not reach
a `.index()` that raises — a damaged file costs its own tile, never the
page."""
_booth(tmp_path, "g", {"p.png": PNG, "q.png": PNG, "r.png": PNG})
_vanish_after_scan(monkeypatch, "p.png", grace=1)
r = _client(tmp_path).get("/b/g/compare?a=p.png&b=q.png")
assert r.status_code in (200, 404), r.status_code
def test_the_review_hides_compare_when_its_item_vanishes_mid_request(tmp_path, monkeypatch):
"""The review checked its item, then the item vanished before the compare
ring was built: the page still renders, without a Compare control (a
compare of it would 404) — never a 500."""
_booth(tmp_path, "g", {"p.png": PNG, "q.png": PNG})
_vanish_after_scan(monkeypatch, "p.png", grace=0)
r = _client(tmp_path).get("/b/g/view?f=p.png")
assert r.status_code == 200, r.status_code
assert 'class="vbtn vcompare"' not in r.text
def test_a_look_records_both_seen(tmp_path):
"""A compare GET is a look at both sides; a 404 records nothing."""
import json