`/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.
120 lines
6.0 KiB
HTML
120 lines
6.0 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
|
||
{% block content %}
|
||
<div class="viewer">
|
||
<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>
|
||
<span class="vtoggle" id="vtoggle" style="display:none">
|
||
<button type="button" class="vseg on" id="btn-fit">Fit</button><button type="button" class="vseg" id="btn-one">1:1</button>
|
||
</span>
|
||
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a>
|
||
</div>
|
||
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous image">‹</a>{% endif %}
|
||
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next image">›</a>{% endif %}
|
||
<div class="vstage fit" id="vstage"><img id="vimg" src="{{ file_url }}" alt="{{ file }}"></div>
|
||
{# THE ANNOTATION, at full size. It was never rendered here before U1 — not
|
||
because the template dropped it, but because the route never resolved it.
|
||
A caption is most useful at the size where you are actually judging the
|
||
thing, so it belongs here at least as much as in the grid. #}
|
||
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
|
||
{# INV-3: the JUDGMENT travels to full size too, not just the caption. This is
|
||
the size at which the operator is actually deciding, so the flag toggle and
|
||
the notes belong here at least as much as on the tile. #}
|
||
<div class="vmarks">
|
||
<form class="vflag" method="post" action="/b/{{ name_url }}/flag">
|
||
<input type="hidden" name="target" value="{{ file }}">
|
||
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
||
<button class="vbtn{% if flagged %} is-flagged{% endif %}"
|
||
title="{{ 'un-flag this item' if flagged else 'flag this one' }}"
|
||
>{{ '✔ flagged' if flagged else '○ flag' }}</button>
|
||
</form>
|
||
{% for m in marks if m.shape == 'note' %}
|
||
<div class="vnote"><pre>{{ m.text }}</pre>
|
||
<form method="post" action="/b/{{ name_url }}/unmark">
|
||
<input type="hidden" name="mark" value="{{ m.id }}">
|
||
<button class="mark-x" title="withdraw this note">×</button>
|
||
</form>
|
||
</div>
|
||
{% endfor %}
|
||
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note">
|
||
<input type="hidden" name="target" value="{{ file }}">
|
||
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
|
||
<button type="submit">Add note</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<style>
|
||
.vnav{position:fixed;top:50%;transform:translateY(-50%);z-index:40;display:flex;
|
||
align-items:center;justify-content:center;width:2.6rem;height:3.4rem;font-size:2rem;
|
||
line-height:1;text-decoration:none;color:var(--fg-1);background:rgba(20,23,32,.55);
|
||
border:1px solid rgba(255,255,255,.10);border-radius:10px;margin:0 .5rem;user-select:none;
|
||
-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);transition:background .15s,border-color .15s}
|
||
.vnav:hover{background:rgba(28,33,46,.92);border-color:var(--aus-bright-cyan,#42dcd1)}
|
||
.vprev{left:0}.vnext{right:0}
|
||
/* Bottom bar rather than the top chrome: a caption can run to CAPTION_MAX
|
||
(800 chars), which would shove the filename and the Fit/1:1 toggle around. */
|
||
.vcap{flex:0 0 auto;max-height:22vh;overflow-y:auto;padding:.6rem clamp(12px,3vw,20px);
|
||
font-size:.85rem;line-height:1.5;color:var(--fg-1);background:var(--rk-surface,rgba(20,23,32,.92));
|
||
border-top:1px solid rgba(255,255,255,.10);white-space:pre-wrap}
|
||
@media print{.vcap{max-height:none;overflow:visible}}
|
||
@media print{.vnav{display:none}}
|
||
</style>
|
||
<script>
|
||
(function () {
|
||
var img = document.getElementById('vimg');
|
||
var stage = document.getElementById('vstage');
|
||
var toggle = document.getElementById('vtoggle');
|
||
var bFit = document.getElementById('btn-fit');
|
||
var bOne = document.getElementById('btn-one');
|
||
var BACK = {{ ('/b/' ~ name_url ~ '/')|tojson }};
|
||
var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
|
||
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }};
|
||
|
||
function setMode(mode) {
|
||
var fit = mode === 'fit';
|
||
stage.classList.toggle('fit', fit);
|
||
stage.classList.toggle('one', !fit);
|
||
bFit.classList.toggle('on', fit);
|
||
bOne.classList.toggle('on', !fit);
|
||
}
|
||
// "fits" == the image at natural size already sits inside the stage, so Fit
|
||
// and 1:1 would render identically — in that case we hide the toggle entirely.
|
||
function fits() {
|
||
return img.naturalWidth <= stage.clientWidth && img.naturalHeight <= stage.clientHeight;
|
||
}
|
||
function evaluate() {
|
||
if (!img.naturalWidth) return;
|
||
if (fits()) {
|
||
toggle.style.display = 'none';
|
||
setMode('fit');
|
||
} else {
|
||
toggle.style.display = 'inline-flex';
|
||
if (!stage.classList.contains('one')) setMode('fit');
|
||
}
|
||
}
|
||
bFit.addEventListener('click', function () { setMode('fit'); });
|
||
bOne.addEventListener('click', function () { setMode('one'); });
|
||
img.addEventListener('load', evaluate);
|
||
window.addEventListener('resize', evaluate);
|
||
if (img.complete) evaluate();
|
||
|
||
/* An arrow key inside the note field is a CARET move, not a navigation.
|
||
The handler is on `document` and the note textarea shipped into this same
|
||
page, so typing a note and reaching for ← threw the draft away; Escape
|
||
did it in one keystroke. Anything editable keeps its own keys. */
|
||
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 = BACK;
|
||
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
|
||
else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT;
|
||
});
|
||
})();
|
||
</script>
|
||
{% endblock %}
|