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,
|
Response,
|
||||||
)
|
)
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -169,12 +170,27 @@ def human_dur(seconds: float) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _newest_mtime(path: Path) -> float:
|
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:
|
try:
|
||||||
newest = path.stat().st_mtime
|
newest = path.stat().st_mtime
|
||||||
except OSError:
|
except OSError:
|
||||||
return 0.0
|
return 0.0
|
||||||
for p in path.rglob("*"):
|
for p in path.rglob("*"):
|
||||||
|
if p.name.startswith(".") and p.name.endswith(".lock"):
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
m = p.stat().st_mtime
|
m = p.stat().st_mtime
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -478,6 +494,18 @@ PICKUP_WORDS = (
|
|||||||
).split()
|
).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:
|
def safe_upload_name(name: str, fallback: str) -> str:
|
||||||
"""Reduce a client-supplied filename to a safe basename (no path, no hidden)."""
|
"""Reduce a client-supplied filename to a safe basename (no path, no hidden)."""
|
||||||
base = (name or "").replace("\\", "/").split("/")[-1].strip()
|
base = (name or "").replace("\\", "/").split("/")[-1].strip()
|
||||||
@@ -760,13 +788,22 @@ def create_app(
|
|||||||
if spec.error is not None:
|
if spec.error is not None:
|
||||||
raise HTTPException(status_code=400, detail=spec.error)
|
raise HTTPException(status_code=400, detail=spec.error)
|
||||||
who = request.client.host if request.client else ""
|
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:
|
try:
|
||||||
if spec.multi:
|
if spec.multi:
|
||||||
choice = {q["key"]: form.get(f"choice.{q['key']}") for q in spec.questions}
|
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}
|
qnotes = {q["key"]: _form_text(form, f"notes.{q['key']}")
|
||||||
answer_pick(booth, mark_id, choice, form.get("notes", ""), who=who, qnotes=qnotes)
|
for q in spec.questions}
|
||||||
|
await run_in_threadpool(answer_pick, booth, mark_id, choice, notes,
|
||||||
|
who=who, qnotes=qnotes)
|
||||||
else:
|
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:
|
except AskError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
return _mark_redirect(name, form, f"mark-{quote(mark_id, safe='')}")
|
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
|
target = raw_target if isinstance(raw_target, str) and raw_target else None
|
||||||
text = form.get("text")
|
text = form.get("text")
|
||||||
try:
|
try:
|
||||||
mark = write_note(booth, target, text if isinstance(text, str) else "",
|
mark = await run_in_threadpool(
|
||||||
who=request.client.host if request.client else "")
|
write_note, booth, target, text if isinstance(text, str) else "",
|
||||||
|
who=request.client.host if request.client else "")
|
||||||
except AskError as exc:
|
except AskError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
return _mark_redirect(name, form, f"mark-{quote(mark.id, safe='')}")
|
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")
|
raise HTTPException(status_code=400, detail="a flag needs a target")
|
||||||
on = str(form.get("on", "1")) not in ("0", "", "false", "off")
|
on = str(form.get("on", "1")) not in ("0", "", "false", "off")
|
||||||
try:
|
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:
|
except AskError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
return _mark_redirect(name, form, f"item-{quote(target, safe='')}")
|
return _mark_redirect(name, form, f"item-{quote(target, safe='')}")
|
||||||
@@ -820,7 +860,7 @@ def create_app(
|
|||||||
mark_id = form.get("mark")
|
mark_id = form.get("mark")
|
||||||
if not isinstance(mark_id, str) or not mark_id:
|
if not isinstance(mark_id, str) or not mark_id:
|
||||||
raise HTTPException(status_code=400, detail="which mark?")
|
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")
|
return _mark_redirect(name, form, "marks")
|
||||||
|
|
||||||
@app.post("/b/{name}/import-asks")
|
@app.post("/b/{name}/import-asks")
|
||||||
@@ -832,7 +872,7 @@ def create_app(
|
|||||||
migrated from the page you are already looking at.
|
migrated from the page you are already looking at.
|
||||||
"""
|
"""
|
||||||
booth = resolve_booth(name)
|
booth = resolve_booth(name)
|
||||||
import_legacy_asks(booth)
|
await run_in_threadpool(import_legacy_asks, booth)
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
return _mark_redirect(name, form, "marks")
|
return _mark_redirect(name, form, "marks")
|
||||||
|
|
||||||
|
|||||||
+95
-11
@@ -218,6 +218,24 @@ def _read_raw_strict(booth: Path) -> list[dict]:
|
|||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def read_error(booth: Path) -> str | None:
|
||||||
|
"""Why this booth's marks cannot be read, or None if they can.
|
||||||
|
|
||||||
|
`marks_for` is lenient on purpose — a review page that will not load is
|
||||||
|
worse than one missing an annotation — and that leniency turns an
|
||||||
|
unreadable file into "no marks". For a BROWSER that is the right trade. For
|
||||||
|
the CLI it is not: a session that asked a question and is told "no such
|
||||||
|
pick" will conclude the question was never posted, when in fact the file
|
||||||
|
holding it is damaged. A machine consumer can act on the difference, so it
|
||||||
|
gets to ask.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_read_raw_strict(booth)
|
||||||
|
except MarksCorrupt as exc:
|
||||||
|
return str(exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _write_raw(booth: Path, entries: list[dict]) -> None:
|
def _write_raw(booth: Path, entries: list[dict]) -> None:
|
||||||
"""Atomic replace, so a reader never sees a half-written document and a
|
"""Atomic replace, so a reader never sees a half-written document and a
|
||||||
crash mid-write cannot truncate the file into a shorter — and therefore
|
crash mid-write cannot truncate the file into a shorter — and therefore
|
||||||
@@ -249,12 +267,20 @@ class _Locked:
|
|||||||
self.booth.mkdir(parents=True, exist_ok=True)
|
self.booth.mkdir(parents=True, exist_ok=True)
|
||||||
lock = self.booth / MARKS_LOCK
|
lock = self.booth / MARKS_LOCK
|
||||||
# `touch(exist_ok=True)` on an EXISTING file bumps its mtime, and a
|
# `touch(exist_ok=True)` on an EXISTING file bumps its mtime, and a
|
||||||
# booth's TTL is measured from its newest mtime including dotfiles — so
|
# booth's TTL is measured from its newest mtime — so an unconditional
|
||||||
# an unconditional touch would keep a booth alive just for being read
|
# touch would keep a booth alive just for being read through a write
|
||||||
# through a write path. Create it only when it is not there.
|
# path. Create it only when it is not there.
|
||||||
|
#
|
||||||
|
# ONCE CREATED, THE LOCK FILE IS NEVER REMOVED (see __exit__).
|
||||||
if not lock.exists():
|
if not lock.exists():
|
||||||
|
# Creating a directory entry bumps the DIRECTORY's mtime, which is
|
||||||
|
# what `_newest_mtime` seeds from — so making our own lock file
|
||||||
|
# would itself read as activity. Put the clock back: the lock is
|
||||||
|
# machinery, and machinery is not the operator touching the booth.
|
||||||
|
before = self.booth.stat()
|
||||||
lock.touch()
|
lock.touch()
|
||||||
self._made_lock = True
|
self._made_lock = True
|
||||||
|
os.utime(self.booth, (before.st_atime, before.st_mtime))
|
||||||
self._lf = lock.open("r+")
|
self._lf = lock.open("r+")
|
||||||
fcntl.flock(self._lf, fcntl.LOCK_EX)
|
fcntl.flock(self._lf, fcntl.LOCK_EX)
|
||||||
try:
|
try:
|
||||||
@@ -264,8 +290,6 @@ class _Locked:
|
|||||||
fcntl.flock(self._lf, fcntl.LOCK_UN)
|
fcntl.flock(self._lf, fcntl.LOCK_UN)
|
||||||
self._lf.close()
|
self._lf.close()
|
||||||
self._lf = None
|
self._lf = None
|
||||||
if self._made_lock:
|
|
||||||
lock.unlink(missing_ok=True)
|
|
||||||
raise
|
raise
|
||||||
self._before = _fingerprint(self.entries)
|
self._before = _fingerprint(self.entries)
|
||||||
return self
|
return self
|
||||||
@@ -284,10 +308,17 @@ class _Locked:
|
|||||||
# would otherwise keep a dead booth alive forever.
|
# would otherwise keep a dead booth alive forever.
|
||||||
if exc_type is None and _fingerprint(self.entries) != self._before:
|
if exc_type is None and _fingerprint(self.entries) != self._before:
|
||||||
_write_raw(self.booth, self.entries)
|
_write_raw(self.booth, self.entries)
|
||||||
elif self._made_lock and not (self.booth / MARKS_FILE).exists():
|
# THE LOCK FILE IS NEVER UNLINKED. It used to be, on the no-op path,
|
||||||
# Nothing was written and this booth had no marks before: do not
|
# so a booth that had never been marked was left exactly as it was
|
||||||
# leave a lock file behind as the only trace of a no-op.
|
# found. That tidiness cost mutual exclusion outright: `flock` binds
|
||||||
(self.booth / MARKS_LOCK).unlink(missing_ok=True)
|
# to an INODE, so unlinking the lock while a second writer is blocked
|
||||||
|
# on it leaves that writer holding an exclusive lock on a deleted
|
||||||
|
# file, and the NEXT writer creates a fresh lock and takes it at
|
||||||
|
# once. Two processes then run the read-modify-write concurrently,
|
||||||
|
# the later `os.replace` drops the earlier one's mark, and both of
|
||||||
|
# them obeyed the protocol. A zero-byte dotfile is the cheaper
|
||||||
|
# thing to leave behind — `booth_items` skips it, the zip skips it,
|
||||||
|
# and `_newest_mtime` exempts it so it cannot hold a booth open.
|
||||||
finally:
|
finally:
|
||||||
fcntl.flock(lf, fcntl.LOCK_UN)
|
fcntl.flock(lf, fcntl.LOCK_UN)
|
||||||
lf.close()
|
lf.close()
|
||||||
@@ -301,6 +332,25 @@ class _Locked:
|
|||||||
# ---- read -------------------------------------------------------------------
|
# ---- read -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_type_error(entry: dict) -> str | None:
|
||||||
|
"""The stored scalars this module refuses to guess at.
|
||||||
|
|
||||||
|
`_clean_text` did `(text or "").replace(...)` and `marks_for` sorts on
|
||||||
|
`(created, id)` — so a stored `text` that is a dict, or a `created` that is a
|
||||||
|
number, raised AttributeError or TypeError out of the READ path. That is not
|
||||||
|
a marks bug, it is an INDEX bug: `list_booths` reads every booth's marks on
|
||||||
|
every page load and `/healthz` does the same, so one hand-edited or
|
||||||
|
foreign-written file took down the front page for every booth on the
|
||||||
|
service. A wrong type is a broken mark, and this module already knows how to
|
||||||
|
render one of those.
|
||||||
|
"""
|
||||||
|
for name in ("created", "by", "text", "error"):
|
||||||
|
value = entry.get(name)
|
||||||
|
if value is not None and not isinstance(value, str):
|
||||||
|
return f"{name} is {type(value).__name__}, not a string"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _hydrate(entry: dict) -> Mark:
|
def _hydrate(entry: dict) -> Mark:
|
||||||
"""One stored entry -> one Mark, declarations normalized.
|
"""One stored entry -> one Mark, declarations normalized.
|
||||||
|
|
||||||
@@ -312,6 +362,15 @@ def _hydrate(entry: dict) -> Mark:
|
|||||||
"""
|
"""
|
||||||
mid = entry["id"]
|
mid = entry["id"]
|
||||||
shape = entry.get("shape") if entry.get("shape") in SHAPES else NOTE
|
shape = entry.get("shape") if entry.get("shape") in SHAPES else NOTE
|
||||||
|
bad = _entry_type_error(entry)
|
||||||
|
if bad is not None:
|
||||||
|
# `created` is dropped rather than coerced, which sorts the entry to the
|
||||||
|
# TOP of the booth's marks: a mark nobody can read is the one that wants
|
||||||
|
# looking at, and burying it under 270 items' worth of notes is how it
|
||||||
|
# stays unnoticed. Deterministic, and stated — `("", id)` against
|
||||||
|
# `(created, id)`.
|
||||||
|
return Mark(id=mid, shape=shape, target=None, created="",
|
||||||
|
error=f"unreadable mark: {bad}")
|
||||||
target = entry.get("target")
|
target = entry.get("target")
|
||||||
if not _valid_target(target):
|
if not _valid_target(target):
|
||||||
target = None
|
target = None
|
||||||
@@ -359,11 +418,26 @@ def _hydrate(entry: dict) -> Mark:
|
|||||||
return Mark(**base, text=_clean_text(entry.get("text")))
|
return Mark(**base, text=_clean_text(entry.get("text")))
|
||||||
|
|
||||||
|
|
||||||
|
def _hydrate_safe(entry: dict) -> Mark:
|
||||||
|
"""`_hydrate`, with the promise that it cannot raise.
|
||||||
|
|
||||||
|
`_entry_type_error` covers the shapes we know how to name; this is the
|
||||||
|
backstop for the ones we do not, and it exists because of WHERE this runs.
|
||||||
|
One unreadable mark must cost that mark, never the page — and on the index
|
||||||
|
it is not even that booth's page, it is all of them.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _hydrate(entry)
|
||||||
|
except Exception as exc: # noqa: BLE001 - deliberate
|
||||||
|
return Mark(id=str(entry.get("id", "")), shape=NOTE, target=None,
|
||||||
|
created="", error=f"unreadable mark: {exc}")
|
||||||
|
|
||||||
|
|
||||||
def marks_for(booth: Path) -> list[Mark]:
|
def marks_for(booth: Path) -> list[Mark]:
|
||||||
"""Every mark in a booth, oldest first, declarations normalized and answers
|
"""Every mark in a booth, oldest first, declarations normalized and answers
|
||||||
folded in. ONE file read — which is the whole point of the storage shape."""
|
folded in. ONE file read — which is the whole point of the storage shape."""
|
||||||
entries = _read_raw(booth)
|
entries = _read_raw(booth)
|
||||||
marks = [_hydrate(e) for e in entries]
|
marks = [_hydrate_safe(e) for e in entries]
|
||||||
# (created, id) rather than created alone: two marks written in the same
|
# (created, id) rather than created alone: two marks written in the same
|
||||||
# second would otherwise order by however json listed them.
|
# second would otherwise order by however json listed them.
|
||||||
marks.sort(key=lambda m: (m.created, m.id))
|
marks.sort(key=lambda m: (m.created, m.id))
|
||||||
@@ -642,7 +716,17 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
|||||||
"id": stem,
|
"id": stem,
|
||||||
"shape": PICK,
|
"shape": PICK,
|
||||||
"target": None,
|
"target": None,
|
||||||
"created": datetime.fromtimestamp(mtime).astimezone().isoformat(timespec="seconds"),
|
# MICROSECONDS, not seconds. `found` is ordered by fractional
|
||||||
|
# mtime and `marks_for` re-sorts on this string, so truncating
|
||||||
|
# to the whole second threw away the only thing distinguishing
|
||||||
|
# two sidecars written in the same second — and the `(created,
|
||||||
|
# id)` tie-break then silently re-sorted them alphabetically,
|
||||||
|
# reversing the order the importer had just established. The
|
||||||
|
# ROADMAP states this import's order is `(mtime, name)`; an
|
||||||
|
# order that is stated and not kept is worse than one never
|
||||||
|
# claimed.
|
||||||
|
"created": datetime.fromtimestamp(mtime).astimezone().isoformat(
|
||||||
|
timespec="microseconds"),
|
||||||
"declaration": decl,
|
"declaration": decl,
|
||||||
"answer": answer,
|
"answer": answer,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,35 @@
|
|||||||
Works with JS off — plain form POST, every shape. An answered pick shows the
|
Works with JS off — plain form POST, every shape. An answered pick shows the
|
||||||
recorded judgment and a collapsed "change" form, because the mark is the
|
recorded judgment and a collapsed "change" form, because the mark is the
|
||||||
CURRENT judgment and not a log. #}
|
CURRENT judgment and not a log. #}
|
||||||
|
{# A mark carrying `error` is sorted out FIRST, whatever shape it claims. A
|
||||||
|
pick keeps its own ⚠ broken rendering below (richer — it has a declaration to
|
||||||
|
show); a broken note would otherwise render as an empty <pre> with a withdraw
|
||||||
|
button, indistinguishable from a note the operator wrote and then cleared,
|
||||||
|
and a broken flag would link to a target that is not there. Unreadable state
|
||||||
|
is visible state — the rule `_hydrate` states for picks, applied to all
|
||||||
|
three. #}
|
||||||
|
{% set broken = marks | selectattr('error') | rejectattr('shape', 'equalto', 'pick') | list %}
|
||||||
{% set picks = marks | selectattr('shape', 'equalto', 'pick') | list %}
|
{% set picks = marks | selectattr('shape', 'equalto', 'pick') | list %}
|
||||||
{% set notes = marks | selectattr('shape', 'equalto', 'note') | list %}
|
{% set notes = marks | selectattr('shape', 'equalto', 'note') | rejectattr('error') | list %}
|
||||||
{% set flags = marks | selectattr('shape', 'equalto', 'flag') | list %}
|
{% set flags = marks | selectattr('shape', 'equalto', 'flag') | rejectattr('error') | list %}
|
||||||
<section class="marks">
|
<section class="marks">
|
||||||
|
|
||||||
|
{% for a in broken %}
|
||||||
|
<article class="mark mark-note is-broken" id="mark-{{ a.id }}">
|
||||||
|
<header class="mark-head">
|
||||||
|
<span class="mark-state">⚠ broken</span>
|
||||||
|
<span class="mark-id"><code>{{ a.id }}</code></span>
|
||||||
|
<span class="board-spacer"></span>
|
||||||
|
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark">
|
||||||
|
<input type="hidden" name="mark" value="{{ a.id }}">
|
||||||
|
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||||||
|
<button type="submit" class="mark-x" title="withdraw this mark">×</button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
<p class="mark-error">This mark could not be read: {{ a.error }}</p>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{% for a in picks %}
|
{% for a in picks %}
|
||||||
<article class="mark mark-pick{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="mark-{{ a.id }}">
|
<article class="mark mark-pick{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="mark-{{ a.id }}">
|
||||||
<header class="mark-head">
|
<header class="mark-head">
|
||||||
|
|||||||
@@ -94,7 +94,11 @@
|
|||||||
back to the flagged items. Always rendered on a gallery booth — the add-note
|
back to the flagged items. Always rendered on a gallery booth — the add-note
|
||||||
field is a control, not a result, so it has to be there before the first
|
field is a control, not a result, so it has to be there before the first
|
||||||
mark exists. #}
|
mark exists. #}
|
||||||
{% if not board %}
|
{# `marks or not board`: the standing link board renders as a board rather than
|
||||||
|
a gallery, and the add-note control would be noise on it — but the
|
||||||
|
suppression was unconditional, so a pick declared on a booth that happens to
|
||||||
|
carry a links.md had no form to answer it and nothing said so. #}
|
||||||
|
{% if marks or not board %}
|
||||||
{% include "_marks.html" %}
|
{% include "_marks.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -187,6 +191,12 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<pre class="textview doc-body">{{ it.rendered }}</pre>
|
<pre class="textview doc-body">{{ it.rendered }}</pre>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{# The doc branch had `markcontrols` and not `marknotes`, so the
|
||||||
|
operator could point at a report and not write down why — on the
|
||||||
|
one item kind whose whole content is prose. Exactly the
|
||||||
|
"patched two of three" failure the blurtoggle macro above was
|
||||||
|
written to prevent, recurring on the macro written to prevent it. #}
|
||||||
|
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
|
||||||
</details>
|
</details>
|
||||||
</figure>
|
</figure>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -33,8 +33,18 @@
|
|||||||
white-space:pre-wrap}
|
white-space:pre-wrap}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('keydown', function (e) {
|
(function () {
|
||||||
if (e.key === 'Escape') window.location.href = {{ ('/b/' ~ name_url ~ '/')|tojson }};
|
/* Escape leaves the page, so it must not fire from inside a field someone
|
||||||
});
|
is typing in — the same guard the image viewer carries, stated in both
|
||||||
|
places because the handler is on `document` in both. */
|
||||||
|
function isEditable(el) {
|
||||||
|
return !!(el && (el.isContentEditable ||
|
||||||
|
/^(input|textarea|select)$/i.test(el.tagName || '')));
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (isEditable(e.target)) return;
|
||||||
|
if (e.key === 'Escape') window.location.href = {{ ('/b/' ~ name_url ~ '/')|tojson }};
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -99,7 +99,17 @@
|
|||||||
img.addEventListener('load', evaluate);
|
img.addEventListener('load', evaluate);
|
||||||
window.addEventListener('resize', evaluate);
|
window.addEventListener('resize', evaluate);
|
||||||
if (img.complete) evaluate();
|
if (img.complete) evaluate();
|
||||||
|
|
||||||
|
/* An arrow key inside the note field is a CARET move, not a navigation.
|
||||||
|
The handler is on `document` and the note textarea shipped into this same
|
||||||
|
page, so typing a note and reaching for ← threw the draft away; Escape
|
||||||
|
did it in one keystroke. Anything editable keeps its own keys. */
|
||||||
|
function isEditable(el) {
|
||||||
|
return !!(el && (el.isContentEditable ||
|
||||||
|
/^(input|textarea|select)$/i.test(el.tagName || '')));
|
||||||
|
}
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (isEditable(e.target)) return;
|
||||||
if (e.key === 'Escape') window.location.href = BACK;
|
if (e.key === 'Escape') window.location.href = BACK;
|
||||||
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
|
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
|
||||||
else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT;
|
else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT;
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# The U2 bug-hunt panel — full triage
|
||||||
|
|
||||||
|
**Date:** 2026-09-22 · **Thread:** `01M33XEC1H0298C0D968FWBN7A` ·
|
||||||
|
**Reply:** `01M33YZZ1VYGZ04JGNXNTBXDKS` · **Shipped as:** `v0.2.2`
|
||||||
|
|
||||||
|
`/heid-bug-hunt` on U2's diff (+2251/−632, 20 sections, 18 post-change
|
||||||
|
snapshots). Four arms — Gróa (Grok), Hulda (Codex), Regin (GLM-5.2), Kimi
|
||||||
|
(kimi-k3) — artifact-only, 4/4 clean transport. Heid adjudicated **9 findings
|
||||||
|
(6 bug / 3 robustness)**. Staleness was disclosed at build: `app.py` was edited
|
||||||
|
after the 06:38:52Z capture.
|
||||||
|
|
||||||
|
## Triage, five-category
|
||||||
|
|
||||||
|
### Category 1 — genuine add (8 taken, all shipped)
|
||||||
|
|
||||||
|
| # | finding | where | why it was real |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Lock-inode split on the no-op unlink (**4/4 convergent**) | `marks._Locked` | `flock` binds to an inode; unlinking under a waiter destroys mutual exclusion silently |
|
||||||
|
| 2 | No-op lock churn resets the TTL via **directory** mtime | `marks._Locked` + `app._newest_mtime` | the guard's own comment reasons about the lock FILE's mtime; the directory is what the sweeper reads |
|
||||||
|
| 3 | Non-string `text` / `created` raise out of the read path | `marks._clean_text`, `marks_for` sort | `list_booths` reads every booth per page load → one bad file 500s `/` and `/healthz` |
|
||||||
|
| 4 | Legacy import stamped `created` at whole-second resolution | `marks.import_legacy_asks` | same-second sidecars re-sorted alphabetically, reversing the order the importer had just set — violates the stated `(mtime, name)` rule |
|
||||||
|
| 5 | `/answer` 500s on a non-string `notes` form value | `app.booth_answer` | the sibling `/note` guards it; same parser, same class of value, two answers |
|
||||||
|
| 6 | All five mark-write routes hold a blocking `flock` on the event loop | `app.py` | a contended lock freezes every route, not just the one request |
|
||||||
|
| 7 | CLI conflates a reader crash with "open" / "unanswered" | `scripts/booth` | `marks` printed a traceback and exited 0; `answer --wait` spun the full hour on a damaged file |
|
||||||
|
| 8 | The inline-doc tile had `markcontrols` and not `marknotes` | `booth.html` | flag a report, cannot say why — on the one item kind that is prose |
|
||||||
|
|
||||||
|
Two more taken on the same sweep, found while fixing the above rather than by
|
||||||
|
the panel: a broken mark of any shape now renders **⚠ broken** instead of as an
|
||||||
|
empty note (the rule `_hydrate` states for picks, applied to all three shapes),
|
||||||
|
and the marks panel is no longer suppressed on a booth that carries a
|
||||||
|
`links.md` *and* has marks.
|
||||||
|
|
||||||
|
### Category 3 — restatement of a settled prior (1, no change)
|
||||||
|
|
||||||
|
**Corrupt read → filtered writeback → silent deletion** (hulda F2, kimi F3,
|
||||||
|
gróa F4; Heid ranked it #3). **Already fixed in `v0.2.1`** by
|
||||||
|
`_read_raw_strict` + `MarksCorrupt` — reads lenient, writes strict. The panel
|
||||||
|
reviewed the pre-fix capture and the staleness was disclosed up front. Verified
|
||||||
|
against the current source before declining, not assumed.
|
||||||
|
|
||||||
|
This is the exact case the cross-frontier triage discipline warns about: a
|
||||||
|
confident, well-argued, four-arm-corroborated finding against code that no
|
||||||
|
longer exists. **Check what the peer actually read before treating an omission
|
||||||
|
or a defect claim as new.**
|
||||||
|
|
||||||
|
### Category 4 — out of place, parked (2)
|
||||||
|
|
||||||
|
- **Note-id recycling** (`note-1` reused after a withdrawal) lets a stale tab
|
||||||
|
delete a newer note. Real mechanism; needs two tabs and an interleaving, and
|
||||||
|
the Booth has one viewer. Non-reused ids are a schema change, not a patch.
|
||||||
|
- **Unvalidated flag / note targets** accumulate orphan marks. Targets come
|
||||||
|
from rendered items; the operator is the only writer through the browser.
|
||||||
|
|
||||||
|
### Category 5 — wrong-grounding (1)
|
||||||
|
|
||||||
|
**`delete_mark` can remove a pick, not only a note.** Framed as an
|
||||||
|
access-control divergence. There is no auth by design, and restricting it would
|
||||||
|
remove the only way to withdraw a pick that hydrates broken. Declined; the
|
||||||
|
docstring is the thing that was imprecise, not the behaviour.
|
||||||
|
|
||||||
|
## What the round is worth remembering for
|
||||||
|
|
||||||
|
1. **The two review gates stayed complementary a second time.** The contract
|
||||||
|
panel (2026-09-21) found three defects; this bug-hunt found eight more, with
|
||||||
|
**no overlap**. Both ran on the same unit. Neither substitutes.
|
||||||
|
2. **The panel beat the code's own comments three times.** The bundle's comments
|
||||||
|
are unusually honest and still wrong about what protected the TTL, and
|
||||||
|
"written atomically" sat next to a filter-then-replace. **A comment is a
|
||||||
|
claim, and a claim can be tested.**
|
||||||
|
3. **The headline bug class shipped with zero guard coverage, and both mutation
|
||||||
|
tables said so.** `test_a_no_op_write_does_not_touch_the_booth` asserted only
|
||||||
|
that `.marks.json` was absent — so removing the lock unlink, removing the
|
||||||
|
whole lock lifecycle, or bumping the directory clock all **SURVIVED** it. The
|
||||||
|
test asserted an artifact of the property instead of the property. The
|
||||||
|
replacement asserts `booth_age_seconds` directly, with a positive control (a
|
||||||
|
real mark still resets the clock) so the fix cannot overshoot into "marking
|
||||||
|
is never activity".
|
||||||
|
4. **`scripts/booth` had no tests at all** and two findings lived there. It has
|
||||||
|
five now, running the real script under the system `python3`.
|
||||||
+53
-9
@@ -1,6 +1,6 @@
|
|||||||
# Persistent memory — booth
|
# Persistent memory — booth
|
||||||
|
|
||||||
_Last updated: 2026-09-21_
|
_Last updated: 2026-09-22_
|
||||||
|
|
||||||
> **Always check for `/tmp/booth-dev-handoff.md`** — if it exists and its
|
> **Always check for `/tmp/booth-dev-handoff.md`** — if it exists and its
|
||||||
> `Written:` stamp is under 8 hours old, read it (it carries the in-flight
|
> `Written:` stamp is under 8 hours old, read it (it carries the in-flight
|
||||||
@@ -17,13 +17,15 @@ loop it turned out to actually be.
|
|||||||
|
|
||||||
## Current state / in-flight
|
## Current state / in-flight
|
||||||
|
|
||||||
_As of 2026-09-21 (late):_
|
_As of 2026-09-22:_
|
||||||
|
|
||||||
- **v1 is gated on seven units** in `ROADMAP.md`, dependency-ordered
|
- **v1 is gated on seven units** in `ROADMAP.md`, dependency-ordered
|
||||||
**U1 → U2 → {U3, U4, U5} → U7**, with **U6 independent**.
|
**U1 → U2 → {U3, U4, U5} → U7**, with **U6 independent**.
|
||||||
- **U1 and U2 are landed and released.** Current version `0.2.1`, deployed to the
|
- **U1 and U2 are landed and released.** Current version `0.2.2`, deployed to the
|
||||||
live service, 253 tests green, tree clean. U1 `ce598b3`; U2 `c7f9437` released as
|
live service, 275 tests green, tree clean, 25/25 booth pages verified 200 after
|
||||||
`v0.2.0`, then `5e41108` as `v0.2.1` carrying four cross-frontier panel findings.
|
the deploy. U1 `ce598b3`; U2 `c7f9437` released as `v0.2.0`, then `5e41108` as
|
||||||
|
`v0.2.1` (four contract-panel findings), then `v0.2.2` carrying the
|
||||||
|
**bug-hunt panel's** nine (below).
|
||||||
- **U5 is the next unit** (operator, 2026-09-21): **self-announcing booths.**
|
- **U5 is the next unit** (operator, 2026-09-21): **self-announcing booths.**
|
||||||
`.booth.json` carrying `{handle, title, why, created}`, written by the CLI from
|
`.booth.json` carrying `{handle, title, why, created}`, written by the CLI from
|
||||||
`$ALTHING_HANDLE`; the index card gains provenance and a one-line purpose, and
|
`$ALTHING_HANDLE`; the index card gains provenance and a one-line purpose, and
|
||||||
@@ -38,16 +40,58 @@ _As of 2026-09-21 (late):_
|
|||||||
and that rule must stay stated. Also worth knowing before scoping: enforcing the
|
and that rule must stay stated. Also worth knowing before scoping: enforcing the
|
||||||
link rule without giving job 5 a home first just makes it homeless — that is the
|
link rule without giving job 5 a home first just makes it homeless — that is the
|
||||||
lesson from the 69% rot, and U5 is the home.
|
lesson from the 69% rot, and U5 is the home.
|
||||||
- **One heid dispatch is still outstanding**: the `/heid-bug-hunt` on U2's diff,
|
- **No heid dispatch is outstanding.** The `/heid-bug-hunt` on U2's diff landed
|
||||||
msg `01M33XEC1H0298C0D968FWBN7A`, thread same. Triage it the same five-category
|
2026-09-22 and shipped as `v0.2.2`; see the dated entry below.
|
||||||
way when it lands. **Do not assume it is ceremony** — the contract-review panel
|
|
||||||
that preceded it found three real defects in already-released code.
|
|
||||||
- Live service `active` on `:8090`, 25 booths, verified 25 × 3 page types after the
|
- Live service `active` on `:8090`, 25 booths, verified 25 × 3 page types after the
|
||||||
last deploy. The booth set churns: `sindra20-engines` and `sindra-finalists` were
|
last deploy. The booth set churns: `sindra20-engines` and `sindra-finalists` were
|
||||||
swept during the session, `cr123a-to-d-sleeve` and `sindra` appeared.
|
swept during the session, `cr123a-to-d-sleeve` and `sindra` appeared.
|
||||||
|
|
||||||
## Recent decisions
|
## Recent decisions
|
||||||
|
|
||||||
|
- `[2026-09-22]` **The U2 bug-hunt panel landed and it was not ceremony —
|
||||||
|
`v0.2.2`.** Nine adopted findings across four arms; eight were real against
|
||||||
|
live code and one was already fixed. The headline was **4/4 convergent from
|
||||||
|
four different angles**: `_Locked.__exit__` unlinked `.marks.lock` on the no-op
|
||||||
|
path, and `flock` binds to an INODE — so a writer blocked on the old inode
|
||||||
|
proceeds while the next writer creates a fresh lock file and takes it at once.
|
||||||
|
Two processes then run the read-modify-write concurrently and the later
|
||||||
|
`os.replace` drops a mark, with both of them obeying the protocol. **The
|
||||||
|
cleanup existed to protect the booth's TTL and it was failing at that too**:
|
||||||
|
creating and removing a directory entry bumps the DIRECTORY's mtime, which is
|
||||||
|
what `_newest_mtime` actually seeds from, so a no-op reset the clock it was
|
||||||
|
written to leave alone. Same code region, two defects, one fix — never unlink
|
||||||
|
the lock, exempt `.<name>.lock` dotfiles from `_newest_mtime`, and put the
|
||||||
|
directory's mtime back after creating one. Full triage in
|
||||||
|
`persistent-memory.d/2026-09-22-bug-hunt-panel.md`.
|
||||||
|
- `[2026-09-22]` **The lenient reader's blast radius was the whole service, not
|
||||||
|
one booth.** `_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 — and `list_booths` reads every
|
||||||
|
booth's marks on every index load. One hand-edited file 500'd `/` and
|
||||||
|
`/healthz` for all 25 booths. Fixed in two layers, matching the house posture:
|
||||||
|
a named type check (`_entry_type_error`) plus a `_hydrate_safe` backstop that
|
||||||
|
cannot raise, and the panel now RENDERS an unreadable mark as ⚠ broken instead
|
||||||
|
of as an empty note. **The general shape: a lenient reader is only lenient if
|
||||||
|
the leniency is bounded by where it runs.** `marks_for` was written for one
|
||||||
|
booth's page and is called in a loop over every booth.
|
||||||
|
- `[2026-09-22]` **`booth marks` / `booth answer` got real exit codes**, because
|
||||||
|
a read that CRASHED was indistinguishable from a read that said no. `marks`
|
||||||
|
printed a traceback and exited 0 (a caller's `jq` saw success and got
|
||||||
|
nothing); `answer --wait` read a damaged file as "not yet" and spun for the
|
||||||
|
full hour before blaming the operator. Now `0 ok · 1 unanswered/timed-out ·
|
||||||
|
2 no such pick · 3 unreadable`, and `read_error()` was added to `marks.py` so
|
||||||
|
the CLI can ask the question the browser must not: the page stays lenient, the
|
||||||
|
machine consumer gets the truth. Also `--wait` now prints ONCE — it was
|
||||||
|
emitting a whole JSON document per poll, so a captured `--wait` held several
|
||||||
|
concatenated values and parsed as none of them.
|
||||||
|
- `[2026-09-22]` **`scripts/booth` had zero tests and now has five**
|
||||||
|
(`tests/test_cli.py`). The panel's guard-strength tables returned UNVERIFIED
|
||||||
|
for every CLI claim because nothing in the suite executed the script — two of
|
||||||
|
the round's findings lived in exactly that gap. The new tests run the real
|
||||||
|
script under the system `python3`, which makes them a live check on INV-1
|
||||||
|
(stdlib-only) as a side effect: a third-party import in `marks.py` now fails
|
||||||
|
in the suite the same way it would fail on a fleet host.
|
||||||
|
|
||||||
- `[2026-09-21]` **v0.2.0 cut and announced; v0.2.1 fixed what the announcement
|
- `[2026-09-21]` **v0.2.0 cut and announced; v0.2.1 fixed what the announcement
|
||||||
was already wrong about.** Operator approved the minor (a v1 unit closed plus a
|
was already wrong about.** Operator approved the minor (a v1 unit closed plus a
|
||||||
CLI surface change for 17 consuming handles clears the release-note bar). The
|
CLI surface change for 17 consuming handles clears the release-note bar). The
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "booth"
|
name = "booth"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
|
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
+74
-17
@@ -22,6 +22,13 @@
|
|||||||
# booth answer <name> <id> [--wait [SECS]]
|
# booth answer <name> <id> [--wait [SECS]]
|
||||||
# print ONE pick's answer (exit 1 if unanswered);
|
# print ONE pick's answer (exit 1 if unanswered);
|
||||||
# --wait polls until it lands (default 3600 s)
|
# --wait polls until it lands (default 3600 s)
|
||||||
|
#
|
||||||
|
# EXIT CODES for the two reading verbs. A read that FAILED gets its own code so
|
||||||
|
# a caller can tell "not yet" from "the file is damaged" — conflating them is
|
||||||
|
# how a broken `.marks.json` used to look like an unanswered question and wait
|
||||||
|
# out the full hour.
|
||||||
|
# marks 0 read ok · 1 --wait timed out with picks open · 3 unreadable
|
||||||
|
# answer 0 answered · 1 unanswered · 2 no such pick · 3 unreadable
|
||||||
# booth marks-import <name> import legacy *.ask.json into .marks.json
|
# booth marks-import <name> import legacy *.ask.json into .marks.json
|
||||||
# booth asks <name> alias for `marks` (deprecated)
|
# booth asks <name> alias for `marks` (deprecated)
|
||||||
#
|
#
|
||||||
@@ -270,6 +277,15 @@ except MarksCorrupt as exc:
|
|||||||
;;
|
;;
|
||||||
marks|asks)
|
marks|asks)
|
||||||
# booth marks <name> [--wait [SECS]] (`asks` is the deprecated alias)
|
# booth marks <name> [--wait [SECS]] (`asks` is the deprecated alias)
|
||||||
|
#
|
||||||
|
# EXIT CODES. 0 = the read succeeded and the document is on stdout; 1 =
|
||||||
|
# --wait gave up with picks still open (the document is still printed); 3 =
|
||||||
|
# the marks could not be read at all. A reader that CRASHED must never look
|
||||||
|
# like an answer — the old shape printed a traceback and exited 0, so a
|
||||||
|
# caller piping to `jq` saw success and got nothing.
|
||||||
|
#
|
||||||
|
# Whether anything is still open is in the payload's `open` list. The read
|
||||||
|
# verb does not encode it in its status: a successful read is a success.
|
||||||
[ $# -ge 1 ] || usage
|
[ $# -ge 1 ] || usage
|
||||||
name="$1"; shift
|
name="$1"; shift
|
||||||
wait_s=0
|
wait_s=0
|
||||||
@@ -278,19 +294,41 @@ except MarksCorrupt as exc:
|
|||||||
# os.replace, and a 2 s cadence is plenty for a human clicking a radio.
|
# os.replace, and a 2 s cadence is plenty for a human clicking a radio.
|
||||||
deadline=$(( $(date +%s) + wait_s ))
|
deadline=$(( $(date +%s) + wait_s ))
|
||||||
while :; do
|
while :; do
|
||||||
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
# CAPTURED, not streamed. Printing inside the loop wrote one whole JSON
|
||||||
|
# document per poll, so `booth marks b --wait | jq` got several values
|
||||||
|
# concatenated and could parse none of them. The wait is a wait; the
|
||||||
|
# print is the result, and it happens once.
|
||||||
|
rc=0
|
||||||
|
out="$(BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
||||||
import json, os, pathlib, sys
|
import json, os, pathlib, sys
|
||||||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||||||
from booth.marks import as_dict, marks_for, open_marks
|
try:
|
||||||
marks = marks_for(pathlib.Path(sys.argv[1]))
|
from booth.marks import as_dict, marks_for, open_marks, read_error
|
||||||
print(json.dumps({"marks": [as_dict(m) for m in marks],
|
booth = pathlib.Path(sys.argv[1])
|
||||||
"open": [m.id for m in open_marks(marks)]},
|
# Ask FIRST whether the file is readable. `marks_for` answers "no marks"
|
||||||
ensure_ascii=False, indent=2))
|
# for a damaged file, which is the right answer for a page and the wrong
|
||||||
sys.exit(1 if open_marks(marks) else 0)
|
# one for a session that wants to know whether its question survived.
|
||||||
' "$DATA/$name" && exit 0
|
broken = read_error(booth)
|
||||||
# exit 1 from the reader means at least one pick is still open
|
if broken:
|
||||||
if [ "$wait_s" -eq 0 ]; then exit 0; fi
|
print(f"booth: {broken}", file=sys.stderr)
|
||||||
|
sys.exit(3)
|
||||||
|
marks = marks_for(booth)
|
||||||
|
doc = json.dumps({"marks": [as_dict(m) for m in marks],
|
||||||
|
"open": [m.id for m in open_marks(marks)]},
|
||||||
|
ensure_ascii=False, indent=2)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"booth: cannot read marks: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(3)
|
||||||
|
print(doc)
|
||||||
|
sys.exit(2 if open_marks(marks) else 0)
|
||||||
|
' "$DATA/$name")" || rc=$?
|
||||||
|
case "$rc" in
|
||||||
|
0) printf '%s\n' "$out"; exit 0 ;; # read ok, nothing open
|
||||||
|
2) if [ "$wait_s" -eq 0 ]; then printf '%s\n' "$out"; exit 0; fi ;;
|
||||||
|
*) echo "cannot read marks in $name" >&2; exit 3 ;;
|
||||||
|
esac
|
||||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||||
|
printf '%s\n' "$out"
|
||||||
echo "timed out after ${wait_s}s with marks still open in $name" >&2; exit 1
|
echo "timed out after ${wait_s}s with marks still open in $name" >&2; exit 1
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -304,20 +342,39 @@ sys.exit(1 if open_marks(marks) else 0)
|
|||||||
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
|
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
|
||||||
deadline=$(( $(date +%s) + wait_s ))
|
deadline=$(( $(date +%s) + wait_s ))
|
||||||
while :; do
|
while :; do
|
||||||
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
rc=0
|
||||||
|
out="$(BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
||||||
import json, os, pathlib, sys
|
import json, os, pathlib, sys
|
||||||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||||||
from booth.marks import marks_for
|
try:
|
||||||
booth, mid = sys.argv[1:3]
|
from booth.marks import marks_for, read_error
|
||||||
m = next((x for x in marks_for(pathlib.Path(booth)) if x.id == mid), None)
|
booth, mid = sys.argv[1:3]
|
||||||
|
broken = read_error(pathlib.Path(booth))
|
||||||
|
if broken:
|
||||||
|
print(f"booth: {broken}", file=sys.stderr)
|
||||||
|
sys.exit(3)
|
||||||
|
# id AND shape, matching the web route. Matching on id alone reported a
|
||||||
|
# note id as "unanswered" and then polled it for an hour — a question that
|
||||||
|
# could never be answered because it was never a question.
|
||||||
|
m = next((x for x in marks_for(pathlib.Path(booth))
|
||||||
|
if x.id == mid and x.shape == "pick"), None)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"booth: cannot read marks: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(3)
|
||||||
if m is None:
|
if m is None:
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
if m.answer is None:
|
if m.answer is None:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
print(json.dumps(m.answer, ensure_ascii=False, indent=2))
|
print(json.dumps(m.answer, ensure_ascii=False, indent=2))
|
||||||
' "$DATA/$name" "$mid" && exit 0
|
' "$DATA/$name" "$mid")" || rc=$?
|
||||||
rc=$?
|
case "$rc" in
|
||||||
if [ "$rc" -eq 2 ]; then echo "no such pick: $name/$mid" >&2; exit 1; fi
|
0) printf '%s\n' "$out"; exit 0 ;;
|
||||||
|
2) echo "no such pick: $name/$mid" >&2; exit 2 ;;
|
||||||
|
# A read that FAILED is not "not yet". Conflating them sent --wait
|
||||||
|
# spinning for the full hour on a broken file and then blamed the
|
||||||
|
# operator for not answering.
|
||||||
|
3) echo "cannot read marks in $name" >&2; exit 3 ;;
|
||||||
|
esac
|
||||||
if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#mark-$mid" >&2; exit 1; fi
|
if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#mark-$mid" >&2; exit 1; fi
|
||||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||||
echo "timed out after ${wait_s}s waiting on $name/$mid" >&2; exit 1
|
echo "timed out after ${wait_s}s waiting on $name/$mid" >&2; exit 1
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""`scripts/booth` — the surface every fleet session actually calls.
|
||||||
|
|
||||||
|
It had no tests at all, which the 2026-09-22 bug-hunt panel found the hard way:
|
||||||
|
its guard-strength table returned UNVERIFIED for every CLI claim because nothing
|
||||||
|
in the suite executes the script. Two of that round's findings live in here.
|
||||||
|
|
||||||
|
These run the real script under the real system `python3` with no venv, which
|
||||||
|
also makes them a live check on INV-1 (stdlib-only): a third-party import in
|
||||||
|
`marks.py` fails here the same way it fails on a fleet host.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
SCRIPT = pathlib.Path(__file__).parent.parent / "scripts" / "booth"
|
||||||
|
|
||||||
|
# Exit codes the verbs promise. 0 is a successful read; a reader that CRASHED
|
||||||
|
# must never be one of the meaningful codes, or a caller cannot tell "no" from
|
||||||
|
# "broken" — which is the whole finding.
|
||||||
|
OK, UNANSWERED, NO_SUCH_PICK, READER_FAILED = 0, 1, 2, 3
|
||||||
|
|
||||||
|
|
||||||
|
def run(data, *args, **kw):
|
||||||
|
env = {**os.environ, "BOOTH_DATA_DIR": str(data), "BOOTH_URL": "http://booth.invalid"}
|
||||||
|
return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True,
|
||||||
|
env=env, timeout=30, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def booth(tmp_path):
|
||||||
|
b = tmp_path / "b"
|
||||||
|
b.mkdir()
|
||||||
|
return tmp_path, b
|
||||||
|
|
||||||
|
|
||||||
|
def _declare(booth_dir, mark_id="winner"):
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||||
|
from booth.marks import declare_pick
|
||||||
|
declare_pick(booth_dir, mark_id,
|
||||||
|
{"prompt": "Which one?", "options": ["A", "B"]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_marks_prints_one_json_document(booth):
|
||||||
|
"""`booth marks <name>` is a read. Its stdout is parsed by the session that
|
||||||
|
called it, so it has to be ONE document — and exit 0, because the read
|
||||||
|
succeeded. Whether a pick is open is in the payload's `open` list, which is
|
||||||
|
where a caller should read it from."""
|
||||||
|
data, b = booth
|
||||||
|
_declare(b)
|
||||||
|
r = run(data, "marks", "b")
|
||||||
|
assert r.returncode == OK, r.stderr
|
||||||
|
doc = json.loads(r.stdout)
|
||||||
|
assert doc["open"] == ["winner"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_marks_wait_prints_once_not_once_per_poll(booth):
|
||||||
|
"""`--wait` polls every 2 s and printed the whole document on every pass, so
|
||||||
|
a capture held several concatenated JSON values and `jq` could not read any
|
||||||
|
of them. The wait is a wait; the print is the result."""
|
||||||
|
data, b = booth
|
||||||
|
_declare(b)
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||||
|
from booth.marks import answer_pick
|
||||||
|
|
||||||
|
# Answer it after the first poll so --wait genuinely loops at least once.
|
||||||
|
r = subprocess.Popen([str(SCRIPT), "marks", "b", "--wait", "20"],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||||
|
env={**os.environ, "BOOTH_DATA_DIR": str(data),
|
||||||
|
"BOOTH_URL": "http://booth.invalid"})
|
||||||
|
import time
|
||||||
|
time.sleep(3)
|
||||||
|
answer_pick(b, "winner", "A")
|
||||||
|
out, err = r.communicate(timeout=30)
|
||||||
|
assert r.returncode == OK, err
|
||||||
|
json.loads(out) # ONE document, or this raises
|
||||||
|
|
||||||
|
|
||||||
|
def test_marks_reports_a_reader_failure_instead_of_printing_garbage(booth):
|
||||||
|
"""A traceback on stdout with exit 0 is the worst of both: the caller's `jq`
|
||||||
|
sees success and gets nothing. A read that could not happen is its own
|
||||||
|
answer and gets its own code."""
|
||||||
|
data, b = booth
|
||||||
|
(b / ".marks.json").write_bytes(b"\xff\xfe not utf-8 at all")
|
||||||
|
r = run(data, "marks", "b")
|
||||||
|
assert r.returncode == READER_FAILED, f"rc={r.returncode} out={r.stdout!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_distinguishes_a_crash_from_an_unanswered_pick(booth):
|
||||||
|
"""`answer` funnelled a reader crash and "not yet answered" through the same
|
||||||
|
exit 1, so `--wait` spun for the full hour on a broken file and then blamed
|
||||||
|
the operator for not answering."""
|
||||||
|
data, b = booth
|
||||||
|
_declare(b)
|
||||||
|
r = run(data, "answer", "b", "winner")
|
||||||
|
assert r.returncode == UNANSWERED
|
||||||
|
|
||||||
|
(b / ".marks.json").write_bytes(b"\xff\xfe not utf-8 at all")
|
||||||
|
r = run(data, "answer", "b", "winner", "--wait", "6")
|
||||||
|
assert r.returncode == READER_FAILED, (
|
||||||
|
"a crash was read as 'unanswered' and waited out the timeout"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_on_a_note_id_says_no_such_pick(booth):
|
||||||
|
"""`answer` matched on id alone while the web route filters on shape, so a
|
||||||
|
note id was reported 'unanswered' and polled forever — a question that could
|
||||||
|
never be answered because it was never a question."""
|
||||||
|
data, b = booth
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||||
|
from booth.marks import write_note
|
||||||
|
write_note(b, "a.png", "just a note")
|
||||||
|
|
||||||
|
r = run(data, "answer", "b", "note-1")
|
||||||
|
assert r.returncode == NO_SUCH_PICK
|
||||||
|
assert "no such pick" in r.stderr
|
||||||
@@ -881,3 +881,313 @@ def test_a_corrupt_marks_file_gives_the_browser_a_409_not_a_500(client):
|
|||||||
# the page still renders, so the operator can see the booth at all
|
# the page still renders, so the operator can see the booth at all
|
||||||
assert c.get("/b/b/").status_code == 200
|
assert c.get("/b/b/").status_code == 200
|
||||||
assert c.get("/b/b/marks.json").status_code == 200
|
assert c.get("/b/b/marks.json").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---- findings from the cross-frontier BUG-HUNT panel, 2026-09-22 -------------
|
||||||
|
#
|
||||||
|
# Heid panel (thread 01M33XEC1H0298C0D968FWBN7A). Four arms, artifact-only,
|
||||||
|
# diff-scoped. The headline was 4/4 convergent and none of it had a guard: the
|
||||||
|
# panel's own mutation tables showed the lock lifecycle SURVIVED every existing
|
||||||
|
# test, because `test_a_no_op_write_does_not_touch_the_booth` asserts only that
|
||||||
|
# `.marks.json` is absent and never looks at the lock or at the clock the
|
||||||
|
# sweeper actually reads.
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_lock_file_is_never_unlinked(tmp_path):
|
||||||
|
"""The lock must outlive the operation that created it.
|
||||||
|
|
||||||
|
`flock` binds to an INODE, not to a path. Unlinking `.marks.lock` while a
|
||||||
|
second writer is blocked on it leaves that writer holding an exclusive lock
|
||||||
|
on a deleted inode — and the next writer along creates a FRESH lock file and
|
||||||
|
takes it immediately. Two processes then run the read-modify-write
|
||||||
|
concurrently and the later `os.replace` drops the earlier one's mark, with
|
||||||
|
no error anywhere. Both of them obeyed the protocol.
|
||||||
|
|
||||||
|
The cleanup existed to keep a no-op from leaving a lock file as its only
|
||||||
|
trace. That is a tidiness goal, and it bought a lost-update race.
|
||||||
|
"""
|
||||||
|
from booth.marks import MARKS_LOCK, set_flag
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
assert set_flag(booth, "ghost.png", False) is None # a no-op
|
||||||
|
assert (booth / MARKS_LOCK).exists(), "the no-op path unlinked the lock file"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_no_op_does_not_reset_the_ttl_clock(tmp_path):
|
||||||
|
"""The property the no-op guard actually exists for, asserted against the
|
||||||
|
clock the sweeper reads instead of against one file's absence.
|
||||||
|
|
||||||
|
Creating or removing a directory entry bumps the DIRECTORY's mtime, and
|
||||||
|
`_newest_mtime` seeds from exactly that. So `touch` + `unlink` of the lock
|
||||||
|
reset the booth's age to zero while leaving no trace behind — the comment on
|
||||||
|
the create-only guard reasons about the lock FILE's mtime and misses that
|
||||||
|
the directory moved underneath it. Repeated, it kept a dead booth alive
|
||||||
|
forever, which is the precise outcome the guard was written to prevent.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from booth.app import booth_age_seconds
|
||||||
|
from booth.marks import delete_mark, set_flag
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
old = 1_000_000_000
|
||||||
|
os.utime(booth, (old, old))
|
||||||
|
|
||||||
|
set_flag(booth, "ghost.png", False) # no-op: never flagged
|
||||||
|
delete_mark(booth, "nothing") # no-op: no such mark
|
||||||
|
|
||||||
|
age = booth_age_seconds(booth, now=old + 90_000)
|
||||||
|
assert age > 86_400, f"a no-op reset the TTL clock (age fell to {age:.0f}s)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_real_mark_still_resets_the_ttl_clock(tmp_path):
|
||||||
|
"""The other half of the same rule, so the fix cannot overshoot into
|
||||||
|
'marking is never activity'. Marking IS activity and must reset the clock;
|
||||||
|
only a write that changes nothing must not."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from booth.app import booth_age_seconds
|
||||||
|
from booth.marks import set_flag
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
old = 1_000_000_000
|
||||||
|
os.utime(booth, (old, old))
|
||||||
|
|
||||||
|
set_flag(booth, "a.png", True) # a real mark
|
||||||
|
|
||||||
|
assert booth_age_seconds(booth, now=old + 90_000) < 86_400
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_non_string_note_text_does_not_crash_the_read(tmp_path):
|
||||||
|
"""`_clean_text` did `(text or "").replace(...)`, so a stored `text` that is
|
||||||
|
valid JSON but not a string raised AttributeError out of the READ path.
|
||||||
|
|
||||||
|
That is not a marks bug, it is an INDEX bug: `list_booths` reads every
|
||||||
|
booth's marks on every page load, so one poisoned file took down `/` and
|
||||||
|
`/healthz` for all 25 booths. The module's stated posture is that a mark it
|
||||||
|
cannot read renders as broken, never as a 500.
|
||||||
|
"""
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
(booth / MARKS_FILE).write_text(json.dumps({
|
||||||
|
"version": 1,
|
||||||
|
"marks": [{"id": "n1", "shape": "note", "text": 7,
|
||||||
|
"created": "2026-09-21T00:00:00+00:00"}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
marks = marks_for(booth)
|
||||||
|
assert len(marks) == 1
|
||||||
|
assert marks[0].error, "a poisoned note read clean instead of reading broken"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_non_string_created_does_not_crash_the_sort(tmp_path):
|
||||||
|
"""`marks_for` sorts on `(created, id)`. A stored `created` of the wrong type
|
||||||
|
made that comparison raise TypeError — same blast radius as the note above,
|
||||||
|
reached through the sort rather than through hydration."""
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
(booth / MARKS_FILE).write_text(json.dumps({
|
||||||
|
"version": 1,
|
||||||
|
"marks": [
|
||||||
|
{"id": "a", "shape": "note", "text": "fine",
|
||||||
|
"created": "2026-09-21T00:00:00+00:00"},
|
||||||
|
{"id": "b", "shape": "note", "text": "also fine", "created": 17},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
marks = marks_for(booth)
|
||||||
|
assert len(marks) == 2
|
||||||
|
# An unreadable mark loses its `created` and so sorts FIRST — the stated
|
||||||
|
# rule is `("", id)` against `(created, id)`. A mark nobody can read is the
|
||||||
|
# one that wants looking at, and the alternative is it landing at an
|
||||||
|
# arbitrary position in the middle of the panel.
|
||||||
|
assert [m.id for m in marks] == ["b", "a"]
|
||||||
|
assert marks[0].error and not marks[1].error
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_import_order_survives_same_second_mtimes(tmp_path):
|
||||||
|
"""ROADMAP states the legacy import's order is `(mtime, name)`. It was
|
||||||
|
stamping `created` at whole-second resolution, so two sidecars written in
|
||||||
|
the same second lost the fractional part that distinguished them and
|
||||||
|
`marks_for`'s `(created, id)` tie-break silently re-sorted them into
|
||||||
|
alphabetical order — reversing the pair the importer had just ordered.
|
||||||
|
|
||||||
|
Deterministic order is a v1 invariant precisely because the operator refers
|
||||||
|
to things positionally. An order that is stated and not kept is worse than
|
||||||
|
one that was never claimed.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from booth.marks import import_legacy_asks
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
for stem in ("zeta", "alpha"):
|
||||||
|
(booth / f"{stem}{ASK_SUFFIX}").write_text(json.dumps(_single()))
|
||||||
|
# Same whole second, different fractions: `zeta` is OLDER and must come first.
|
||||||
|
os.utime(booth / f"zeta{ASK_SUFFIX}", (1_700_000_000.10, 1_700_000_000.10))
|
||||||
|
os.utime(booth / f"alpha{ASK_SUFFIX}", (1_700_000_000.90, 1_700_000_000.90))
|
||||||
|
|
||||||
|
imported = [m.id for m in import_legacy_asks(booth)]
|
||||||
|
assert imported == ["zeta", "alpha"], "the importer's own order is wrong"
|
||||||
|
assert [m.id for m in marks_for(booth)] == imported, (
|
||||||
|
"the read path re-sorted what the importer ordered"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_index_survives_a_poisoned_marks_file(client):
|
||||||
|
"""The blast radius, asserted where it actually hurts.
|
||||||
|
|
||||||
|
`list_booths` reads every booth's marks on every index load and `/healthz`
|
||||||
|
does the same. One hand-edited or foreign-written `.marks.json` therefore
|
||||||
|
took down the front page for all 25 booths — the single-booth failure the
|
||||||
|
lenient reader exists to contain, escaping the booth it belongs to.
|
||||||
|
"""
|
||||||
|
c, data = client
|
||||||
|
good = data / "good"
|
||||||
|
good.mkdir()
|
||||||
|
_png(good / "a.png")
|
||||||
|
bad = data / "bad"
|
||||||
|
bad.mkdir()
|
||||||
|
(bad / MARKS_FILE).write_text(json.dumps({
|
||||||
|
"version": 1,
|
||||||
|
"marks": [{"id": "n1", "shape": "note", "text": {"oops": True}, "created": 3}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
assert c.get("/").status_code == 200
|
||||||
|
assert c.get("/healthz").status_code == 200
|
||||||
|
assert c.get("/b/bad/").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_treats_a_non_string_notes_field_as_no_notes(client):
|
||||||
|
"""`booth_note` guards `text` with `isinstance(..., str)`; `booth_answer`
|
||||||
|
passed `notes` straight to `_clean_notes`, which calls `.replace` on it. A
|
||||||
|
multipart FILE part named `notes` is a str to nobody, so the route 500'd on
|
||||||
|
hostile-but-legal input where its sibling handled the same class of value.
|
||||||
|
|
||||||
|
Both routes now read the field the same way: a value that is not text is no
|
||||||
|
value. The CHOICE is the judgment and it still lands — throwing the whole
|
||||||
|
answer away over a junk optional field would be the wrong trade."""
|
||||||
|
c, data = client
|
||||||
|
b = data / "b"
|
||||||
|
b.mkdir()
|
||||||
|
declare_pick(b, "winner", _single())
|
||||||
|
|
||||||
|
r = c.post(
|
||||||
|
"/b/b/answer",
|
||||||
|
data={"ask": "winner", "choice": "A — baseline"},
|
||||||
|
files={"notes": ("n.txt", b"surprise", "text/plain")},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 303
|
||||||
|
mark = next(m for m in marks_for(b) if m.id == "winner")
|
||||||
|
assert mark.answer["choice"] == "A — baseline"
|
||||||
|
assert not mark.answer.get("notes")
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_inline_doc_tile_offers_a_note_control(client):
|
||||||
|
"""Three item branches, two of them call `marknotes`. The doc branch got the
|
||||||
|
flag button and not the note field, so the operator could point at a report
|
||||||
|
and not write down why — on the one item kind whose whole purpose is prose.
|
||||||
|
|
||||||
|
This is the exact failure the `blurtoggle` macro comment names ("patched two
|
||||||
|
of three"), recurring on the macro that was written to prevent it.
|
||||||
|
"""
|
||||||
|
c, data = client
|
||||||
|
b = data / "b"
|
||||||
|
b.mkdir()
|
||||||
|
(b / "report.md").write_text("# report\n\nprose here\n")
|
||||||
|
|
||||||
|
html = c.get("/b/b/").text
|
||||||
|
assert 'value="report.md"' in html, "the doc tile has no mark controls at all"
|
||||||
|
# `marknotes`' add-field, which only that macro emits. The booth-level panel
|
||||||
|
# has its own note form, so the presence of /note on the page proves nothing.
|
||||||
|
assert 'placeholder="a note on this item"' in html, (
|
||||||
|
"an inline doc tile has no way to add a note"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_marks_panel_survives_a_booth_that_also_has_a_link_board(client):
|
||||||
|
"""The board booth renders as a board instead of a gallery, which is right —
|
||||||
|
but the suppression was unconditional, so a pick declared on a booth that
|
||||||
|
happens to carry a `links.md` had no form to answer it and no way to say so."""
|
||||||
|
c, data = client
|
||||||
|
b = data / "b"
|
||||||
|
b.mkdir()
|
||||||
|
(b / "links.md").write_text("- [a thing](http://example.invalid) <sub>· who · when</sub>\n")
|
||||||
|
declare_pick(b, "winner", _single())
|
||||||
|
|
||||||
|
html = c.get("/b/b/").text
|
||||||
|
assert "Which render wins?" in html, "a pick on a board booth was unanswerable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_zoom_view_does_not_navigate_away_from_a_note_being_typed(client):
|
||||||
|
"""The viewer's arrow keys move between images and Escape goes back. The
|
||||||
|
note textarea landed in the same page, and the handler is on `document`, so
|
||||||
|
an arrow key meant for the caret threw away the draft instead of moving it.
|
||||||
|
|
||||||
|
Asserted structurally: the handler must bail on events from an editable
|
||||||
|
target. There is no browser in this suite, and a guard nobody can test is
|
||||||
|
exactly how this shipped."""
|
||||||
|
c, data = client
|
||||||
|
b = data / "b"
|
||||||
|
b.mkdir()
|
||||||
|
_png(b / "a.png")
|
||||||
|
|
||||||
|
js = c.get("/b/b/view?f=a.png").text
|
||||||
|
assert "isEditable" in js, "the viewer's key handler has no editing guard"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("route", ["booth_answer", "booth_note", "booth_flag",
|
||||||
|
"booth_unmark", "booth_import_asks"])
|
||||||
|
def test_mark_writes_do_not_block_the_event_loop(route):
|
||||||
|
"""Every mark write takes a blocking `flock` and does synchronous disk I/O.
|
||||||
|
In an `async def` handler that runs ON the event loop, so a lock held by
|
||||||
|
another process — the CLI mid-`marks-import`, a second browser tab — freezes
|
||||||
|
every other request, including the index and `/healthz`.
|
||||||
|
|
||||||
|
Structural, like `test_stdlib_only`, and for the same reason: the failure is
|
||||||
|
a property of where the call runs, which no single-process response
|
||||||
|
assertion can see. The rule is that an async mark-write handler hands the
|
||||||
|
locked section to a worker thread and never calls the writer inline.
|
||||||
|
"""
|
||||||
|
src = pathlib.Path(__file__).parent.parent / "booth" / "app.py"
|
||||||
|
fn = next(
|
||||||
|
n for n in ast.walk(ast.parse(src.read_text()))
|
||||||
|
if isinstance(n, ast.AsyncFunctionDef) and n.name == route
|
||||||
|
)
|
||||||
|
writers = {"answer_pick", "write_note", "set_flag", "delete_mark",
|
||||||
|
"import_legacy_asks"}
|
||||||
|
for node in ast.walk(fn):
|
||||||
|
if not isinstance(node, ast.Call):
|
||||||
|
continue
|
||||||
|
name = getattr(node.func, "id", None) or getattr(node.func, "attr", None)
|
||||||
|
if name in writers:
|
||||||
|
pytest.fail(f"{route} calls {name}() on the event loop; "
|
||||||
|
"dispatch it through run_in_threadpool")
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unreadable_mark_is_visible_on_the_page(client):
|
||||||
|
"""Surviving the poisoned file is half of it. A note whose stored `text` is
|
||||||
|
unreadable hydrates with empty text, and the panel rendered that as an empty
|
||||||
|
`<pre>` with a withdraw button beside it — which looks exactly like a note
|
||||||
|
the operator wrote and then cleared.
|
||||||
|
|
||||||
|
`_hydrate`'s own docstring forbids this for picks ("a broken question the
|
||||||
|
session believes it posted has to be visible — silently hiding it is the one
|
||||||
|
outcome nobody can debug"). It is the same argument for every shape."""
|
||||||
|
c, data = client
|
||||||
|
b = data / "b"
|
||||||
|
b.mkdir()
|
||||||
|
(b / MARKS_FILE).write_text(json.dumps({
|
||||||
|
"version": 1,
|
||||||
|
"marks": [{"id": "n1", "shape": "note", "text": {"oops": True},
|
||||||
|
"created": "2026-09-21T00:00:00+00:00"}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
html = c.get("/b/b/").text
|
||||||
|
assert "⚠ broken" in html, "an unreadable mark rendered as an empty note"
|
||||||
|
assert "n1" in html
|
||||||
|
|||||||
Reference in New Issue
Block a user