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:
@@ -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
|
||||
Reference in New Issue
Block a user