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