mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
566 lines
30 KiB
Python
566 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
# MobileGL - tools/trace_replay/run_tcp_matrix.py
|
|
# Copyright (c) 2025-2026 MobileGL-Dev
|
|
# Licensed under the GNU Lesser General Public License v3.0:
|
|
# https://www.gnu.org/licenses/gpl-3.0.txt
|
|
# https://www.gnu.org/licenses/lgpl-3.0.txt
|
|
# SPDX-License-Identifier: LGPL-3.0-only
|
|
# End of Source File Header
|
|
|
|
"""Run manifest-selected TCP retraces from a CTest JSON catalog, with resumable evidence.
|
|
|
|
Requires Linux/WSL process groups. The old local CTest/manifest TIMEOUT is not a
|
|
TCP deadline: log inactivity and an explicit absolute ceiling are separate.
|
|
|
|
BOTH BACKENDS, ONE DRIVER (P7). `--backend` selects DirectGLES (the default, and the P6.5
|
|
residual 39-case golden matrix) or DirectVulkan (P7 exit gate 3's device matrix). The three
|
|
things that have to be per backend are, and the rest is deliberately shared:
|
|
|
|
* SELECTION - a case is planned for a backend only if its own `ci_backends` names it, so the
|
|
DirectVulkan-only iris case appears in a DirectVulkan sweep and not in a DirectGLES one,
|
|
and a `--case` named explicitly that does not run a requested backend is an error rather
|
|
than a silent one-plan-fewer;
|
|
* GOLDEN AND THRESHOLD - from `backend_overrides.<backend>` in trace_cases.json when the case
|
|
declares one, and a run that matched ANOTHER backend's declared golden is refused by name
|
|
(the shared `alternate_golden` is an OR across backends and cannot express this);
|
|
* RESULT NAMING AND CHECKPOINT KEY - `<backend>/credit-<n>/<case>`, which already carried the
|
|
backend, plus the golden/threshold recorded in each results.json row.
|
|
|
|
`--require-run-ahead` is orthogonal to the backend and applies to both.
|
|
"""
|
|
from __future__ import annotations
|
|
import argparse
|
|
import contextlib
|
|
import ctypes
|
|
import fcntl
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import signal
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import uuid
|
|
|
|
from trace_cases import (backend_override_goldens, case_for_backend, ci_backends,
|
|
load_trace_case_manifest)
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
OWNED_ENV = {"MOBILEGL_TRANSPORT", "MOBILEGL_IPC_CONTROL", "MOBILEGL_IPC_DATA",
|
|
"MOBILEGL_IPC_TOKEN", "MOBILEGL_IPC_DIAL", "MOBILEGL_IPC_ROLE",
|
|
"MOBILEGL_IPC_SERVER_PATH", "MOBILEGL_LOG_FILE_PATH"}
|
|
|
|
|
|
def read_json(path):
|
|
def invalid(value):
|
|
raise ValueError("non-finite JSON number: " + value)
|
|
return json.loads(Path(path).read_text(encoding="utf-8"), parse_constant=invalid)
|
|
|
|
|
|
def digest(path):
|
|
h = hashlib.sha256()
|
|
with Path(path).open("rb") as stream:
|
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def fingerprint(value):
|
|
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
|
|
|
|
def atomic_json(path, value):
|
|
temporary = path.with_name(path.name + "." + uuid.uuid4().hex + ".tmp")
|
|
try:
|
|
with temporary.open("w", encoding="utf-8") as stream:
|
|
json.dump(value, stream, ensure_ascii=False, indent=2, allow_nan=False)
|
|
stream.write("\n")
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary, path)
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def select_cases(manifest_path, requested, backends=None):
|
|
manifest = load_trace_case_manifest(manifest_path)
|
|
raw = read_json(manifest_path)
|
|
explicit = {case["name"]: case.get("split", raw.get("defaults", {}).get("split"))
|
|
for case in raw["cases"]}
|
|
cases = {case["name"]: case for case in manifest["cases"]}
|
|
names = requested or [case["name"] for case in cases.values() if case["split"]]
|
|
if len(names) != len(set(names)):
|
|
raise ValueError("duplicate --case")
|
|
selected = []
|
|
for name in names:
|
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", name) or name not in cases:
|
|
raise ValueError("unknown/unsafe case: " + name)
|
|
case = cases[name]
|
|
# An explicit non-CI workload, such as rd12, is useful for extra golden/perf runs.
|
|
# An explicit split:false remains a refusal, including in this explicit selection.
|
|
if explicit[name] is False or (not case["split"] and case.get("ci", True)):
|
|
raise ValueError("case opts out of split: " + name)
|
|
# A NAMED CASE THAT DOES NOT RUN THE REQUESTED BACKEND IS AN ERROR, NOT A QUIET DROP.
|
|
# The default selection is a subset and filtering it per backend is correct - the
|
|
# DirectVulkan-only iris case is simply absent from a DirectGLES sweep. But
|
|
# `--case X --backend DirectVulkan` where X is DirectGLES-only used to produce one
|
|
# fewer plan and no message at all, and with a single --case it produced the generic
|
|
# "no selected case supports the requested backend" instead of naming X.
|
|
if requested and backends and not [b for b in backends if b in ci_backends(case)]:
|
|
raise ValueError(f"{name} does not run any requested backend: "
|
|
f"ci_backends = {', '.join(ci_backends(case))}")
|
|
selected.append(case)
|
|
if not selected:
|
|
raise ValueError("the manifest selected no split cases")
|
|
return selected
|
|
|
|
|
|
def properties(test):
|
|
return {entry["name"]: entry["value"] for entry in test.get("properties", [])}
|
|
|
|
|
|
def case_environment(test, base, args, output=None):
|
|
env = dict(base) # A fresh copy for every case; never leak the previous case's quirks.
|
|
for pair in properties(test).get("ENVIRONMENT", []):
|
|
key, sep, value = pair.partition("=")
|
|
if not sep:
|
|
raise ValueError("malformed CTest environment entry: " + pair)
|
|
if key not in OWNED_ENV:
|
|
env[key] = value
|
|
for key in OWNED_ENV:
|
|
env.pop(key, None)
|
|
env.update(MOBILEGL_TRANSPORT="spawn", MOBILEGL_IPC_CONTROL=args.endpoint,
|
|
MOBILEGL_IPC_DATA="stream", MOBILEGL_IPC_TOKEN=args.token,
|
|
MOBILEGL_IPC_REQUIRE_SAME_BUILD="1", MOBILEGL_IPC_LOG_FORWARD="1",
|
|
MOBILEGL_PIPE_STATS="1", MOBILEGL_PIPE_STATS_PERIOD="1",
|
|
MOBILEGL_IPC_PRESENT_CREDIT=str(args.credit))
|
|
if getattr(args, "require_run_ahead", False):
|
|
env["MOBILEGL_IPC_RUN_AHEAD"] = "1"
|
|
if output is not None:
|
|
env["MOBILEGL_LOG_FILE_PATH"] = str(output / "output/mobilegl.log")
|
|
return env
|
|
|
|
|
|
def definitions(command):
|
|
result = {}
|
|
for arg in command:
|
|
if arg.startswith("-D") and "=" in arg:
|
|
key, value = arg[2:].split("=", 1)
|
|
result[key.split(":", 1)[0]] = value
|
|
return result
|
|
|
|
|
|
def plan_case(case, backend, catalog, args, hashes):
|
|
# THE GOLDEN AND THE THRESHOLD ARE PER BACKEND FROM HERE ON. case_for_backend returns the
|
|
# case unchanged when it declares no `backend_overrides`, so every DirectGLES plan, its
|
|
# identity fingerprint and its checkpoint key are bit-for-bit what they were before.
|
|
resolved = case_for_backend(case, backend)
|
|
foreign_goldens = backend_override_goldens(case, backend)
|
|
name = f"MobileGLTraceReplay.{case['name']}.{backend}"
|
|
test = next((catalog[key] for key in (name, name + ".TCP", name + ".SPAWN", name + ".SPLIT")
|
|
if key in catalog), None)
|
|
if test is None:
|
|
raise ValueError("CTest catalog has no entry for " + name)
|
|
command = list(test["command"])
|
|
defs = definitions(command)
|
|
if defs.get("TRACE_CASE_NAME") != case["name"] or defs.get("TRACE_BACKEND") != backend:
|
|
raise ValueError("CTest name and command disagree: " + name)
|
|
if "-P" not in command or not defs.get("TRACE_ARCHIVE"):
|
|
raise ValueError("expected a run_trace_case.cmake catalog entry: " + name)
|
|
fixtures = args.fixtures or Path(defs["TRACE_ARCHIVE"]).parent
|
|
runner = (args.runner or Path(defs["TRACE_REPLAY_EXE"])).resolve()
|
|
script = args.source / "tools/trace_replay/run_trace_case.cmake"
|
|
values = {"TRACE_REPLAY_EXE": str(runner), "MOBILEGL_LIBRARY": str(args.library),
|
|
"TRACE_CASE_NAME": case["name"], "TRACE_BACKEND": backend,
|
|
"TRACE_TRANSPORT": "spawn", "TRACE_IPC_SERVER_PATH": "",
|
|
"TRACE_IPC_CONTROL": "", "TRACE_IPC_TOKEN": ""}
|
|
# Read values from the CURRENT manifest, not stale -D values in the catalog.
|
|
keys = {"TRACE_FILE": "trace_file", "TRACE_TARGET_CALL": "target_call", "TRACE_WIDTH": "width",
|
|
"TRACE_HEIGHT": "height", "TRACE_SSIM_THRESHOLD": "ssim_threshold", "TRACE_CROP_X": "crop_x",
|
|
"TRACE_CROP_Y": "crop_y", "TRACE_CROP_WIDTH": "crop_width", "TRACE_CROP_HEIGHT": "crop_height",
|
|
"TRACE_COHERENT_AS_FLUSH": "coherent_as_flush"}
|
|
for key, field in keys.items():
|
|
value = resolved.get(field, False if field == "coherent_as_flush" else 0.99 if field == "ssim_threshold" else 0)
|
|
values[key] = ("ON" if value else "OFF") if isinstance(value, bool) else str(value)
|
|
inputs = []
|
|
for key, field in (("TRACE_ARCHIVE", "trace_archive"), ("TRACE_GOLDEN", "golden"),
|
|
("TRACE_ALTERNATE_GOLDEN", "alternate_golden")):
|
|
path = (fixtures / resolved[field]).resolve() if resolved.get(field) else None
|
|
values[key] = str(path) if path else ""
|
|
if path:
|
|
stat = path.stat()
|
|
inputs.append({"path": str(path), "size": stat.st_size, "mtime_ns": stat.st_mtime_ns,
|
|
**({"sha256": digest(path)} if key != "TRACE_ARCHIVE" else {})})
|
|
foreign = {str((fixtures / relative).resolve()) for relative in foreign_goldens}
|
|
trace_file = Path(resolved["trace_file"])
|
|
if trace_file.is_absolute() or ".." in trace_file.parts:
|
|
raise ValueError("trace_file must remain inside the extracted input directory")
|
|
for path in (runner, args.library, script):
|
|
if str(path) not in hashes:
|
|
hashes[str(path)] = digest(path)
|
|
env = case_environment(test, args.base_env, args)
|
|
# Ignore shell/session bookkeeping when resuming, but retain renderer knobs and
|
|
# every case-specific catalog variable. The digest stores no token/plaintext env.
|
|
case_keys = {pair.partition("=")[0] for pair in properties(test).get("ENVIRONMENT", [])}
|
|
relevant = {key: value for key, value in env.items() if key in case_keys or
|
|
key.startswith(("MOBILEGL_", "MGITEST_", "EGL_", "LIBGL_", "MESA_", "VK_", "__GL", "__EGL", "LD_")) or
|
|
key in ("DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "DRI_PRIME", "GALLIUM_DRIVER")}
|
|
identity = fingerprint({"case": resolved, "backend": backend, "credit": args.credit,
|
|
"endpoint": args.endpoint, "token": args.token,
|
|
"require_run_ahead": args.require_run_ahead,
|
|
"artifacts": {str(p): hashes[str(p)] for p in (runner, args.library, script)},
|
|
"inputs": inputs, "environment": relevant, "command": command})
|
|
return {"case": resolved, "backend": backend, "test": test, "command": command, "values": values,
|
|
"script": script, "runner": runner, "identity": identity,
|
|
"foreign_goldens": foreign,
|
|
"key": f"{backend}/credit-{args.credit}/{case['name']}"}
|
|
|
|
|
|
def command_for(plan, work):
|
|
values = {**plan["values"], "TRACE_OUTPUT_DIR": str(work), "TRACE_ARTIFACT_DIR": str(work.parent / "artifacts")}
|
|
command, found = [], set()
|
|
original = plan["command"]
|
|
for index, arg in enumerate(original):
|
|
if index and original[index - 1] == "-P":
|
|
command.append(str(plan["script"]))
|
|
elif arg.startswith("-D") and arg[2:].split("=", 1)[0].split(":", 1)[0] in values:
|
|
key = arg[2:].split("=", 1)[0].split(":", 1)[0]
|
|
command.append(f"-D{key}={values[key]}")
|
|
found.add(key)
|
|
else:
|
|
command.append(arg)
|
|
position = command.index("-P")
|
|
command[position:position] = [f"-D{k}={v}" for k, v in values.items() if k not in found]
|
|
return command
|
|
|
|
|
|
def group_members(pgid):
|
|
members = []
|
|
for path in Path("/proc").glob("[0-9]*/stat"):
|
|
try:
|
|
fields = path.read_text().rsplit(")", 1)[1].split()
|
|
if int(fields[2]) == pgid:
|
|
members.append((int(path.parent.name), fields[0]))
|
|
except (OSError, ValueError, IndexError):
|
|
pass
|
|
return members
|
|
|
|
|
|
def reap_group(pgid):
|
|
while True:
|
|
try:
|
|
pid, _ = os.waitpid(-pgid, os.WNOHANG)
|
|
if pid == 0:
|
|
break
|
|
except ChildProcessError:
|
|
break
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def subreaper():
|
|
libc = ctypes.CDLL(None, use_errno=True)
|
|
previous = ctypes.c_int()
|
|
if libc.prctl(37, ctypes.byref(previous), 0, 0, 0) or libc.prctl(36, 1, 0, 0, 0):
|
|
raise OSError(ctypes.get_errno(), "cannot enable Linux child subreaper")
|
|
try:
|
|
yield
|
|
finally:
|
|
libc.prctl(36, previous.value, 0, 0, 0)
|
|
|
|
|
|
def terminate_group(process, grace=2.0):
|
|
"""Reap descendants even when the wrapper exits before a TERM-ignoring child."""
|
|
pgid = process.pid
|
|
for sig, duration in ((signal.SIGTERM, grace), (signal.SIGKILL, 2.0)):
|
|
try:
|
|
os.killpg(pgid, sig)
|
|
except ProcessLookupError:
|
|
pass
|
|
until = time.monotonic() + duration
|
|
while time.monotonic() < until:
|
|
process.poll() # Let Popen reap its own child before reaping adopted descendants.
|
|
if process.returncode is not None:
|
|
reap_group(pgid)
|
|
if not group_members(pgid):
|
|
return []
|
|
time.sleep(.02)
|
|
process.wait(timeout=2)
|
|
reap_group(pgid)
|
|
return group_members(pgid)
|
|
|
|
|
|
def progress_signature(work, console):
|
|
signature = []
|
|
for path in [console, *work.rglob("*.log")]:
|
|
try:
|
|
stat = path.stat()
|
|
signature.append((str(path), stat.st_size, stat.st_mtime_ns))
|
|
except FileNotFoundError:
|
|
pass
|
|
return tuple(sorted(signature))
|
|
|
|
|
|
def supervise(command, env, cwd, work, console, idle_seconds=300, max_seconds=7200,
|
|
poll_seconds=.2, terminate_grace=2.0, wake=None):
|
|
started = time.monotonic()
|
|
last_progress = started
|
|
previous = progress_signature(work, console)
|
|
timeout_kind = None
|
|
next_wake = started
|
|
with subreaper(), console.open("wb") as output:
|
|
process = subprocess.Popen(command, env=env, cwd=cwd, stdout=output, stderr=output,
|
|
start_new_session=True)
|
|
try:
|
|
while process.poll() is None:
|
|
now = time.monotonic()
|
|
if wake is not None and now >= next_wake:
|
|
wake()
|
|
next_wake = time.monotonic() + 15
|
|
signature = progress_signature(work, console)
|
|
if signature != previous:
|
|
previous, last_progress = signature, time.monotonic()
|
|
now = time.monotonic()
|
|
if now - started >= max_seconds:
|
|
timeout_kind = "absolute"
|
|
break
|
|
if now - last_progress >= idle_seconds:
|
|
timeout_kind = "idle"
|
|
break
|
|
time.sleep(poll_seconds)
|
|
leftovers = group_members(process.pid) if process.poll() is not None else []
|
|
returncode = process.returncode
|
|
finally:
|
|
remaining = terminate_group(process, terminate_grace)
|
|
return {"returncode": 124 if timeout_kind else returncode, "timeout_kind": timeout_kind,
|
|
"seconds": round(time.monotonic() - started, 3), "pid": process.pid,
|
|
"leftovers_after_parent_exit": [pid for pid, state in leftovers if state != "Z"],
|
|
"remaining_processes": remaining}
|
|
|
|
|
|
def actual_arm_from_text(client_text):
|
|
return {"armed": "run-ahead ARMED" in client_text,
|
|
"lockstep": "running lockstep" in client_text,
|
|
"disarmed": bool(re.search(r"\bDISARMED\b", client_text))}
|
|
|
|
|
|
def validate_result(plan, work, endpoint, require_run_ahead=False):
|
|
path = work / "output/result.json"
|
|
result = read_json(path)
|
|
if result.get("passed") is not True or result.get("statusCode") != 0:
|
|
raise ValueError("result.json does not report success")
|
|
if result.get("backend") != plan["backend"] or result.get("targetCall") != int(plan["values"]["TRACE_TARGET_CALL"]):
|
|
raise ValueError("result identifies a different backend/call")
|
|
expected_trace = (work / "input" / plan["case"]["trace_file"]).resolve()
|
|
if Path(result.get("tracePath", "")).resolve() != expected_trace:
|
|
raise ValueError("result tracePath belongs to a different attempt")
|
|
actual = work / "output/actual.png"
|
|
if Path(result.get("actualPath", "")).resolve() != actual.resolve() or not actual.is_file():
|
|
raise ValueError("result has no current-attempt actual image")
|
|
goldens = {str(Path(plan["values"][key]).resolve()) for key in ("TRACE_GOLDEN", "TRACE_ALTERNATE_GOLDEN") if plan["values"][key]}
|
|
matched = str(Path(result.get("matchedGoldenPath", "")).resolve())
|
|
if matched not in goldens:
|
|
# THE WRONG-BACKEND GOLDEN IS NAMED AS SUCH. Matching an image another backend declared
|
|
# for this case is not "a different golden" in the ordinary sense - it is the arm going
|
|
# green against the other backend's picture, which is the one failure a two-backend
|
|
# golden matrix exists to catch, so it says so.
|
|
if matched in plan.get("foreign_goldens", set()):
|
|
raise ValueError("result matched a golden another backend declares for this case, "
|
|
"not " + plan["backend"] + "'s: " + matched)
|
|
raise ValueError("result matched a different golden")
|
|
score = result.get("ssim")
|
|
if isinstance(score, bool) or not isinstance(score, (float, int)) or not math.isfinite(score) or score < float(plan["values"]["TRACE_SSIM_THRESHOLD"]):
|
|
raise ValueError("result SSIM does not meet the current manifest threshold")
|
|
client_log = work / "output/mobilegl.client.log"
|
|
client_text = client_log.read_text(errors="replace")
|
|
marker = re.compile(r"control=tcp data=stream server=(\S+) pid=([1-9][0-9]*)")
|
|
if not any(match[0] == endpoint.removeprefix("tcp://") for match in marker.findall(client_text)):
|
|
raise ValueError("current-attempt log lacks the requested TCP arm proof")
|
|
actual_arm = actual_arm_from_text(client_text)
|
|
if require_run_ahead and (not actual_arm["armed"] or actual_arm["lockstep"] or actual_arm["disarmed"]):
|
|
raise ValueError("required run-ahead arm missing or contradicted in private client log: " + str(actual_arm))
|
|
return {"result": result, "result_path": str(path), "result_sha256": digest(path),
|
|
"client_log": str(client_log), "client_log_sha256": digest(client_log), "actual_arm": actual_arm}
|
|
|
|
|
|
def reusable(row, plan, out, endpoint, require_run_ahead=False):
|
|
if row is None or row.get("status") != "passed" or row.get("returncode") != 0 or row.get("timeout_kind") is not None or row.get("identity") != plan["identity"]:
|
|
return False
|
|
if require_run_ahead and row.get("require_run_ahead") is not True:
|
|
return False
|
|
try:
|
|
work = Path(row["work"])
|
|
if not work.resolve().is_relative_to(out.resolve()):
|
|
return False
|
|
proof = validate_result(plan, work, endpoint, require_run_ahead)
|
|
return all(proof[key] == row.get(key) for key in ("result_sha256", "client_log_sha256", "actual_arm"))
|
|
except (OSError, ValueError, KeyError):
|
|
return False
|
|
|
|
|
|
def run_case(plan, args, row):
|
|
work = Path(row["work"])
|
|
console = work.parent / "runner.log"
|
|
env = case_environment(plan["test"], args.base_env, args, work)
|
|
row["requested_arm"] = {key: env.get(key) for key in ("MOBILEGL_IPC_RUN_AHEAD",)}
|
|
props = properties(plan["test"])
|
|
cwd = props.get("WORKING_DIRECTORY", str(args.source))
|
|
def wake():
|
|
with (work.parent / "wake-adb.log").open("ab") as log:
|
|
try:
|
|
subprocess.run([args.adb, "-s", args.wake_adb_serial, "shell", "input", "keyevent", "KEYCODE_WAKEUP"],
|
|
stdout=log, stderr=log, timeout=5, check=False)
|
|
except (OSError, subprocess.TimeoutExpired) as error:
|
|
log.write((str(error) + "\n").encode())
|
|
outcome = supervise(command_for(plan, work), env, cwd, work, console, args.idle_seconds,
|
|
args.max_seconds, wake=wake if args.wake_adb_serial else None)
|
|
row.update(outcome, status="failed", console=str(console))
|
|
client_log = work / "output/mobilegl.client.log"
|
|
row["actual_arm"] = actual_arm_from_text(client_log.read_text(errors="replace")) if client_log.is_file() else None
|
|
if outcome["timeout_kind"]:
|
|
row["status"] = "timeout"
|
|
elif outcome["returncode"] == 0 and not outcome["remaining_processes"] and not outcome["leftovers_after_parent_exit"]:
|
|
try:
|
|
row.update(validate_result(plan, work, args.endpoint, args.require_run_ahead), status="passed")
|
|
except (OSError, ValueError, KeyError) as error:
|
|
row["error"] = str(error)
|
|
if row["status"] != "passed" and row["returncode"] == 0:
|
|
row["process_returncode"] = 0
|
|
row["returncode"] = 1 # A zero wrapper exit cannot turn stale/missing proof green.
|
|
return row
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
catalog_source = parser.add_mutually_exclusive_group(required=True)
|
|
catalog_source.add_argument("--catalog", type=Path, help="ctest --show-only=json-v1 output")
|
|
catalog_source.add_argument("--build-dir", type=Path, help="read a catalog using ctest; does not build")
|
|
parser.add_argument("--source", type=Path, default=ROOT)
|
|
parser.add_argument("--manifest", type=Path)
|
|
parser.add_argument("--fixtures", type=Path, help="default: the fixture directory recorded in the catalog")
|
|
parser.add_argument("--runner", type=Path, help="override TRACE_REPLAY_EXE from the catalog")
|
|
parser.add_argument("--library", type=Path, required=True)
|
|
parser.add_argument("--endpoint", required=True)
|
|
parser.add_argument("--token", default=os.environ.get("MOBILEGL_IPC_TOKEN", ""))
|
|
parser.add_argument("--out", type=Path, required=True)
|
|
parser.add_argument("--case", action="append")
|
|
parser.add_argument("--backend", action="append", choices=("DirectGLES", "DirectVulkan"),
|
|
help="repeatable; default DirectGLES. A case is planned for a requested "
|
|
"backend only when its own ci_backends carries it, and its golden "
|
|
"and SSIM threshold come from that backend's backend_overrides "
|
|
"block when the manifest declares one.")
|
|
parser.add_argument("--credit", type=int, choices=(1, 2, 3), default=2)
|
|
parser.add_argument("--resume", action="store_true")
|
|
parser.add_argument("--require-run-ahead", action="store_true",
|
|
help="require private-client ARMED proof, no lockstep fallback/DISARMED; force RUN_AHEAD=1")
|
|
parser.add_argument("--idle-seconds", type=float, default=300)
|
|
parser.add_argument("--max-seconds", type=float, default=7200)
|
|
parser.add_argument("--wake-adb-serial", help="explicit opt-in: KEYCODE_WAKEUP every 15 seconds while a case runs")
|
|
parser.add_argument("--adb", default="adb")
|
|
args = parser.parse_args(argv)
|
|
if sys.platform != "linux":
|
|
parser.error("run this process-group driver in Linux/WSL")
|
|
if not args.endpoint.startswith("tcp://") or any(not math.isfinite(value) or value <= 0
|
|
for value in (args.idle_seconds, args.max_seconds)):
|
|
parser.error("a tcp:// endpoint and positive timeouts are required")
|
|
args.source, args.library, args.out = args.source.resolve(), args.library.resolve(), args.out.resolve()
|
|
args.base_env = dict(os.environ)
|
|
args.manifest = args.manifest or args.source / "tools/trace_replay/trace_cases.json"
|
|
backends = args.backend or ["DirectGLES"]
|
|
if len(backends) != len(set(backends)):
|
|
parser.error("duplicate --backend")
|
|
try:
|
|
cases = select_cases(args.manifest, args.case, backends)
|
|
document = read_json(args.catalog) if args.catalog else json.loads(subprocess.check_output(
|
|
["ctest", "--test-dir", str(args.build_dir), "--show-only=json-v1"], text=True))
|
|
catalog = {test["name"]: test for test in document["tests"]}
|
|
hashes = {}
|
|
plans = [plan_case(case, backend, catalog, args, hashes) for case in cases
|
|
for backend in backends if backend in ci_backends(case)]
|
|
if not plans:
|
|
raise ValueError("no selected case supports the requested backend")
|
|
except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error:
|
|
parser.error(str(error))
|
|
args.out.mkdir(parents=True, exist_ok=True)
|
|
checkpoint_path = args.out / "checkpoint.json"
|
|
with (args.out / ".matrix.lock").open("w") as lock:
|
|
try:
|
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
parser.error("another matrix driver owns this output directory")
|
|
if not checkpoint_path.exists() and (args.out / "results.json").is_file():
|
|
legacy = args.out / ("results.legacy-" + uuid.uuid4().hex + ".json")
|
|
shutil.copy2(args.out / "results.json", legacy)
|
|
print(json.dumps({"legacy_results_preserved": str(legacy)}), flush=True)
|
|
checkpoint = read_json(checkpoint_path) if checkpoint_path.exists() else {"schema_version": 1, "runs": []}
|
|
if checkpoint.get("schema_version") != 1 or not isinstance(checkpoint.get("runs"), list):
|
|
parser.error("unsupported/corrupt checkpoint; old scratch results are not a checkpoint")
|
|
selected = []
|
|
def interrupted(signum, frame):
|
|
raise KeyboardInterrupt
|
|
handlers = {sig: signal.signal(sig, interrupted) for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)}
|
|
try:
|
|
for plan in plans:
|
|
prior = next((r for r in reversed(checkpoint["runs"]) if r.get("key") == plan["key"]), None)
|
|
if args.resume and reusable(prior, plan, args.out, args.endpoint, args.require_run_ahead):
|
|
selected.append({**prior, "resumed": True})
|
|
print(json.dumps({"case": prior["case"], "backend": prior["backend"], "status": "passed", "resumed": True}), flush=True)
|
|
continue
|
|
attempt = args.out / plan["key"] / ("attempt-" + uuid.uuid4().hex)
|
|
attempt.mkdir(parents=True)
|
|
row = {"key": plan["key"], "case": plan["case"]["name"], "backend": plan["backend"],
|
|
"credit": args.credit, "ci": plan["case"].get("ci", True), "identity": plan["identity"],
|
|
"require_run_ahead": args.require_run_ahead,
|
|
# Which image and which threshold THIS backend was scored against. With
|
|
# a per-backend golden in play, a results.json that named neither could
|
|
# not be read months later without the manifest of the day beside it.
|
|
"golden": plan["values"]["TRACE_GOLDEN"],
|
|
"alternate_golden": plan["values"]["TRACE_ALTERNATE_GOLDEN"],
|
|
"ssim_threshold": plan["values"]["TRACE_SSIM_THRESHOLD"],
|
|
"work": str(attempt / "work"), "status": "running", "started_at_ns": time.time_ns()}
|
|
checkpoint["runs"].append(row)
|
|
atomic_json(checkpoint_path, checkpoint)
|
|
print(json.dumps({"case": row["case"], "backend": row["backend"], "status": "running"}), flush=True)
|
|
try:
|
|
run_case(plan, args, row)
|
|
except KeyboardInterrupt:
|
|
# An outer timeout sends SIGTERM, which our handler turns
|
|
# into KeyboardInterrupt. Preserve a terminal row in BOTH
|
|
# ledgers; otherwise the wrapper sees rc=124 but no
|
|
# results.json and cannot distinguish cancellation from a
|
|
# driver that never wrote evidence.
|
|
row.update(status="canceled", returncode=130,
|
|
completed_at_ns=time.time_ns())
|
|
row.setdefault("seconds", round((row["completed_at_ns"] - row["started_at_ns"]) / 1e9, 3))
|
|
atomic_json(checkpoint_path, checkpoint)
|
|
selected.append({**row, "resumed": False})
|
|
atomic_json(args.out / "results.json", selected)
|
|
print(json.dumps({k: row.get(k) for k in
|
|
("case", "backend", "status", "returncode", "seconds", "timeout_kind", "error")}),
|
|
flush=True)
|
|
return 130
|
|
except Exception as error:
|
|
row.update(status="failed", returncode=1, error=str(error))
|
|
row["completed_at_ns"] = time.time_ns()
|
|
atomic_json(checkpoint_path, checkpoint)
|
|
selected.append({**row, "resumed": False})
|
|
atomic_json(args.out / "results.json", selected)
|
|
print(json.dumps({k: row.get(k) for k in ("case", "backend", "status", "returncode", "seconds", "timeout_kind", "error")}), flush=True)
|
|
if row["status"] == "timeout" or row.get("remaining_processes"):
|
|
# Do not convert an unhealthy peer into a cascade of Busy outcomes.
|
|
# The failed checkpoint is retained; resume after checking the peer.
|
|
break
|
|
time.sleep(.3)
|
|
finally:
|
|
for sig, handler in handlers.items():
|
|
signal.signal(sig, handler)
|
|
atomic_json(args.out / "results.json", selected)
|
|
return 0 if all(row["status"] == "passed" for row in selected) else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|