Files
BFM-decomp/tools/shared_lock.py
T
Drew T e0eaa16741 feat(phase-30 S45p7): Stage 1 complete — shared-state RW lock + 15 more banks (wave-3 revived)
STAGE 1 of docs/concurrency-design.md, landed and negative-control proven.

tools/shared_lock.py (NEW) — one reader/writer flock over the FLEET-SHARED state
(src/shared/*, config/overlays.mk, config/dedup.us.yaml, the overlay .c files
propagation rewrites). Per-binary resources keep gate_stage's existing per-binary flock.
  - gate_stage takes it SHARED when the gate writes no shared state, EXCLUSIVE when it
    does (propagate, or the arity pre-pass enabled) -- so distinct-binary gates still run
    concurrently but can never overlap a writer.
  - dedup_propagate and fix_arity_callers --apply take it EXCLUSIVE.
  - NESTING-AWARE: gate_stage SPAWNS both writers, so a naive child lock would deadlock
    against the parent. The holder exports BFM_SHARED_LOCK_HELD and children inherit.

NEGATIVE CONTROLS (all pass):
  NC1 a held SHARED lock refuses a non-blocking exclusive writer, loudly, naming the lock
  NC2 parent-holds/child-inherits does NOT deadlock (the real risk in this design)
  NC3 two readers acquire concurrently (0.00s) -- phase-B parallelism preserved

bulk_harvest docstring CORRECTED: its "propagation is the ONLY writer of the shared
engine_core.h" claim was FALSE as written and had been asserted for phases (F1 -- the
arity pre-pass writes it from inside every worker). Now states what is actually true,
under which two conditions, plus the one-line assertion that detects a violation.

BANKS: wave-3's drafts re-gated on a CLEAN tree -> 15 of 20 banked. The same drafts
previously reported 0 banked / 20 near -- that verdict was 100% an artifact of the
broken tree, which is why they were held as UNJUDGED rather than accepted as failures.
check-all 213 passed / 0 failed. F1 bracketing assertion CLEAN.

Session total banked: 44 functions + func_8015C030 propagated x7.
2026-08-07 18:57:10 -06:00

92 lines
3.7 KiB
Python

#!/usr/bin/env python3
"""shared_lock.py — one reader/writer lock over the FLEET-SHARED source state.
Stage 1 of docs/concurrency-design.md.
WHAT IT PROTECTS
src/shared/engine_core.h, src/shared/engine_types.h, config/overlays.mk,
config/dedup.us.yaml, and the per-overlay .c files that propagation rewrites.
These are the only files whose writers span binaries; every per-binary resource
(build/<bin>/**, src/<bin>/*.c, asm/<bin>/**) is already isolated and is guarded by
gate_stage's existing per-binary flock.
WHY
A gate that merely READS the shared header can safely run beside other such gates —
that is the parallel lane bulk_harvest wants. But a writer (dedup_propagate,
fix_arity_callers --apply) racing a reader can let a gate compute a verdict against a
TRANSIENT tree state that never exists again ("verdict decay", design §2.2b). That
cannot false-bank — the gate compares against config/check.<bin>.sha, which no stage
writes, and INCLUDE_ASM pastes the original bytes — but it mints banks whose C is not
byte-correct against the state actually committed, and the R22 then fails on a batch
that "all gated green".
MODES
shared() readers — parallel gates that do not write shared state
exclusive() writers — dedup_propagate, fix_arity_callers --apply, serial-lane gates
NESTING
gate_stage SPAWNS both writers as subprocesses. A child re-acquiring exclusively while
the parent holds the lock would deadlock, so the parent exports BFM_SHARED_LOCK_HELD=1
and children treat the lock as already held. The parent is responsible for taking the
correct mode for what it is about to do (EX if it will propagate or run the arity
pre-pass, SH otherwise) — see gate_stage.run_gate.
"""
import contextlib
import fcntl
import os
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LOCK = os.path.join(REPO, ".run/auto/shared_state.lock")
ENV = "BFM_SHARED_LOCK_HELD"
@contextlib.contextmanager
def hold(exclusive, nonblocking=False, announce=None):
"""Hold the shared-state lock. No-op if an ancestor already holds it (see NESTING)."""
if os.environ.get(ENV):
yield "inherited"
return
os.makedirs(os.path.dirname(LOCK), exist_ok=True)
fh = open(LOCK, "w")
mode = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
if nonblocking:
mode |= fcntl.LOCK_NB
try:
fcntl.flock(fh, mode)
except OSError:
fh.close()
raise SystemExit(
f"[shared-lock] REFUSED: another process holds {os.path.relpath(LOCK, REPO)}"
+ (f" ({announce})" if announce else "")
+ ".\n A fleet-shared writer may not run while gates are reading shared state"
" (docs/concurrency-design.md §2.2b). Wait for it, or re-run without --nb.")
prev = os.environ.get(ENV)
os.environ[ENV] = "1"
try:
yield "exclusive" if exclusive else "shared"
finally:
if prev is None:
os.environ.pop(ENV, None)
else:
os.environ[ENV] = prev
fcntl.flock(fh, fcntl.LOCK_UN)
fh.close()
def held_by_ancestor():
return bool(os.environ.get(ENV))
if __name__ == "__main__":
# Test helper: hold the lock for N seconds so a negative control can observe a refusal.
# python3 tools/shared_lock.py shared 30
import time
mode = (sys.argv[1] if len(sys.argv) > 1 else "shared").lower()
secs = float(sys.argv[2]) if len(sys.argv) > 2 else 5.0
os.environ.pop(ENV, None)
with hold(exclusive=(mode == "exclusive")) as m:
print(f"[shared-lock] holding {m} for {secs}s (pid {os.getpid()})", flush=True)
time.sleep(secs)
print("[shared-lock] released", flush=True)