fix(marks): v0.2.2 — nine findings from the cross-frontier bug-hunt panel
`/heid-bug-hunt` on U2's diff, four arms, artifact-only. Eight findings were real against live code; a ninth was already closed by v0.2.1 and is recorded as declined. Full triage in persistent-memory.d/2026-09-22-bug-hunt-panel.md. THE LOCK LIFECYCLE (4/4 convergent, and two defects in one place) `_Locked.__exit__` unlinked `.marks.lock` on the no-op path so a booth that had never been marked was left exactly as it was found. `flock` binds to an INODE: unlinking it under a blocked waiter leaves that waiter holding an exclusive lock on a deleted file while the next writer creates a fresh lock and takes it immediately. Two processes then run the read-modify-write concurrently, the later os.replace drops the earlier one's mark, and both obeyed the protocol. The cleanup existed to protect the booth's TTL, and was failing at that too: creating or removing a directory entry bumps the DIRECTORY's mtime, which is what `_newest_mtime` seeds from. The guard's comment reasons about the lock file's own mtime and misses that the directory moved underneath it. One fix: never unlink the lock, exempt `.<name>.lock` dotfiles from `_newest_mtime`, and restore the directory's mtime after creating one. THE READ PATH'S BLAST RADIUS `_clean_text` did `(text or "").replace(...)` and `marks_for` sorts on `(created, id)`, so a stored `text` that was a dict or a `created` that was a number raised out of the read path. `list_booths` reads every booth's marks on every index load, so one hand-edited file returned 500 for `/` and `/healthz` across all 25 booths. Guarded in two layers — a named type check and a `_hydrate_safe` backstop that cannot raise — and an unreadable mark now renders as ⚠ broken rather than as an empty note. ALSO - import_legacy_asks stamped `created` at whole-second resolution, so two sidecars from the same second lost the ordering the importer had just established and re-sorted alphabetically. Microseconds, per the stated `(mtime, name)` rule. - The five mark-write routes ran a blocking flock on the event loop; they now dispatch through run_in_threadpool, asserted structurally like INV-1. - `/answer` 500'd on a non-string `notes` form value where `/note` handled it. - The inline-doc tile had a flag control and no note field. - The marks panel was suppressed on any booth carrying a links.md. - The viewer's arrow keys and Escape threw away a note being typed. CLI `booth marks` printed a traceback and exited 0 on a failed read, and `--wait` emitted a whole JSON document per poll. `booth answer --wait` read a damaged file as "not yet" and spun the full hour. Both now use real exit codes — 0 ok, 1 unanswered/timed-out, 2 no such pick, 3 unreadable — and `--wait` prints once. `marks.read_error()` lets the CLI ask what the page must not: the browser stays lenient, the machine consumer gets the truth. `scripts/booth` had no tests; it has five now, run against the real script under the system python3, which also makes them a live check on INV-1. 275 tests (253 before). Live service restarted, 25/25 booth pages verified 200.
This commit is contained in:
+49
-9
@@ -45,6 +45,7 @@ from fastapi.responses import (
|
||||
Response,
|
||||
)
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
try:
|
||||
@@ -169,12 +170,27 @@ def human_dur(seconds: float) -> str:
|
||||
|
||||
|
||||
def _newest_mtime(path: Path) -> float:
|
||||
"""Newest mtime among a folder and everything under it."""
|
||||
"""Newest mtime among a folder and everything under it — OUR LOCKS EXCEPT.
|
||||
|
||||
A booth's age is how long since somebody touched it, and a lock sidecar is
|
||||
machinery: `marks.py` and `links.py` each create one on the way into a
|
||||
read-modify-write, including one that turns out to change nothing. Counting
|
||||
it made reading-through-a-write-path look like activity, and a no-op mark
|
||||
POST on a dead booth reset its clock.
|
||||
|
||||
The exclusion is `.<something>.lock` — a DOTfile, which is the Booth's own
|
||||
namespace. An agent that posts a real artifact called `build.lock` still
|
||||
gets its clock counted. Everything else counts too, dotfiles included,
|
||||
because `.marks.json`, `.blurred` and `.pins` are the operator doing
|
||||
something.
|
||||
"""
|
||||
try:
|
||||
newest = path.stat().st_mtime
|
||||
except OSError:
|
||||
return 0.0
|
||||
for p in path.rglob("*"):
|
||||
if p.name.startswith(".") and p.name.endswith(".lock"):
|
||||
continue
|
||||
try:
|
||||
m = p.stat().st_mtime
|
||||
except OSError:
|
||||
@@ -478,6 +494,18 @@ PICKUP_WORDS = (
|
||||
).split()
|
||||
|
||||
|
||||
def _form_text(form, key: str) -> str:
|
||||
"""One form field as text, or "" for anything that is not text.
|
||||
|
||||
A multipart FILE part named `notes` parses to an UploadFile, not a str, and
|
||||
every downstream cleaner calls `.replace` on what it is handed. Coercing
|
||||
here keeps that decision in one place instead of one `isinstance` per call
|
||||
site — which is how `/note` came to have the guard and `/answer` not to.
|
||||
"""
|
||||
value = form.get(key)
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def safe_upload_name(name: str, fallback: str) -> str:
|
||||
"""Reduce a client-supplied filename to a safe basename (no path, no hidden)."""
|
||||
base = (name or "").replace("\\", "/").split("/")[-1].strip()
|
||||
@@ -760,13 +788,22 @@ def create_app(
|
||||
if spec.error is not None:
|
||||
raise HTTPException(status_code=400, detail=spec.error)
|
||||
who = request.client.host if request.client else ""
|
||||
# `notes` is whatever the form parser yielded. A multipart FILE part
|
||||
# named `notes` is an UploadFile, and `_clean_notes` calls `.replace` on
|
||||
# it — a 500 on hostile-but-legal input, where the sibling `/note` route
|
||||
# returns 400 for exactly the same class of value. Same parser, same
|
||||
# question, one answer.
|
||||
notes = _form_text(form, "notes")
|
||||
try:
|
||||
if spec.multi:
|
||||
choice = {q["key"]: form.get(f"choice.{q['key']}") for q in spec.questions}
|
||||
qnotes = {q["key"]: form.get(f"notes.{q['key']}") for q in spec.questions}
|
||||
answer_pick(booth, mark_id, choice, form.get("notes", ""), who=who, qnotes=qnotes)
|
||||
qnotes = {q["key"]: _form_text(form, f"notes.{q['key']}")
|
||||
for q in spec.questions}
|
||||
await run_in_threadpool(answer_pick, booth, mark_id, choice, notes,
|
||||
who=who, qnotes=qnotes)
|
||||
else:
|
||||
answer_pick(booth, mark_id, form.get("choice"), form.get("notes", ""), who=who)
|
||||
await run_in_threadpool(answer_pick, booth, mark_id,
|
||||
form.get("choice"), notes, who=who)
|
||||
except AskError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return _mark_redirect(name, form, f"mark-{quote(mark_id, safe='')}")
|
||||
@@ -785,8 +822,9 @@ def create_app(
|
||||
target = raw_target if isinstance(raw_target, str) and raw_target else None
|
||||
text = form.get("text")
|
||||
try:
|
||||
mark = write_note(booth, target, text if isinstance(text, str) else "",
|
||||
who=request.client.host if request.client else "")
|
||||
mark = await run_in_threadpool(
|
||||
write_note, booth, target, text if isinstance(text, str) else "",
|
||||
who=request.client.host if request.client else "")
|
||||
except AskError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return _mark_redirect(name, form, f"mark-{quote(mark.id, safe='')}")
|
||||
@@ -806,7 +844,9 @@ def create_app(
|
||||
raise HTTPException(status_code=400, detail="a flag needs a target")
|
||||
on = str(form.get("on", "1")) not in ("0", "", "false", "off")
|
||||
try:
|
||||
set_flag(booth, target, on, who=request.client.host if request.client else "")
|
||||
await run_in_threadpool(
|
||||
set_flag, booth, target, on,
|
||||
who=request.client.host if request.client else "")
|
||||
except AskError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return _mark_redirect(name, form, f"item-{quote(target, safe='')}")
|
||||
@@ -820,7 +860,7 @@ def create_app(
|
||||
mark_id = form.get("mark")
|
||||
if not isinstance(mark_id, str) or not mark_id:
|
||||
raise HTTPException(status_code=400, detail="which mark?")
|
||||
delete_mark(booth, mark_id)
|
||||
await run_in_threadpool(delete_mark, booth, mark_id)
|
||||
return _mark_redirect(name, form, "marks")
|
||||
|
||||
@app.post("/b/{name}/import-asks")
|
||||
@@ -832,7 +872,7 @@ def create_app(
|
||||
migrated from the page you are already looking at.
|
||||
"""
|
||||
booth = resolve_booth(name)
|
||||
import_legacy_asks(booth)
|
||||
await run_in_threadpool(import_legacy_asks, booth)
|
||||
form = await request.form()
|
||||
return _mark_redirect(name, form, "marks")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user