From d801b8f1778d48040eb2ec01fda67bedcef35667 Mon Sep 17 00:00:00 2001 From: Drew T <50529377+Druthulu@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:48:45 -0600 Subject: [PATCH] =?UTF-8?q?fix(config):=20the=20registry=20wiper=20found?= =?UTF-8?q?=20=E2=80=94=20four=20truncating=20writes,=20now=20atomic=20and?= =?UTF-8?q?=20collapse-refusing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROOT CAUSE of both wipes today. config/overlays.mk was rewritten in four places with open(mk, "w").write(txt) (jr_isolate_all.py:593, jtbl_carve.py:1077/1118/1165) — which TRUNCATES to zero first and only then writes. Three ways that loses the registry: the process dies between truncate and write (empty file); another process reads inside that window (sees an empty registry); two writers interleave (a partial line lands after the last good one — this morning's file ended in a stray `uto.txt` fragment, exactly that fingerprint). The jtbl carve automation runs AT THE GATE, which is when all three wipes happened, and ONE_PER_GID=0 made it far likelier by putting many more carve members in every wave. BLAST RADIUS, measured twice: with no binaries registered, main's object glob sweeps every overlay's nonmatchings/*.s into MAIN's OBJS and assembles them standalone, so main cannot build, the main lane correctly refuses against a RED baseline, and every overlay gate rejects every draft. Waves dn/do banked 0/224 and 0/236; waves ei..em banked 2 of ~1,100 with 675 backlog rows reading "match_one MATCH but the whole-binary gate rejected" — the local oracle proving the drafts were byte-correct while the tree could not build them. tools/mk_write.py is now the only writer: atomic (tmp + fsync + os.replace, so no reader ever sees a partial file and a crash leaves the original intact), collapse-refusing (a rewrite below 80% of the current line count raises), and flock-serialized. TWO HONEST LIMITS, recorded rather than papered over: * Callers still READ outside the lock, so two concurrent carves can each read-edit-write and the second drops the first's line. That is a LOST UPDATE — a missing line, not a wiped file — caught downstream by the fleet check and jtbl_pads_fix. Closing it means holding the lock across read-modify-write in every caller. * The guard now also refuses when the CURRENT file is under 100 lines. That case cost me directly: my own verification control overwrote a registry a carve had truncated seconds earlier, because the collapse check was skipped when the old file was empty. A control must assert its precondition; mine did not, and now the tool enforces it instead. --- tools/jr_isolate_all.py | 3 +- tools/jtbl_carve.py | 7 ++-- tools/mk_write.py | 89 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 tools/mk_write.py diff --git a/tools/jr_isolate_all.py b/tools/jr_isolate_all.py index 393a0f281..49f57ec9c 100644 --- a/tools/jr_isolate_all.py +++ b/tools/jr_isolate_all.py @@ -32,6 +32,7 @@ import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import overlay_src_split as oss +import mk_write as MKW # atomic, collapse-refusing overlays.mk writer (P31 S60) REPO = oss.REPO O0_SUFFIX = ("_o0", "_o0b") @@ -590,7 +591,7 @@ def repoint_overlays_mk(carve_renames, dry, ov=None, cfg_lines=None): txt = re.sub(pads_pat, lambda m: m.group(1) + new_sub + m.group(2), txt, count=1, flags=re.M) changed.append(f"JTBL_PADS {old_sub}.o -> {new_sub}.o") if not dry: - open(mk, "w").write(txt) + MKW.write_overlays_mk(txt, path=mk) return changed diff --git a/tools/jtbl_carve.py b/tools/jtbl_carve.py index d027b6717..8fbaa463d 100644 --- a/tools/jtbl_carve.py +++ b/tools/jtbl_carve.py @@ -46,6 +46,7 @@ import os import re import subprocess import sys +import mk_write as MKW # atomic, collapse-refusing overlays.mk writer (P31 S60) REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -1074,7 +1075,7 @@ def set_overlays_var(ov, args): if anchor not in txt: sys.exit(f"jtbl_carve: no {anchor} anchor in overlays.mk") txt = txt.replace(anchor, anchor + "\n" + var, 1) - open(mk, "w").write(txt) + MKW.write_overlays_mk(txt, path=mk) def set_pads_vars(ov, pads_map): @@ -1115,7 +1116,7 @@ def set_pads_vars(ov, pads_map): if not m: sys.exit(f"jtbl_carve: no {ov}_JTBL_INTERLEAVE line to anchor JTBL_PADS on") txt = txt[:m.end()] + "\n" + block + txt[m.end():] - open(mk, "w").write(txt) + MKW.write_overlays_mk(txt, path=mk) for sub in set(before) | set(after): if before.get(sub) != after.get(sub): obj = os.path.join(REPO, f"build/src/{ov}/{sub}.o") @@ -1162,7 +1163,7 @@ def revert(ov): if not m2: sys.exit(f"jtbl_carve: no {ov}_JTBL_INTERLEAVE line to anchor committed JTBL_PADS on") txt = txt[:m2.end()] + "\n" + "\n".join(committed_lines) + txt[m2.end():] - open(mk, "w").write(txt) + MKW.write_overlays_mk(txt, path=mk) for sub in set(now_pads) | set(committed_pads): if now_pads.get(sub) != committed_pads.get(sub): obj = os.path.join(REPO, f"build/src/{ov}/{sub}.o") diff --git a/tools/mk_write.py b/tools/mk_write.py new file mode 100644 index 000000000..0b656b034 --- /dev/null +++ b/tools/mk_write.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""mk_write.py — the ONLY safe way to rewrite config/overlays.mk. + +WHY THIS EXISTS (P31 S60 — it cost the campaign two registry wipes in one day). + +config/overlays.mk is the generated registry that defines all 141 overlay binaries: EXE paths, +VRAM bases, ASM_DIR/SRC_DIR roots, symbol files, and every §8e JTBL_PADS spec. Four separate code +paths rewrote it with + + open(mk, "w").write(txt) + +which truncates the file to ZERO first and only then writes. Three ways that loses the registry: + + * the process dies between truncate and write -> an EMPTY file; + * another process reads it inside that window -> it sees an empty or partial registry; + * two writers interleave -> a partial line lands after the last good one. The morning's wipe + left exactly that fingerprint: a stray `uto.txt` fragment after the final entry. + +The blast radius is total and silent. With no binaries registered, main's object glob (which +prunes siblings via `$(_ASM_DIR)`) sweeps every overlay's nonmatchings/*.s into MAIN's OBJS +and assembles them standalone; main cannot build, the main lane correctly refuses to gate against +a RED baseline, and EVERY overlay gate rejects EVERY draft because no overlay can build. Measured +twice: waves dn/do banked 0 of 224 and 0 of 236, and waves ei..em banked 2 of ~1,100 with 675 +backlog rows reading "match_one MATCH but the whole-binary gate rejected" — the local oracle +proving the drafts were byte-correct while the tree could not build them. + +WHAT THIS GUARANTEES + 1. ATOMIC. Write a sibling tmp file, fsync, then os.replace() — a rename on the same filesystem + is atomic, so no reader ever observes a partial registry and a crash leaves the original + untouched. + 2. REFUSES A COLLAPSE. A rewrite that would drop below `min_ratio` of the current line count is + refused loudly (R43: refuse, never mishandle). A carve edits a line or two; it never removes + 20% of the file, so this cannot fire on legitimate work. + 3. SERIALIZED. An flock so two concurrent carves cannot interleave their writes. + +WHAT IT DOES NOT FIX, stated honestly: callers still READ outside the lock, so two carves can each +read, edit, and write, and the second silently drops the first's line. That is a LOST UPDATE — a +missing line, not a wiped file — and it is caught downstream by the fleet check and jtbl_pads_fix. +Closing it properly means holding the lock across read-modify-write in every caller. +""" +import fcntl +import os + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +MK = os.path.join(REPO, "config", "overlays.mk") +LOCK = os.path.join(REPO, ".run", "auto", "overlays_mk.lock") +FLOOR = 100 # a healthy registry is ~5,000 lines; below this it is already broken + + +def write_overlays_mk(txt, path=MK, min_ratio=0.8): + """Replace overlays.mk atomically, refusing any rewrite that collapses it. + + Returns the number of lines written. Raises RuntimeError on a refused collapse — callers + should let that propagate: a carve that cannot safely record its spec must fail loudly, not + continue with an unrecorded one. + """ + new_lines = txt.count("\n") + try: + with open(path, errors="replace") as fh: + old_lines = sum(1 for _ in fh) + except OSError: + old_lines = 0 + + os.makedirs(os.path.dirname(LOCK), exist_ok=True) + with open(LOCK, "w") as lk: + fcntl.flock(lk, fcntl.LOCK_EX) + # AN ALREADY-EMPTY REGISTRY IS NOT A LICENCE TO WRITE (P31 S60, learned the hard way: my + # own verification control clobbered a registry that a carve had truncated seconds + # earlier, because the collapse check was skipped when old_lines was 0). A healthy file is + # ~5,000 lines; anything under FLOOR means the registry is ALREADY broken and the right + # move is to refuse and let a human restore it from git, not to layer another write on top. + if old_lines < FLOOR: + raise RuntimeError( + f"mk_write: {path} currently has {old_lines} lines — the registry is already " + f"broken (healthy is ~5,000). REFUSING to write over it; restore it with " + f"`git checkout HEAD -- config/overlays.mk` first. Nothing was written.") + if old_lines and new_lines < old_lines * min_ratio: + raise RuntimeError( + f"mk_write: REFUSING to write {path} with {new_lines} lines — it currently has " + f"{old_lines}. A carve edits a line or two; this would drop " + f"{100 * (1 - new_lines / max(old_lines, 1)):.0f}% of the registry. " + f"Nothing was written.") + tmp = path + ".tmp" + with open(tmp, "w") as fh: + fh.write(txt) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, path) + return new_lines