mirror of
https://github.com/Druthulu/BFM-decomp
synced 2026-09-28 14:59:48 -04:00
fix(config): the registry wiper found — four truncating writes, now atomic and collapse-refusing
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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
+4
-3
@@ -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")
|
||||
|
||||
@@ -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 `$(<bin>_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
|
||||
Reference in New Issue
Block a user