swayfx-mosaic/tooling/probes/step20c-static-runtime/support.py

84 lines
3.6 KiB
Python

"""Private probe support; no desktop environment or global process ownership.
WHY: failures must consume the same persistent experiment budget as successes.
CONTRACT: a stable flock file serializes durable atomic ledger updates.
INVARIANT: no reset/refund; only argv execution and owned process groups.
"""
import fcntl
import hashlib
import json
import os
from pathlib import Path
import signal
import subprocess
import tempfile
import time
REPO = Path(__file__).resolve().parents[3]
LIMITS = {'scene_build': 1, 'mosaic_build': 1, 'stage': 1, 'notices': 1,
'elf': 1, 'run': 1, 'control': 1, 'capture': 8, 'camera': 1}
def sha(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def save(lab, name, value):
(Path(lab)/'results'/(name+'.json')).write_text(json.dumps(value, indent=2)+'\n')
def reserve(lab, kind):
lab = Path(lab)
if kind not in LIMITS:
raise ValueError(kind)
with (lab/'budget.lock').open('a+') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
path = lab/'results/budget.json'
state = json.loads(path.read_text()) if path.exists() else {
'limits': LIMITS, 'used': dict.fromkeys(LIMITS, 0), 'events': []}
if state['limits'] != LIMITS or state['used'][kind] >= LIMITS[kind]:
raise RuntimeError('budget exhausted or changed: '+kind)
state['used'][kind] += 1
state['events'].append({'sequence': len(state['events']), 'kind': kind})
fd, temporary = tempfile.mkstemp(prefix='.budget-', dir=path.parent)
with os.fdopen(fd, 'w') as out:
json.dump(state, out, indent=2); out.write('\n'); out.flush(); os.fsync(out.fileno())
os.replace(temporary, path)
fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
return state
def environment():
env = {k:v for k,v in os.environ.items() if k not in (
'SWAYSOCK','I3SOCK','WAYLAND_DISPLAY','DISPLAY','XDG_RUNTIME_DIR',
'LD_LIBRARY_PATH','LD_PRELOAD','PKG_CONFIG_PATH')}
env.update(LC_ALL='C', PYTHONDONTWRITEBYTECODE='1', MESON_ROOT_CMD='/bin/false')
return env
def command(lab, label, argv, cwd=None, extra=None, timeout=600, allow_failure=False):
lab=Path(lab); path=lab/'raw'/(label+'.log')
assert not path.exists(), 'diagnostic already recorded: '+label
with path.open('wb') as out:
proc=subprocess.Popen(list(map(str,argv)),cwd=cwd,env={**environment(),**(extra or {})},
stdout=out,stderr=subprocess.STDOUT,start_new_session=True)
try:
deadline=time.monotonic()+timeout
while proc.poll() is None:
if time.monotonic()>=deadline or path.stat().st_size>64*1024*1024:
raise subprocess.TimeoutExpired(argv,timeout)
time.sleep(.1)
except subprocess.TimeoutExpired:
os.killpg(proc.pid,signal.SIGTERM)
try: proc.wait(timeout=3)
except subprocess.TimeoutExpired:
os.killpg(proc.pid,signal.SIGKILL);proc.wait()
try: os.killpg(proc.pid,signal.SIGKILL)
except ProcessLookupError: pass
raise
with (lab/'raw/commands.jsonl').open('a') as out:
out.write(json.dumps({'label':label,'argv':list(map(str,argv)), 'cwd':str(cwd),
'env':extra or {},'exit':proc.returncode})+'\n')
assert path.stat().st_size < 64*1024*1024, 'output bound exceeded'
if not allow_failure and proc.returncode:
raise RuntimeError(f'{label} exit={proc.returncode}; inspect {path}')
return path.read_text(errors='replace')