"""Keeps the match and emulator results that the Logbook's exact and verified tiers come from current, so nobody has to remember to rerun them: match the whole-program check of main ([reverse] ltcg: `ltcg.py check --build --decomp
--json`), written to [reverse] match_results; when main's HEAD has moved, at most every match_every_minutes. behaviour the emulator over every readable file ([reverse] diffrun_command: `diffrun.py --readable --jobs 4 --json`), written to [reverse] diffrun_results, its printout to readable.txt beside it; when main's HEAD has moved, at most every behaviour_every_hours and only inside behaviour_window (local hours). Configured per project in the [logbook] table (main is [logbook] decomp): results_refresh = true off unless set match_every_minutes = 60 behaviour_every_hours = 12 behaviour_window = "01-06" from 01:00 to 06:00 local time; "" for any hour One run at a time (the server runs one thread for every project), at the lowest process priority, into a .partial file that replaces the results only once it is complete. The match run holds the same per-checkout lock as re_match_program, so it waits its turn behind any other whole-program build of main. State (per job: the commit, start and finish of the last run, its counts and error; a run in progress) is kept in the logbook folder as results.json, so a restarted server neither repeats a run nor starts a second one beside a run its predecessor left going (that run's output is adopted when it finishes). `harbor logbook results --job match|behaviour` asks the server for a run now, whatever the schedule; it is a file, refresh-.request, in the same folder.""" import datetime import json import os import subprocess import sys import tempfile import time import traceback from pathlib import Path from . import logbook JOBS = ("match", "behaviour") RESULTS_KEY = {"match": "match_results", "behaviour": "diffrun_results"} DEFAULTS = {"results_refresh": False, "match_every_minutes": 60, "behaviour_every_hours": 12, "behaviour_window": "01-06"} TIMEOUT = {"match": 2 * 3600, "behaviour": 8 * 3600} # generous: the runs are at idle priority on a busy machine TICK = 120 # seconds between looks START_DELAY = 60 # let a restarted server settle first STATE = "results.json" # ---------------------------------------------------------------------------------------------- configuration def settings(project): lb = getattr(project, "logbook", None) or {} return {k: lb.get(k, v) for k, v in DEFAULTS.items()} def window(text): """"01-06" -> (1, 6): from 01:00 up to 06:00. "22-04" wraps past midnight. ""/None -> None (any hour).""" text = str(text or "").strip() if not text or text.lower() in ("any", "always"): return None start, end = (int(part) for part in text.split("-", 1)) if not (0 <= start <= 24 and 0 <= end <= 24): raise ValueError(f"behaviour_window hours must be 0-24: {text!r}") return start, end def in_window(span, hour): if span is None: return True start, end = span if start == end: return True return start <= hour < end if start < end else hour >= start or hour < end def configured(project, job): """What a job needs from the project, or None when it cannot run here.""" rev = project.reverse or {} lb = getattr(project, "logbook", None) or {} if not lb.get("decomp") or not rev.get(RESULTS_KEY[job]): return None if job == "match" and rev.get("ltcg"): return {"script": rev["ltcg"], "results": rev["match_results"]} if job == "behaviour" and rev.get("diffrun_command"): command = rev["diffrun_command"] return {"command": command if isinstance(command, list) else [command], "results": rev["diffrun_results"]} return None # ---------------------------------------------------------------------------------------------- state def _folder(project): return logbook.folder(project) def load_state(project): path = _folder(project) / STATE try: state = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} except (OSError, ValueError): state = {} state.setdefault("jobs", {}) return state def save_state(project, state): path = _folder(project) / STATE partial = path.with_name(path.name + ".partial") partial.write_text(json.dumps(state, indent=1), encoding="utf-8") os.replace(partial, path) def request(project, job): """Ask the server's refresher for a run of `job` at its next look, whatever the schedule.""" if job not in JOBS: raise ValueError(f"job is one of {', '.join(JOBS)}") path = _folder(project) / f"refresh-{job}.request" path.write_text(_iso(time.time()), encoding="utf-8") return {"requested": job, "file": str(path)} def _requested(project): return {job for job in JOBS if (_folder(project) / f"refresh-{job}.request").exists()} def _clear_request(project, job): try: (_folder(project) / f"refresh-{job}.request").unlink() except FileNotFoundError: pass def _iso(ts): return datetime.datetime.fromtimestamp(ts, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") # ---------------------------------------------------------------------------------------------- the decision def due(cfg, state, job, head, now, hour, requested=()): """(True, why) when `job` should run now; (False, why not) otherwise. Pure: the tests drive it directly.""" if job in requested: return True, "requested" if not cfg.get("results_refresh"): return False, "results_refresh is off" last = state.get("jobs", {}).get(job, {}) if last.get("head") == head: return False, "already at main's HEAD" if job == "match": every = float(cfg.get("match_every_minutes", 60)) * 60 else: every = float(cfg.get("behaviour_every_hours", 12)) * 3600 if last.get("started_ts") and now - last["started_ts"] < every: return False, f"last run started {int((now - last['started_ts']) / 60)} min ago" if job == "behaviour" and not in_window(window(cfg.get("behaviour_window")), hour): return False, f"outside behaviour_window {cfg.get('behaviour_window')}" return True, "main moved" def choose(cfg, state, head, now, hour, requested=(), available=JOBS): """The one job to run now (the match first: it is quicker and more often due), or (None, reasons).""" reasons = {} for job in JOBS: if job not in available: reasons[job] = "not configured" continue ok, why = due(cfg, state, job, head, now, hour, requested) if ok: return job, why reasons[job] = why return None, reasons # ---------------------------------------------------------------------------------------------- running def _head(project): from .logbook_auto import _git return _git(project.logbook["decomp"], "rev-parse", "HEAD").strip() def _spawn(argv, cwd, stdout, stderr): flags = 0 if os.name == "nt": flags = subprocess.IDLE_PRIORITY_CLASS | subprocess.CREATE_NO_WINDOW # children (cl.exe) inherit idle env = dict(os.environ, PYTHONIOENCODING="utf-8") return subprocess.Popen(argv, cwd=str(cwd), stdin=subprocess.DEVNULL, stdout=stdout, stderr=stderr, env=env, creationflags=flags) def _kill_tree(pid): if os.name == "nt": subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) else: try: os.kill(pid, 9) except OSError: pass def _alive(pid): from .reverse import pid_alive return bool(pid) and pid_alive(pid) def _paths(project, job): target = Path(configured(project, job)["results"]) paths = {"target": target, "partial": target.with_name(target.name + ".partial"), "log": _folder(project) / f"{job}.log"} if job == "behaviour": paths["text"] = target.with_suffix(".txt") paths["text_partial"] = target.with_suffix(".txt.partial") return paths def _argv(project, job, out): need = configured(project, job) if job == "match": script = Path(need["script"]) return [sys.executable, "-B", str(script), "check", "--build", "--decomp", str(Path(project.logbook["decomp"])), "--json", str(out)], script.parent command = [str(c) for c in need["command"]] return command + ["--readable", "--jobs", "4", "--json", str(out)], Path(command[-1]).parent def _match_lock(project): """The lock re_match_program holds around a whole-program build of main (harbor.reverse.match_program).""" from .reverse import _exclusive rev = project.reverse or {} return _exclusive(Path(rev.get("debugger_temp") or tempfile.gettempdir()) / "ltcg-main.lock", wait_seconds=0) def _counts(rows, field): counts = {} for r in rows: counts[str(r.get(field))] = counts.get(str(r.get(field)), 0) + 1 return counts def _complete(project, state, run, now, returncode=None): """Move a finished run's output into place, or record why it failed. Clears state['running'].""" job, paths = run["job"], _paths(project, run["job"]) last = dict(state["jobs"].get(job, {})) last.update(started_ts=run["started_ts"], started=_iso(run["started_ts"]), finished=_iso(now)) try: partial = paths["partial"] text = partial.read_text(encoding="utf-8") if partial.exists() else "" if not text.strip(): ended = "the run was interrupted" if returncode is None else \ returncode if isinstance(returncode, str) else f"exit code {returncode}" raise RuntimeError(f"no results were written ({ended}); see {paths['log']}") rows = json.loads(text) if not isinstance(rows, list) or not rows: raise RuntimeError("the results file is empty") os.replace(partial, paths["target"]) if job == "behaviour" and paths["text_partial"].exists(): os.replace(paths["text_partial"], paths["text"]) last.update(head=run["head"], ok=True, error=None, functions=len(rows), counts=_counts(rows, "status" if job == "match" else "verdict"), results_mtime_ns=paths["target"].stat().st_mtime_ns) except (OSError, ValueError, RuntimeError) as error: last.update(ok=False, error=str(error)[:500]) state["jobs"][job] = last state.pop("running", None) save_state(project, state) return {"job": job, "ok": last["ok"], "head": run["head"][:8], **({"error": last["error"]} if not last["ok"] else {})} def run_job(project, state, job, head, now, reason=""): paths = _paths(project, job) argv, cwd = _argv(project, job, paths["partial"]) for stale in [paths["partial"]] + ([paths["text_partial"]] if job == "behaviour" else []): if stale.exists(): stale.write_bytes(b"") # a leftover of an interrupted run is never taken for this one run = {"job": job, "pid": None, "head": head, "started_ts": now, "reason": reason} clock = time.monotonic() returncode = None with open(paths["log"], "wb") as log: stdout = open(paths["text_partial"], "wb") if job == "behaviour" else log try: log.write(f"{_iso(now)} {job} at {head[:8]} ({reason}): {' '.join(argv)}\n".encode("utf-8")) log.flush() proc = _spawn(argv, cwd, stdout, log if job == "behaviour" else subprocess.STDOUT) run["pid"] = proc.pid state["running"] = {**run, "started": _iso(now)} save_state(project, state) try: returncode = proc.wait(timeout=TIMEOUT[job]) except subprocess.TimeoutExpired: _kill_tree(proc.pid) proc.wait() returncode = f"timed out after {TIMEOUT[job] // 60} min" except OSError as error: returncode = f"could not start: {error}" finally: if stdout is not log: stdout.close() return _complete(project, state, run, now + (time.monotonic() - clock), returncode) def tick(project, now=None, hour=None): """One look: finish off a run an earlier server left, then start the job that is due (and wait for it).""" now = time.time() if now is None else now hour = datetime.datetime.now().hour if hour is None else hour available = [job for job in JOBS if configured(project, job)] if not available: return {"idle": "not configured"} state = load_state(project) running = state.get("running") if running: if _alive(running.get("pid")) and now - running.get("started_ts", 0) < TIMEOUT.get(running.get("job"), 0): return {"busy": running["job"], "pid": running["pid"]} return {"adopted": _complete(project, state, running, now)} requested = _requested(project) if not requested and not settings(project).get("results_refresh"): return {"idle": "results_refresh is off"} head = _head(project) job, why = choose(settings(project), state, head, now, hour, requested, available) if not job: return {"idle": why, "head": head[:8]} if job == "match": from .util import HarborError try: with _match_lock(project): _clear_request(project, job) return run_job(project, state, job, head, now, why) except HarborError: return {"waiting": "another whole-program build of main holds the lock"} _clear_request(project, job) return run_job(project, state, job, head, now, why) # ---------------------------------------------------------------------------------------------- reading def freshness(project): """For the progress view: which commit the exact and verified tiers are from, and how old they are.""" state = load_state(project) out = {} for job in JOBS: need = configured(project, job) target = Path(need["results"]) if need else Path((project.reverse or {}).get(RESULTS_KEY[job]) or "") last = state["jobs"].get(job, {}) try: mtime_ns = target.stat().st_mtime_ns if str(target) not in ("", ".") else None except OSError: mtime_ns = None if mtime_ns is None: out[job] = None continue ours = last.get("ok") and last.get("results_mtime_ns") == mtime_ns out[job] = {"sha": last["head"][:7] if ours else None, "at": last.get("finished") if ours else _iso(mtime_ns / 1e9), "error": last.get("error") if last.get("ok") is False else None} running = state.get("running") out["running"] = {"job": running["job"], "started": running.get("started")} if running else None out["enabled"] = bool(settings(project).get("results_refresh")) return out def status(project, now=None, hour=None): """For `harbor logbook results`: the state plus what the next look would do.""" now = time.time() if now is None else now hour = datetime.datetime.now().hour if hour is None else hour state = load_state(project) available = [job for job in JOBS if configured(project, job)] try: head = _head(project) if available else "" except RuntimeError: head = "" nxt = {job: due(settings(project), state, job, head, now, hour, _requested(project)) for job in available} return {"settings": settings(project), "head": head[:8], "jobs": state["jobs"], "running": state.get("running"), "requested": sorted(_requested(project)), "next": {job: {"due": ok, "why": why} for job, (ok, why) in nxt.items()}, "state_file": str(_folder(project) / STATE)} def loop(registry): """Server thread: one look every two minutes at every project (results_refresh on, or a run requested).""" time.sleep(START_DELAY) while True: for name in registry.names(): try: h, _ = registry.get(name) tick(h.project) except Exception: traceback.print_exc() time.sleep(TICK)