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
+127
View File
@@ -1247,3 +1247,130 @@ def test_a_write_over_an_unparseable_marks_file_still_refuses(tmp_path):
with pytest.raises(MarksCorrupt):
set_flag(booth, "a.png", True)
# ---- findings from the U5 diff-scoped BUG-HUNT panel, 2026-09-22 ------------
def test_the_marks_reader_never_blocks_on_a_file_that_is_not_a_file(tmp_path):
"""Same hole the size cap opened in the manifest, in the sibling it was
copied from. `st_size` is 0 for a FIFO, so it passes the cap, and then
`read_text` blocks with no EOF. `list_booths` reads every booth's marks on
every `GET /` and `/healthz`."""
import os
import signal
booth = tmp_path / "b"
booth.mkdir()
os.mkfifo(booth / MARKS_FILE)
def _timeout(signum, frame):
raise AssertionError("marks_for blocked on a FIFO and never returned")
old = signal.signal(signal.SIGALRM, _timeout)
signal.alarm(5)
try:
assert marks_for(booth) == []
finally:
signal.alarm(0)
signal.signal(signal.SIGALRM, old)
def test_new_marks_and_imported_marks_share_one_stamp_format(tmp_path):
"""The v0.2.2 fix for the legacy-import ordering opened a NEW ordering bug,
which is the shape worth remembering. `import_legacy_asks` moved to
microsecond precision while `now_stamp` stayed at whole seconds, and `-` is
0x2D against `.` at 0x2E — so `...T10:00:00-07:00` sorts BEFORE
`...T10:00:00.500000-07:00`, putting a LATER mark ahead of an EARLIER
import inside the same second.
Deterministic order is a v1 invariant precisely because the operator refers
to things positionally. One format, or the rule cannot be stated.
"""
from booth.marks import now_stamp
stamp = now_stamp()
assert "." in stamp.split("T")[1], f"now_stamp is not sub-second: {stamp}"
assert len(stamp.split(".")[1].split("+")[0].split("-")[0]) == 6
def test_the_importer_cannot_raise_out_of_a_poisoned_entry(tmp_path):
"""`marks_for` routes every entry through `_hydrate_safe`; the importer's
return still went through the bare `_hydrate`, so the one path that reads
entries it did not write was the one without the guard."""
booth = tmp_path / "b"
booth.mkdir()
(booth / MARKS_FILE).write_text(json.dumps({
"version": 1,
"marks": [{"id": "n1", "shape": "note", "text": {"bad": True},
"created": "2026-09-21T00:00:00+00:00"}],
}))
(booth / f"q1{ASK_SUFFIX}").write_text(json.dumps(_single()))
from booth.marks import import_legacy_asks
out = import_legacy_asks(booth) # must not raise
assert isinstance(out, list)
def test_a_document_that_would_not_read_back_is_refused_at_the_write(tmp_path):
"""The read bound is on the STORED bytes and the write adds `indent=2`, so a
document that fits in memory can land over the limit on disk and then read
back as no marks at all — every mark in the booth gone, silently. Refuse
loudly instead: a write that fails is recoverable.
Asserted against `_write_raw` directly, because no single mark can get
there: `_clean_text` caps a note at TEXT_MAX and a flag is a fixed shape.
The reachable path is accumulation — `_note_id` puts no ceiling on how many
notes one booth may carry — which is thousands of writes, not one. Testing
it through `write_note` would need a fixture nobody could justify, and
would be testing the cap rather than the guard.
"""
from booth.marks import MARKS_MAX_BYTES, MarksCorrupt, _write_raw
booth = tmp_path / "b"
booth.mkdir()
bulk = [{"id": f"note-{i}", "shape": "note", "text": "x" * 500,
"created": "2026-09-21T00:00:00.000000+00:00"}
for i in range(MARKS_MAX_BYTES // 400)]
with pytest.raises(MarksCorrupt):
_write_raw(booth, bulk)
assert not (booth / MARKS_FILE).exists(), "a refused write still landed"
def test_a_clock_restore_that_fails_does_not_take_the_route_down(tmp_path):
"""The concrete half of the mtime-restore finding.
`_Locked.__enter__` puts the booth directory's clock back after creating its
lock, and `os.utime` can fail — a read-only directory, a booth whose owner
we are not. It used to escape into the route and answer 500 for what is
otherwise a perfectly good request. Not putting the clock back is a cost
this module can absorb; not answering is not.
The RACE half of that finding is documented in the code and deliberately not
closed: the alternative fix would silently retire the documented behaviour
that releasing a kept board resets its clock
(`test_releasing_a_board_RESETS_its_ttl_clock` pins that on purpose), which
is a TTL doctrine change rather than a bug fix.
"""
import os
from booth.marks import MARKS_LOCK, set_flag
booth = tmp_path / "b"
booth.mkdir()
real_utime = os.utime
def boom(path, *a, **kw):
if str(path) == str(booth):
raise PermissionError("read-only directory")
return real_utime(path, *a, **kw)
os.utime = boom
try:
assert set_flag(booth, "a.png", True) is not None
finally:
os.utime = real_utime
assert (booth / MARKS_LOCK).exists()
assert [m.target for m in marks_for(booth)] == ["a.png"]