#!/usr/bin/env python """Drive a glcts run on the local host, resuming across crashes. Local-host counterpart of run_cts.py: MobileGL crashes on some cases and glcts takes the whole process down with it, so a single invocation stops at the first crash. This runner re-invokes glcts with only the cases that have no result yet, records the case that was open when the process died as "Crash" (or "Hang" on a timeout), and repeats until the list is exhausted. Usage: python run_cts_local.py --backend DirectVulkan \\ --glcts --lib \\ --caselist --outdir [--env K=V ...] """ import argparse import glob import os import re import resource import subprocess import sys import time CASE_START = re.compile(r"^#beginTestCaseResult\s+(\S+)") CASE_END = re.compile(r"^#endTestCaseResult") CASE_TERM = re.compile(r"^#terminateTestCaseResult") def completed_cases(qpa_path): """Return (finished_case_names, last_started_case_or_None).""" finished = [] current = None if not os.path.exists(qpa_path): return finished, None with open(qpa_path, "r", encoding="utf-8", errors="replace") as fh: for line in fh: m = CASE_START.match(line) if m: current = m.group(1) continue if current is not None and (CASE_END.match(line) or CASE_TERM.match(line)): finished.append(current) current = None return finished, current def main(): ap = argparse.ArgumentParser() ap.add_argument("--backend", required=True, choices=["DirectGLES", "DirectVulkan"]) ap.add_argument("--glcts", required=True, help="path to the glcts binary") ap.add_argument("--lib", required=True, help="path to libMobileGL.so") ap.add_argument("--caselist", required=True) ap.add_argument("--outdir", required=True) ap.add_argument("--surface", default="fbo", help="--deqp-surface-type value") # Without an explicit size, dEQP's FboRenderContext sizes the wrapper FBO to # GL_MAX_RENDERBUFFER_SIZE (16384^2 here) and size-derived test allocations # explode (a 4-sample 16K depth texture alone is 4 GiB). ap.add_argument("--surface-size", type=int, default=256, help="--deqp-surface-width/height value") # With DONT_CARE depth/stencil bits dEQP's FboRenderContext picks the first entry of # its own format list, GL_DEPTH32F_STENCIL8. framebuffer_blit meanwhile hardcodes # GL_DEPTH24_STENCIL8 for its own buffers whenever it detects an FBO surface, then # blits depth between the two - which the spec forbids for mismatched formats, so a # conformant driver has to fail it. Asking for a config the test agrees with avoids # the contradiction instead of papering over it. ap.add_argument("--gl-config-name", default="rgba8888d24s8", help="--deqp-gl-config-name value (empty string to leave it unset)") ap.add_argument("--max-rounds", type=int, default=4000) ap.add_argument("--max-empty-streak", type=int, default=64, help="abort after this many consecutive chunks that produce no log at all") ap.add_argument("--chunk-timeout", type=int, default=1800, help="seconds before killing one glcts invocation (a wedged case never returns)") # dEQP's watchdog aborts the process when a single case exceeds a hardcoded 30s # (framework/common/tcuApp.hpp), which is not a hang on a CPU rasterizer - some # texture_swizzle cases legitimately take ~17s each and cross it once the process is # warm, so they came back as spurious Timeouts. dEQP's own default is off; the # chunk-timeout above is what actually rescues a genuinely wedged case. ap.add_argument("--watchdog", default="disable", choices=["enable", "disable"], help="--deqp-watchdog value") ap.add_argument("--skip-file", default=None, help="file of case names to exclude, e.g. cases known to wedge the host") ap.add_argument("--waiver-file", default=None, help="--deqp-waiver-file value, e.g. tools/cts/waivers/mobilegl-fbo-harness.xml") ap.add_argument("--env", action="append", default=[], metavar="K=V", help="extra environment variable for glcts (repeatable)") args = ap.parse_args() os.makedirs(args.outdir, exist_ok=True) glcts = os.path.abspath(args.glcts) lib = os.path.abspath(args.lib) # glcts resolves its gl_cts data tree relative to the binary's directory. workdir = os.path.dirname(glcts) with open(args.caselist, "r", encoding="utf-8") as fh: remaining = [l.strip() for l in fh if l.strip() and not l.strip().startswith("#")] skipped = [] if args.skip_file and os.path.isfile(args.skip_file): with open(args.skip_file, "r", encoding="utf-8") as fh: skip = {l.strip() for l in fh if l.strip() and not l.strip().startswith("#")} skipped = [c for c in remaining if c in skip] remaining = [c for c in remaining if c not in skip] print(f"[run_cts_local] skipping {len(skipped)} case(s) from {args.skip_file}") total = len(remaining) print(f"[run_cts_local] {args.backend}: {total} cases") env = dict(os.environ) env["MOBILEGL_BACKEND_TYPE"] = args.backend env["MOBILEGL_CTS_LIB"] = lib for kv in args.env: k, _, v = kv.partition("=") env[k] = v crashed = [] hung = [] done = set() chunk = 0 started = time.time() empty_streak = 0 # Resume: results in chunk files from an interrupted run still count. The # case that was open when that run died is re-tried rather than assumed bad. prior_chunks = sorted(glob.glob(os.path.join(args.outdir, "chunk*.qpa"))) for prior in prior_chunks: finished, _ = completed_cases(prior) done.update(finished) if prior_chunks: chunk = int(re.search(r"chunk(\d+)\.qpa$", prior_chunks[-1]).group(1)) + 1 remaining = [c for c in remaining if c not in done] print(f"[run_cts_local] resuming: {len(done)} case(s) already measured, " f"{len(remaining)} to go") while remaining and chunk < args.max_rounds: listfile = os.path.abspath(os.path.join(args.outdir, "remaining.txt")) with open(listfile, "w", encoding="utf-8", newline="\n") as fh: fh.write("\n".join(remaining) + "\n") qpa = os.path.abspath(os.path.join(args.outdir, f"chunk{chunk:04d}.qpa")) cmd = [ glcts, f"--deqp-caselist-file={listfile}", f"--deqp-surface-type={args.surface}", f"--deqp-surface-width={args.surface_size}", f"--deqp-surface-height={args.surface_size}", "--deqp-terminate-on-device-lost=disable", f"--deqp-watchdog={args.watchdog}", "--deqp-log-images=disable", "--deqp-log-shader-sources=disable", f"--deqp-log-filename={qpa}", ] if args.gl_config_name: cmd.append(f"--deqp-gl-config-name={args.gl_config_name}") if args.waiver_file: cmd.append(f"--deqp-waiver-file={os.path.abspath(args.waiver_file)}") timed_out = False try: # RLIMIT_CORE=0: MobileGL asserts abort with a core dump, and writing # a multi-GB glcts core image after every crash dominates wall time. subprocess.run(cmd, cwd=workdir, env=env, timeout=args.chunk_timeout, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, preexec_fn=lambda: resource.setrlimit(resource.RLIMIT_CORE, (0, 0))) except subprocess.TimeoutExpired: timed_out = True print(f"[run_cts_local] chunk {chunk:04d} timed out after {args.chunk_timeout}s", file=sys.stderr) finished, in_flight = completed_cases(qpa) for c in finished: done.add(c) progressed = len(finished) if progressed > 0: empty_streak = 0 if in_flight is not None: if timed_out: print(f"[run_cts_local] HANG in {in_flight} - quarantining it") hung.append(in_flight) else: crashed.append(in_flight) done.add(in_flight) progressed += 1 elif progressed == 0: empty_streak += 1 if empty_streak >= args.max_empty_streak: print(f"[run_cts_local] ABORTING: {empty_streak} consecutive chunks produced no " f"output. Something systemic is wrong; refusing to label the rest of the " f"suite as crashes.", file=sys.stderr) break victim = remaining[0] label = "Hang" if timed_out else "Crash" print(f"[run_cts_local] no output at all; recording {victim} as {label}") (hung if timed_out else crashed).append(victim) done.add(victim) progressed = 1 remaining = [c for c in remaining if c not in done] elapsed = time.time() - started print( f"[run_cts_local] chunk {chunk:04d}: +{progressed} (done {len(done)}/{total}, " f"crashes {len(crashed)}, hangs {len(hung)}, {elapsed / 60:.1f} min)" ) chunk += 1 with open(os.path.join(args.outdir, "crashed.txt"), "w", encoding="utf-8", newline="\n") as fh: fh.write("\n".join(crashed) + ("\n" if crashed else "")) with open(os.path.join(args.outdir, "hung.txt"), "w", encoding="utf-8", newline="\n") as fh: fh.write("\n".join(hung) + ("\n" if hung else "")) with open(os.path.join(args.outdir, "unrun.txt"), "w", encoding="utf-8", newline="\n") as fh: fh.write("\n".join(remaining) + ("\n" if remaining else "")) if skipped: with open(os.path.join(args.outdir, "skipped.txt"), "w", encoding="utf-8", newline="\n") as fh: fh.write("\n".join(skipped) + "\n") if remaining: print(f"[run_cts_local] WARNING: {len(remaining)} cases were never run (see unrun.txt)", file=sys.stderr) print(f"[run_cts_local] finished: {len(done)}/{total} cases, {len(crashed)} crashes, " f"{len(hung)} hangs, {chunk} invocations") print(f"[run_cts_local] qpa chunks in {args.outdir}") return 0 if __name__ == "__main__": sys.exit(main())