fix(manifest)!: the size cap opened a service-wide hang; close it

The diff-scoped bug-hunt panel, four arms, artifact-only. Its strongest
finding is one I created two hours earlier while hardening the reader.

`stat` reports size 0 for a FIFO and 0 for a symlink to /dev/zero, so both
sail under the byte cap added for the RecursionError round — and then
`read_text` either blocks in read() with no EOF, so the except never runs,
or allocates until the kernel intervenes. `list_booths` reads every booth
on every GET / and /healthz, so ONE such file stalls the front page for the
whole service, with no error and no recovery short of a restart.
Reproduced before believing it (timeout returned 124). S_ISREG is checked
BEFORE the size in both modules now; verified against the live service with
two FIFOs planted, which answered 200 in 36ms.

The shape worth carrying: st_size answers a different question than "can
this be read", and a bound that trusts it inherits everything it does not
mean. A hardening fix opened a worse hole than the one it closed.

THE UPLOAD PATH WROTE ABOVE ITS OWN CLEANUP GUARD (4/4)

A failed manifest write orphaned a .uploaded half-booth with no files in
it — and because the temp name now carries a random suffix, nothing ever
overwrote the leak, and .booth.json.<hex>.tmp is not a .lock, so
_newest_mtime counted it and kept that empty booth past every sweep. The
uniqueness fix from the previous round is what made the leak permanent.
Both writes moved inside the guard; the temp is removed on every exit path.

DAMAGED BYTES ARE KEPT, NOT REPLACED (4/4, INV-6)

Marks made this explicit in v0.2.1 and this write path contradicted it: a
manifest that failed on ONE field lost the others with it, including a why
the re-announcer may never have kept anywhere. It diverges from marks in
HOW it honours the rule — marks refuse and answer 409 because the
operator's judgment is not restatable; a manifest quarantines and proceeds,
because refusing would fail `booth add` and lose the files it was copying.

ONE OPENNESS PREDICATE, AS U2 SAID (2/4)

`booth answer` spelled out `if m.answer is None` while `booth marks` asked
`open_marks`, so a partially-answered pick read as done to one verb and
open to the other — at the same instant, on the same booth. U2's INV-2 put
openness in one function precisely so they could not drift. The mirror case
is fixed too: a pick that hydrates broken is refused by the web route, so
`answer --wait` polled an hour on a form nothing could ever land.

ALSO

- now_stamp was whole-second while the importer had moved to microseconds,
  and '-' sorts before '.', so a later mark came out ahead of an earlier
  import inside the same second. One format; the previous round's ordering
  fix had opened this one.
- `_broken` was the third of three directory-name fallbacks and the one
  still handing a raw name into a card's sub-line.
- An identical re-announce rewrote the file and reset the TTL. `booth link`
  does this on every post to the standing board.
- The importer's return went through the bare _hydrate, not _hydrate_safe.
- A marks document could be written larger than it can be read back, and
  then read as no marks at all. Refused at the write instead.
- `choice` reached the answer builder raw while `notes` beside it did not.

AND ONE FINDING DELIBERATELY NOT FULLY CLOSED

The mtime-restore race is real. The clean fix — ignore a booth directory's
own mtime whenever the booth holds anything — also silently retires the
documented rule that releasing a kept board resets its clock, which the CLI
header, the README and a deliberately-written test all pin. That is a TTL
doctrine change, not a bug fix, and an existing test caught the attempt.
The concrete half is fixed (a failing os.utime escaped and 500'd the
route); the race is stated in the code where the next reader will meet it.

341 tests. Live service restarted, 24/24 booth pages verified.
This commit is contained in:
Vuong Hoang
2026-09-22 02:27:18 -07:00
parent f3193fb054
commit 95beede3c3
11 changed files with 616 additions and 61 deletions
+25 -5
View File
@@ -30,7 +30,11 @@
# 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
# answer 0 answered · 1 unanswered · 2 no such pick · 3 unreadable ·
# 4 the pick hydrated broken and can never be answered
#
# `answer` and `marks` use the SAME openness predicate. A partially-answered
# pick is still open to both; a broken one is closed to both.
# booth marks-import <name> import legacy *.ask.json into .marks.json
# booth asks <name> alias for `marks` (deprecated)
#
@@ -433,7 +437,7 @@ sys.exit(2 if open_marks(marks) else 0)
import json, os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
try:
from booth.marks import marks_for, read_error
from booth.marks import marks_for, open_marks, read_error
booth, mid = sys.argv[1:3]
broken = read_error(pathlib.Path(booth))
if broken:
@@ -442,14 +446,27 @@ try:
# 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)
marks = marks_for(pathlib.Path(booth))
m = next((x for x in marks if x.id == mid and x.shape == "pick"), None)
# THE openness predicate, not a second spelling of it. `answer is None` is
# what this read used to test, and it disagreed with `marks --wait` on a
# PARTIALLY answered pick: one verb returned the half-filled form while the
# other blocked on the same booth at the same instant. U2 put openness in
# one function precisely so the two could not drift.
still_open = m is not None and m in open_marks(marks)
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:
if m.error:
# Not open, and never going to be: the web route refuses this form with a
# 400, so waiting on it is waiting on nothing. `marks --wait` already
# returns immediately here; this is the other half of that agreement.
print(f"booth: pick is broken and cannot be answered: {m.error}",
file=sys.stderr)
sys.exit(4)
if still_open:
sys.exit(1)
print(json.dumps(m.answer, ensure_ascii=False, indent=2))
' "$DATA/$name" "$mid")" || rc=$?
@@ -460,6 +477,9 @@ print(json.dumps(m.answer, ensure_ascii=False, indent=2))
# 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 ;;
# A pick that hydrated broken is refused by the web route, so no answer
# can ever land. Waiting on it is waiting on nothing.
4) exit 4 ;;
esac
if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#mark-$mid" >&2; exit 1; fi
if [ "$(date +%s)" -ge "$deadline" ]; then