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
+74 -17
View File
@@ -22,6 +22,13 @@
# booth answer <name> <id> [--wait [SECS]]
# print ONE pick's answer (exit 1 if unanswered);
# --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 asks <name> alias for `marks` (deprecated)
#
@@ -270,6 +277,15 @@ except MarksCorrupt as exc:
;;
marks|asks)
# 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
name="$1"; shift
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.
deadline=$(( $(date +%s) + wait_s ))
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
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.marks import as_dict, marks_for, open_marks
marks = marks_for(pathlib.Path(sys.argv[1]))
print(json.dumps({"marks": [as_dict(m) for m in marks],
"open": [m.id for m in open_marks(marks)]},
ensure_ascii=False, indent=2))
sys.exit(1 if open_marks(marks) else 0)
' "$DATA/$name" && exit 0
# exit 1 from the reader means at least one pick is still open
if [ "$wait_s" -eq 0 ]; then exit 0; fi
try:
from booth.marks import as_dict, marks_for, open_marks, read_error
booth = pathlib.Path(sys.argv[1])
# Ask FIRST whether the file is readable. `marks_for` answers "no marks"
# for a damaged file, which is the right answer for a page and the wrong
# one for a session that wants to know whether its question survived.
broken = read_error(booth)
if broken:
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
printf '%s\n' "$out"
echo "timed out after ${wait_s}s with marks still open in $name" >&2; exit 1
fi
sleep 2
@@ -304,20 +342,39 @@ sys.exit(1 if open_marks(marks) else 0)
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
deadline=$(( $(date +%s) + wait_s ))
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
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.marks import marks_for
booth, mid = sys.argv[1:3]
m = next((x for x in marks_for(pathlib.Path(booth)) if x.id == mid), None)
try:
from booth.marks import marks_for, read_error
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:
sys.exit(2)
if m.answer is None:
sys.exit(1)
print(json.dumps(m.answer, ensure_ascii=False, indent=2))
' "$DATA/$name" "$mid" && exit 0
rc=$?
if [ "$rc" -eq 2 ]; then echo "no such pick: $name/$mid" >&2; exit 1; fi
' "$DATA/$name" "$mid")" || rc=$?
case "$rc" in
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 [ "$(date +%s)" -ge "$deadline" ]; then
echo "timed out after ${wait_s}s waiting on $name/$mid" >&2; exit 1