Files
booth/booth/templates/doc.html
T
Vuong Hoang 026a1fc392 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.
2026-09-22 00:20:58 -07:00

51 lines
2.1 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
{% block content %}
<div class="docview">
<div class="vbar">
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a>
<span class="vname">{{ file }}</span>
<span class="vspacer"></span>
<a class="vbtn" href="{{ file_url }}?dl=1" title="download {{ file }}">⬇</a>
</div>
{# Same record, same reason as the image viewer: the sidecar that says what
this doc IS travels with it to full-page view. #}
{% if caption %}<div class="doccap">{{ caption }}</div>{% endif %}
{% if marks %}
<div class="docmarks">
{% for m in marks if m.shape == 'note' %}<pre class="vnote">{{ m.text }}</pre>{% endfor %}
</div>
{% endif %}
{% if is_html %}
<article class="markdown-body">{{ body|safe }}</article>
{% else %}
<pre class="textview">{{ body }}</pre>
{% endif %}
</div>
<style>
/* .markdown-body and .textview now live in base.html (shared with the inline
gallery view). Only the full-page layout wrapper is page-specific. */
.docview{max-width:52rem;margin:0 auto;padding:0 clamp(12px,3vw,20px) 4rem}
.docview .textview{overflow-x:auto}
.doccap{margin:.9rem 0 1.2rem;padding:.6rem .85rem;font-size:.85rem;line-height:1.5;
color:var(--fg-1);background:var(--rk-surface,rgba(255,255,255,.04));
border-left:2px solid var(--aus-bright-cyan,#42dcd1);border-radius:0 6px 6px 0;
white-space:pre-wrap}
</style>
<script>
(function () {
/* Escape leaves the page, so it must not fire from inside a field someone
is typing in — the same guard the image viewer carries, stated in both
places because the handler is on `document` in both. */
function isEditable(el) {
return !!(el && (el.isContentEditable ||
/^(input|textarea|select)$/i.test(el.tagName || '')));
}
document.addEventListener('keydown', function (e) {
if (isEditable(e.target)) return;
if (e.key === 'Escape') window.location.href = {{ ('/b/' ~ name_url ~ '/')|tojson }};
});
})();
</script>
{% endblock %}