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:
+95
-11
@@ -218,6 +218,24 @@ def _read_raw_strict(booth: Path) -> list[dict]:
|
||||
return entries
|
||||
|
||||
|
||||
def read_error(booth: Path) -> str | None:
|
||||
"""Why this booth's marks cannot be read, or None if they can.
|
||||
|
||||
`marks_for` is lenient on purpose — a review page that will not load is
|
||||
worse than one missing an annotation — and that leniency turns an
|
||||
unreadable file into "no marks". For a BROWSER that is the right trade. For
|
||||
the CLI it is not: a session that asked a question and is told "no such
|
||||
pick" will conclude the question was never posted, when in fact the file
|
||||
holding it is damaged. A machine consumer can act on the difference, so it
|
||||
gets to ask.
|
||||
"""
|
||||
try:
|
||||
_read_raw_strict(booth)
|
||||
except MarksCorrupt as exc:
|
||||
return str(exc)
|
||||
return None
|
||||
|
||||
|
||||
def _write_raw(booth: Path, entries: list[dict]) -> None:
|
||||
"""Atomic replace, so a reader never sees a half-written document and a
|
||||
crash mid-write cannot truncate the file into a shorter — and therefore
|
||||
@@ -249,12 +267,20 @@ class _Locked:
|
||||
self.booth.mkdir(parents=True, exist_ok=True)
|
||||
lock = self.booth / MARKS_LOCK
|
||||
# `touch(exist_ok=True)` on an EXISTING file bumps its mtime, and a
|
||||
# booth's TTL is measured from its newest mtime including dotfiles — so
|
||||
# an unconditional touch would keep a booth alive just for being read
|
||||
# through a write path. Create it only when it is not there.
|
||||
# booth's TTL is measured from its newest mtime — so an unconditional
|
||||
# touch would keep a booth alive just for being read through a write
|
||||
# path. Create it only when it is not there.
|
||||
#
|
||||
# ONCE CREATED, THE LOCK FILE IS NEVER REMOVED (see __exit__).
|
||||
if not lock.exists():
|
||||
# Creating a directory entry bumps the DIRECTORY's mtime, which is
|
||||
# what `_newest_mtime` seeds from — so making our own lock file
|
||||
# would itself read as activity. Put the clock back: the lock is
|
||||
# machinery, and machinery is not the operator touching the booth.
|
||||
before = self.booth.stat()
|
||||
lock.touch()
|
||||
self._made_lock = True
|
||||
os.utime(self.booth, (before.st_atime, before.st_mtime))
|
||||
self._lf = lock.open("r+")
|
||||
fcntl.flock(self._lf, fcntl.LOCK_EX)
|
||||
try:
|
||||
@@ -264,8 +290,6 @@ class _Locked:
|
||||
fcntl.flock(self._lf, fcntl.LOCK_UN)
|
||||
self._lf.close()
|
||||
self._lf = None
|
||||
if self._made_lock:
|
||||
lock.unlink(missing_ok=True)
|
||||
raise
|
||||
self._before = _fingerprint(self.entries)
|
||||
return self
|
||||
@@ -284,10 +308,17 @@ class _Locked:
|
||||
# would otherwise keep a dead booth alive forever.
|
||||
if exc_type is None and _fingerprint(self.entries) != self._before:
|
||||
_write_raw(self.booth, self.entries)
|
||||
elif self._made_lock and not (self.booth / MARKS_FILE).exists():
|
||||
# Nothing was written and this booth had no marks before: do not
|
||||
# leave a lock file behind as the only trace of a no-op.
|
||||
(self.booth / MARKS_LOCK).unlink(missing_ok=True)
|
||||
# THE LOCK FILE IS NEVER UNLINKED. It used to be, on the no-op path,
|
||||
# so a booth that had never been marked was left exactly as it was
|
||||
# found. That tidiness cost mutual exclusion outright: `flock` binds
|
||||
# to an INODE, so unlinking the lock while a second writer is blocked
|
||||
# on it leaves that writer holding an exclusive lock on a deleted
|
||||
# file, and the NEXT writer creates a fresh lock and takes it at
|
||||
# once. Two processes then run the read-modify-write concurrently,
|
||||
# the later `os.replace` drops the earlier one's mark, and both of
|
||||
# them obeyed the protocol. A zero-byte dotfile is the cheaper
|
||||
# thing to leave behind — `booth_items` skips it, the zip skips it,
|
||||
# and `_newest_mtime` exempts it so it cannot hold a booth open.
|
||||
finally:
|
||||
fcntl.flock(lf, fcntl.LOCK_UN)
|
||||
lf.close()
|
||||
@@ -301,6 +332,25 @@ class _Locked:
|
||||
# ---- read -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _entry_type_error(entry: dict) -> str | None:
|
||||
"""The stored scalars this module refuses to guess at.
|
||||
|
||||
`_clean_text` did `(text or "").replace(...)` and `marks_for` sorts on
|
||||
`(created, id)` — so a stored `text` that is a dict, or a `created` that is a
|
||||
number, raised AttributeError or TypeError 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 and `/healthz` does the same, so one hand-edited or
|
||||
foreign-written file took down the front page for every booth on the
|
||||
service. A wrong type is a broken mark, and this module already knows how to
|
||||
render one of those.
|
||||
"""
|
||||
for name in ("created", "by", "text", "error"):
|
||||
value = entry.get(name)
|
||||
if value is not None and not isinstance(value, str):
|
||||
return f"{name} is {type(value).__name__}, not a string"
|
||||
return None
|
||||
|
||||
|
||||
def _hydrate(entry: dict) -> Mark:
|
||||
"""One stored entry -> one Mark, declarations normalized.
|
||||
|
||||
@@ -312,6 +362,15 @@ def _hydrate(entry: dict) -> Mark:
|
||||
"""
|
||||
mid = entry["id"]
|
||||
shape = entry.get("shape") if entry.get("shape") in SHAPES else NOTE
|
||||
bad = _entry_type_error(entry)
|
||||
if bad is not None:
|
||||
# `created` is dropped rather than coerced, which sorts the entry to the
|
||||
# TOP of the booth's marks: a mark nobody can read is the one that wants
|
||||
# looking at, and burying it under 270 items' worth of notes is how it
|
||||
# stays unnoticed. Deterministic, and stated — `("", id)` against
|
||||
# `(created, id)`.
|
||||
return Mark(id=mid, shape=shape, target=None, created="",
|
||||
error=f"unreadable mark: {bad}")
|
||||
target = entry.get("target")
|
||||
if not _valid_target(target):
|
||||
target = None
|
||||
@@ -359,11 +418,26 @@ def _hydrate(entry: dict) -> Mark:
|
||||
return Mark(**base, text=_clean_text(entry.get("text")))
|
||||
|
||||
|
||||
def _hydrate_safe(entry: dict) -> Mark:
|
||||
"""`_hydrate`, with the promise that it cannot raise.
|
||||
|
||||
`_entry_type_error` covers the shapes we know how to name; this is the
|
||||
backstop for the ones we do not, and it exists because of WHERE this runs.
|
||||
One unreadable mark must cost that mark, never the page — and on the index
|
||||
it is not even that booth's page, it is all of them.
|
||||
"""
|
||||
try:
|
||||
return _hydrate(entry)
|
||||
except Exception as exc: # noqa: BLE001 - deliberate
|
||||
return Mark(id=str(entry.get("id", "")), shape=NOTE, target=None,
|
||||
created="", error=f"unreadable mark: {exc}")
|
||||
|
||||
|
||||
def marks_for(booth: Path) -> list[Mark]:
|
||||
"""Every mark in a booth, oldest first, declarations normalized and answers
|
||||
folded in. ONE file read — which is the whole point of the storage shape."""
|
||||
entries = _read_raw(booth)
|
||||
marks = [_hydrate(e) for e in entries]
|
||||
marks = [_hydrate_safe(e) for e in entries]
|
||||
# (created, id) rather than created alone: two marks written in the same
|
||||
# second would otherwise order by however json listed them.
|
||||
marks.sort(key=lambda m: (m.created, m.id))
|
||||
@@ -642,7 +716,17 @@ def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||
"id": stem,
|
||||
"shape": PICK,
|
||||
"target": None,
|
||||
"created": datetime.fromtimestamp(mtime).astimezone().isoformat(timespec="seconds"),
|
||||
# MICROSECONDS, not seconds. `found` is ordered by fractional
|
||||
# mtime and `marks_for` re-sorts on this string, so truncating
|
||||
# to the whole second threw away the only thing distinguishing
|
||||
# two sidecars written in the same second — and the `(created,
|
||||
# id)` tie-break then silently re-sorted them alphabetically,
|
||||
# reversing the order the importer had just established. The
|
||||
# ROADMAP states this import's order is `(mtime, name)`; an
|
||||
# order that is stated and not kept is worse than one never
|
||||
# claimed.
|
||||
"created": datetime.fromtimestamp(mtime).astimezone().isoformat(
|
||||
timespec="microseconds"),
|
||||
"declaration": decl,
|
||||
"answer": answer,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user