• v0.2.2 026a1fc392

    fix(marks): v0.2.2 — nine findings from the cross-frontier bug-hunt panel

    vh released this 2026-09-22 00:20:58 -07:00 | 125 commits to main since this release

    /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.

    Downloads