#!/usr/bin/env python3 """Run explicit row276 changes only under Sage's grant. --static runs no tests.""" import argparse import hashlib import json import os from pathlib import Path import signal import subprocess import time HERE = Path(__file__).resolve().parent REPO = HERE.parents[3] def digest(data): return hashlib.sha256(data).hexdigest() def busy(): p = subprocess.run(['bash', '/mnt/storage/scratch/sage-busy-now.sh'], text=True, capture_output=True) if p.returncode or p.stdout.splitlines()[-1:] != ['CLEAR']: raise RuntimeError('busy refusal: ' + p.stdout + p.stderr) def run(command, path, seconds=60): if command[:2] == ['node', '--test']: limits = json.loads((HERE/'WHOLE-GATE-LIMITS.json').read_text())['files'] limit = limits.get(command[-1], 900) command = ['timeout','-k','10',str(limit),*command] seconds = limit + 30 busy() started = time.monotonic() command_file = path.with_suffix('.command.json') command_file.write_text(json.dumps(command)) # Keep command text out of wrapper argv: the busy guard scans every argv. # launch.py execs the structured command. bash owns a pipefail/tee pipeline. p = subprocess.Popen(['bash','-o','pipefail','-c', 'python3 "$1" "$2" 2>&1 | tee "$3"', '--',str(HERE/'launch.py'),str(command_file),str(path)], cwd=REPO,start_new_session=True) expired = False try: code = p.wait(timeout=seconds) expired = code in (124, 137) except subprocess.TimeoutExpired: expired = True os.killpg(p.pid, signal.SIGKILL) code = p.wait() finally: try: os.killpg(p.pid, signal.SIGKILL) except ProcessLookupError: pass return dict(code=code, timeout=expired, seconds=round(time.monotonic()-started, 3), log=str(path)) def main(): ap = argparse.ArgumentParser() ap.add_argument('--static', action='store_true') ap.add_argument('--grant') ap.add_argument('--out', type=Path) args = ap.parse_args() definitions = json.loads((HERE/'changes.json').read_text()) checked = [] for d in definitions: for file in [d['file'], *d.get('also_files', [])]: original = (REPO/file).read_bytes() count = original.count(d['old'].encode()) if count != 1: raise RuntimeError(f"{d['id']}: {file}: target occurs {count} times") changed = original.replace(d['old'].encode(), d['new'].encode(), 1) checked.append(dict(id=d['id'], file=file, occurrences=count, before=digest(original), after=digest(changed))) if args.static: print(json.dumps(checked, indent=2)) return if not args.grant or not args.out: ap.error('execution needs --grant and --out') if not os.environ.get('TMPDIR', '').startswith('/mnt/storage/scratch/tmp/g'): raise RuntimeError('TMPDIR must be the granted scratch directory') status = subprocess.check_output(['git','status','--porcelain','--untracked-files=no'],cwd=REPO) args.out.mkdir(parents=True, exist_ok=True) results = dict(grant=args.grant, static=checked, bases={}, changes=[]) failures = 0 for d in definitions: key = d['killer']+'|'+d['pattern'] command = ['node','--test','--test-reporter=tap','--test-name-pattern',d['pattern'],d['killer']] if key not in results['bases']: base = run(command, args.out/(d['id']+'-base.log')) results['bases'][key] = base (args.out/'results.json').write_text(json.dumps(results,indent=2)+'\n') if base['code'] != 0 or base['seconds'] > 30: raise RuntimeError('base failed or exceeded 30 seconds: '+d['id']) originals = {REPO/file: (REPO/file).read_bytes() for file in [d['file'], *d.get('also_files', [])]} try: for target, original in originals.items(): target.write_bytes(original.replace(d['old'].encode(),d['new'].encode(),1)) pre = subprocess.run(['bash','-n',str(target)],capture_output=True) if pre.returncode: raise RuntimeError('invalid syntax: '+d['id']) result = run(command,args.out/(d['id']+'.log')) result.update(id=d['id'], base_seconds=results['bases'][key]['seconds'], outcome='timeout' if result['timeout'] else 'killed' if result['code'] != 0 else 'survived') results['changes'].append(result) if result['timeout']: raise RuntimeError('timeout requires Sage/Mos classification: '+d['id']) if result['outcome'] != 'killed': failures += 1 finally: for target, original in originals.items(): target.write_bytes(original) assert target.read_bytes() == original assert subprocess.check_output(['git','status','--porcelain','--untracked-files=no'],cwd=REPO) == status (args.out/'results.json').write_text(json.dumps(results,indent=2)+'\n') if failures >= 1: raise RuntimeError('first unexpected failure: end the row276 window') print(json.dumps(results,indent=2)) if __name__ == '__main__': main()