fix(u6): the booth check fails closed with a reason, and a dead write leaves no scratch

Two more from the in-session adversarial pass.

`booth link`'s new booth-URL check shells out to booth/links.py. When that
import cannot run, the command substitution under `set -e` aborted the script
with a bare ModuleNotFoundError traceback: the right DIRECTION (no row was
appended — a guard that fails open is not a guard) reached by accident, and
unactionable when it fires. Handled explicitly now: exit 3, and a message
naming what the check needs. The fail-closed direction is stated rather than
inherited from shell semantics, and a test pins it — the defeating change in
either direction goes red.

_write_all's scratch file was stranded beside the registry if the write died
between create and replace. Cleaned up on every exit path. The prior registry
was never at risk either way: os.replace is the only thing that publishes.

Also pins normalization idempotence, which `bench state <id|url>` and
`bench rm <id|url>` both rely on: they normalize whatever they are handed, so
an id that did not normalize to itself would miss the row it names.
This commit is contained in:
vh
2026-09-22 13:33:59 -07:00
parent 8c7f2127eb
commit 0a2bb1d26c
4 changed files with 84 additions and 3 deletions
+12 -2
View File
@@ -258,9 +258,19 @@ def _write_all(root: Path, benches: dict[str, Bench]) -> None:
# of one fact is two things that can disagree.
for b in benches.values()
}
# Per-pid scratch name so two writers cannot share it: the atomic-replace
# promise is that a READER never sees a partial file, not that two writers
# never collide on the way there.
tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
os.replace(tmp, path)
try:
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
os.replace(tmp, path)
except BaseException:
# A write that dies between create and replace would otherwise strand
# the scratch file beside the registry forever. The prior registry is
# untouched either way — os.replace is the only thing that publishes.
tmp.unlink(missing_ok=True)
raise
class _Locked: