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:
Vuong Hoang
2026-09-22 00:20:58 -07:00
parent 70fb15886b
commit 026a1fc392
12 changed files with 842 additions and 53 deletions
+121
View File
@@ -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
+310
View File
@@ -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
assert c.get("/b/b/").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