fix(r2): the heid bug-hunt panel (round "Nyx", 4/4) — triaged and folded

In-place client (base.html):
- Saves are serialized: POST, re-fetch and swap complete before the next
  save starts, so an older snapshot can no longer land after a newer one.
- A form already queued or in flight ignores another submit; a
  double-click writes one note.
- Dirty controls (drafts, unsent radio choices) and disclosures carry by
  identity (form action + hidden ask/target/mark/f + name), not position.
- Any non-tile structural difference, or a page with no region to swap,
  reloads instead of patching.

Server and templates:
- .seen is a JSON array read without following links or blocking,
  regular files of at most 1 MiB only; malformed, nested-too-deep or
  planted markers read as nothing seen.
- landed_at reads symlinks by lstat and skips one unreadable entry
  instead of pinning the booth in "new".
- The Desk counts flags on current items only; orphan flags are listed
  under the tray with an unmark form.
- Agent-written bench and bookmark URLs link only when http(s).
- Audio and video tiles carry a review link.
- A rel the filesystem cannot represent is a 404, not a 500.
- A non-finite Accept q-value fails to parse.
- The standalone marks page has regions and updates in place.
- The review's next arrow sits at the edge at phone width.

Contract amended for each, plus an accepted-risks section (unlocked
.seen read-modify-write, a planted .viewed symlink, Item.ordinal with
no default).

741 passed. Each new browser test was mutation-checked against its fix;
the serialization test forces the race with a held first refresh, since
localhost alone never lost it.
This commit is contained in:
vh
2026-09-23 10:22:51 -07:00
parent fa5d46443d
commit 77833dc6d4
11 changed files with 622 additions and 82 deletions
+41 -12
View File
@@ -37,6 +37,8 @@ import asyncio
import fcntl
import hashlib
import io
import json
import math
import os
import re
import secrets
@@ -258,15 +260,16 @@ def _newest_mtime(path: Path) -> float:
def _content_mtime(path: Path) -> float:
"""`landed_at` (R2 C4): the newest mtime among the booth's CONTENT — regular
files with no dot-component in their path. Deliberately NOT `_newest_mtime`
files and symlinks (by lstat) with no dot-component in their path. Deliberately NOT `_newest_mtime`
(INV-5 of r2): a mark, a view, a blur or a keep is activity, never new
content, so none of them may make a booth read as newly landed.
Files only, never directories: creating `.viewed` bumps the booth
directory's own mtime, and counting that would make the first look at a
booth look like a delivery. An empty booth landed at 0.0. Unknowable reads
as NOW, the posture `_newest_mtime` takes and for a milder reason here: a
booth we cannot read is shown as new rather than hidden as old.
booth look like a delivery. An empty booth landed at 0.0. One unreadable
ENTRY is skipped; a booth whose walk cannot run at all reads as NOW, the
posture `_newest_mtime` takes and for a milder reason here: a booth we
cannot read is shown as new rather than hidden as old.
"""
newest = 0.0
try:
@@ -275,10 +278,15 @@ def _content_mtime(path: Path) -> float:
if any(part.startswith(".") for part in rel.parts):
continue
try:
st = p.stat()
except FileNotFoundError:
# lstat: a posted SYMLINK counts by its own mtime — when it was
# placed — never by its target's. A link to a busy file outside
# the booth must not make the booth read as newly delivered.
st = p.lstat()
except OSError:
# One unreadable entry costs that entry, not the booth: reading
# the whole booth as landed NOW would pin it in "new" forever.
continue
if stat.S_ISREG(st.st_mode) and st.st_mtime > newest:
if (stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode)) and st.st_mtime > newest:
newest = st.st_mtime
except OSError:
return time.time()
@@ -392,10 +400,13 @@ def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None:
try:
live = {it.rel for it in items}
seen = (read_seen(booth) | {rel}) & live
# A JSON array, UTF-8 explicitly: a rel may hold a newline or a leading
# space, and the host locale must not decide whether a name encodes.
body = json.dumps(sorted(seen), ensure_ascii=False).encode("utf-8", "surrogateescape")
fd, tmp = tempfile.mkstemp(prefix=".seen.", suffix=".tmp", dir=booth)
try:
with os.fdopen(fd, "w") as fh:
fh.write("".join(f"{r}\n" for r in sorted(seen)))
with os.fdopen(fd, "wb") as fh:
fh.write(body)
os.replace(tmp, booth / SEEN_FILE)
except BaseException:
try:
@@ -403,7 +414,9 @@ def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None:
except OSError:
pass
raise
except OSError:
except (OSError, ValueError):
# ValueError covers an encode failure — it is not an OSError, and a
# look that cannot be recorded must never cost the page.
pass
@@ -433,6 +446,9 @@ def wants_json(accept: str | None) -> bool:
key, _, value = param.partition("=")
if key.strip().lower() == "q":
q = float(value.strip())
if not math.isfinite(q):
# inf, 1e999, nan parse as floats but are not q-values
raise ValueError("non-finite q")
if mtype.strip().lower() == "application/json" and q > 0:
wanted = True
except ValueError:
@@ -647,7 +663,9 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
# dated question, because the damage is what needs fixing.
"open_since": (min(stamps) if stamps and hold != HOLD_UNREADABLE
else None),
"flags": len(flagged_targets(marks)),
# items that EXIST: a flag on a file since deleted is shown on
# the booth page for withdrawal, not counted as a pick here
"flags": len(flagged_targets(marks) & {it.rel for it in items}),
# Two clocks, named apart (INV-5): `mtime` is activity,
# `landed_at` is content. "New since you looked" reads only the
# second, so a flag or a view never makes a booth look new.
@@ -1180,7 +1198,16 @@ def create_app(
# the wrong group. The rail's jump links do not depend on this.
"inline_groups": bool(rail["groups"]) and _contiguous(
[it["group"] for it in shown]),
# THE flag predicate for this page — the tile class and the tile
# toggle read it too, so no surface on the page can disagree.
"flagged_set": flagged_targets(marks),
"tray": [it for it in gallery if it["name"] in flagged_targets(marks)],
# A flag whose file is gone from the booth: no tile to stamp and
# no tray slot, so it is listed apart with its withdraw control
# rather than vanishing from the page while staying in the file.
"orphan_flags": [m for m in marks
if m.shape == "flag" and m.error is None and m.target
and m.target not in {it["name"] for it in gallery}],
"ord_width": len(str(len(gallery))),
"uploaded": (booth / UPLOAD_MARKER).exists(),
# The same provenance line the index card carries. Deliberate:
@@ -1705,7 +1732,9 @@ def create_app(
booth = resolve_booth(name)
try:
target = (booth / f).resolve()
except OSError:
except (OSError, ValueError):
# ValueError: an embedded NUL. Hostile input, like every other
# unresolvable `f` — a 404, never a 500.
raise HTTPException(status_code=404, detail="no such file")
if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
raise HTTPException(status_code=404, detail="no such file")