SniperGold_ML/engine/lock.py

101 lines
2.9 KiB
Python

"""Run lock management (spec 21, G-11).
Heartbeat refresh every 60 s; lock is stale after 300 s without refresh.
Default policy REFUSES to take over a lock, even a stale one; release of a
stale lock requires the explicit human ``--force-release-lock`` flag. The
Engine never kills or takes over a live process automatically.
"""
import json
import os
import time
class LockError(Exception):
pass
LOCK_HEARTBEAT_SEC = 60
LOCK_STALE_SEC = 300
def _read(path):
try:
with open(path, "rb") as fh:
return json.loads(fh.read().decode("utf-8"))
except FileNotFoundError:
return None
except Exception as e:
raise LockError("unreadable lock file: %s" % e)
def lock_age(info):
return time.time() - info.get("heartbeat_epoch_s", 0)
def lock_status(path):
"""Read-only lock inspection. Returns dict or None."""
if not os.path.exists(path):
return None
info = _read(path)
if info is None:
return None
info = dict(info)
info["_age_sec"] = lock_age(info)
info["_stale"] = info["_age_sec"] > LOCK_STALE_SEC
return info
class LockHandle:
def __init__(self, path, run_id, pid, force_release=False):
self.path = path
self.run_id = run_id
self.pid = pid
self.force_release = force_release
self.held = False
self._last_touch = 0.0
def acquire(self):
existing = _read(self.path)
now = time.time()
if existing is not None:
age = now - existing.get("heartbeat_epoch_s", 0)
if age <= LOCK_STALE_SEC:
raise LockError("lock held by live run (pid %s, run %s)"
% (existing.get("pid"), existing.get("run_id")))
if not self.force_release:
raise LockError(
"stale lock present (age %.0f s); refusing takeover; "
"explicit --force-release-lock is required (human gate G-11)"
% age)
os.makedirs(os.path.dirname(os.path.abspath(self.path)), exist_ok=True)
self._write(now)
self.held = True
def _write(self, now):
info = {"pid": self.pid, "run_id": self.run_id,
"heartbeat_epoch_s": now}
tmp = self.path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(info, fh, sort_keys=True, separators=(",", ":"))
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, self.path)
def touch(self):
if not self.held:
return
now = time.time()
if now - self._last_touch < LOCK_HEARTBEAT_SEC / 2.0:
return
self._write(now)
self._last_touch = now
def release(self):
if not self.held:
return
try:
os.remove(self.path)
except FileNotFoundError:
pass
self.held = False