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:
+41
-12
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user