Files
MobileGL/tools/piglit-android/run_piglit_android.py
T

348 lines
14 KiB
Python

#!/usr/bin/env python3
# MobileGL - tools/piglit-android/run_piglit_android.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 piglit GL tests on a connected Android device against MobileGL.
Pipeline:
1. Read a test list produced by `piglit print-cmd` ("name ::: command" lines).
2. Rewrite host paths to on-device paths and collect referenced data files.
3. Push binaries/libs/data to the device (incremental, marker-based).
4. Execute tests serially on-device in chunked shell scripts under `timeout`,
with MobileGL served to waffle via WAFFLE_EGL_LIBRARY=libMobileGL.so.
5. Parse "PIGLIT: {...}" result lines, classify, and write results + summary.
The device never needs python or an APK; everything runs as the adb shell user
from /data/local/tmp.
"""
import argparse
import json
import os
import re
import shlex
import subprocess
import sys
import tarfile
import tempfile
import time
from pathlib import Path
RESULT_LINE = re.compile(rb'PIGLIT: *({.*})')
MARK_START = re.compile(rb'@@@T (\d+) START')
MARK_EXIT = re.compile(rb'@@@T (\d+) EXIT (\d+)')
# toybox timeout exit codes: 124 = timed out (SIGTERM), 137 = SIGKILL after -k.
TIMEOUT_EXITS = {124, 137, 142}
def adb(args, serial=None, **kw):
cmd = ['adb'] + (['-s', serial] if serial else []) + args
return subprocess.run(cmd, **kw)
def adb_check(args, serial=None):
r = adb(args, serial=serial, capture_output=True)
if r.returncode != 0:
raise RuntimeError(f"adb {' '.join(args)} failed: {r.stderr.decode(errors='replace')}")
return r.stdout
def parse_list(path):
tests = []
for line in Path(path).read_text().splitlines():
line = line.strip()
if not line or ' ::: ' not in line:
continue
name, cmd = line.split(' ::: ', 1)
tests.append((name.strip(), shlex.split(cmd)))
return tests
def rewrite_cmd(argv, piglit_root, build_dir, device_dir):
"""Map host paths in a test command to device paths.
Returns (device_argv, referenced_host_files).
"""
build_abs = str((piglit_root / build_dir).resolve())
src_abs = str(piglit_root.resolve())
out = []
refs = []
for i, arg in enumerate(argv):
a = arg
if i == 0:
# program: "build-android/bin/foo" or absolute
prog = a if os.path.isabs(a) else str((piglit_root / a).resolve())
refs.append(prog)
out.append(f'{device_dir}/bin/{os.path.basename(prog)}')
continue
p = a if os.path.isabs(a) else None
if p and p.startswith(build_abs + '/generated_tests/'):
refs.append(p)
out.append(p.replace(build_abs + '/generated_tests', device_dir + '/generated_tests', 1))
elif p and p.startswith(build_abs + '/tests/'):
# Serialized profiles record data paths under the build dir, but
# only *generated* data lives there in an out-of-tree build;
# plain test files stay in the source tests/ dir.
if not os.path.exists(p):
p = p.replace(build_abs + '/tests', src_abs + '/tests', 1)
refs.append(p)
out.append(p.replace(src_abs + '/tests', device_dir + '/tests', 1))
else:
refs.append(p)
out.append(p.replace(build_abs + '/tests', device_dir + '/build-tests', 1))
elif p and p.startswith(src_abs + '/tests/'):
refs.append(p)
out.append(p.replace(src_abs + '/tests', device_dir + '/tests', 1))
else:
out.append(a)
return out, refs
def backend_env(backend, device_dir, use_angle=False):
env = {
'LD_LIBRARY_PATH': f'{device_dir}/lib',
'WAFFLE_EGL_LIBRARY': 'libMobileGL.so',
'WAFFLE_GL_LIBRARY': 'libMobileGL.so',
# Upgrade low compat context requests to what MobileGL implements;
# without this, piglit's supports_gl_compat_version=10 tests get the
# raw backend version string and skip themselves.
'WAFFLE_FORCE_GL_CONTEXT_VERSION': '33core',
'PIGLIT_PLATFORM': 'surfaceless_egl',
'PIGLIT_SOURCE_DIR': device_dir,
'MOBILEGL_BACKEND_TYPE': backend,
'MOBILEGL_LOG_FILE_PATH': f'{device_dir}/mobilegl.log',
}
if backend == 'DirectGLES':
env['MOBILEGL_USE_ANGLE'] = '1' if use_angle else '0'
if backend == 'DirectVulkan':
# The device ICD usually lacks VK_EXT_headless_surface, which the
# MobileGL pbuffer path needs; use a real ANativeWindow from
# AImageReader instead (patched waffle surfaceless_egl platform).
env['WAFFLE_ANDROID_WINDOW'] = 'imagereader'
return env
def push_tree(host, dev, serial, marker_name, force=False):
"""Push a directory once, tracked by a marker file on the device."""
marker = f'{dev}.pushed-{marker_name}'
if not force:
r = adb(['shell', f'test -e {shlex.quote(marker)} && echo yes'],
serial=serial, capture_output=True)
if r.stdout.strip() == b'yes':
return False
print(f' pushing {host} -> {dev}')
adb_check(['shell', f'rm -rf {shlex.quote(dev)}'], serial=serial)
adb_check(['push', str(host), dev], serial=serial)
adb_check(['shell', f'touch {shlex.quote(marker)}'], serial=serial)
return True
def push_files(files, strip_prefix, dev_prefix, serial):
"""Tar up referenced data files (relative to strip_prefix) and unpack on device."""
files = sorted(set(files))
if not files:
return
with tempfile.NamedTemporaryFile(suffix='.tar', delete=False) as tf:
tar_path = tf.name
with tarfile.open(tar_path, 'w') as tar:
for f in files:
rel = os.path.relpath(f, strip_prefix)
if rel.startswith('..'):
raise RuntimeError(f'file {f} not under {strip_prefix}')
tar.add(f, arcname=rel)
dev_tar = dev_prefix + '/.data.tar'
adb_check(['shell', f'mkdir -p {shlex.quote(dev_prefix)}'], serial=serial)
adb_check(['push', tar_path, dev_tar], serial=serial)
adb_check(['shell', f'cd {shlex.quote(dev_prefix)} && tar xf .data.tar && rm .data.tar'],
serial=serial)
os.unlink(tar_path)
def classify(exit_code, piglit_results, timed_out):
if timed_out:
return 'timeout'
result = None
for r in piglit_results:
if 'result' in r:
result = r['result']
if result is None:
return 'crash' if exit_code != 0 else 'notrun'
if exit_code not in (0, 1):
return 'crash'
return result
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--piglit-root', required=True, type=Path,
help='piglit source checkout (with the Android build dir inside)')
ap.add_argument('--build-dir', default='build-android',
help='Android build dir name inside piglit root')
ap.add_argument('--list', required=True,
help='test list file from `piglit print-cmd` (name ::: cmd)')
ap.add_argument('--backend', required=True, choices=['DirectGLES', 'DirectVulkan'])
ap.add_argument('--use-angle', action='store_true',
help='DirectGLES only: let MobileGL load ANGLE instead of the system driver')
ap.add_argument('--mobilegl-lib', required=True, type=Path,
help='stripped libMobileGL.so for the device')
ap.add_argument('--waffle-lib', required=True, type=Path,
help='cross-built libwaffle-1.so')
ap.add_argument('--device-dir', default='/data/local/tmp/piglit-mgl')
ap.add_argument('--out', required=True, type=Path, help='host results directory')
ap.add_argument('--serial', default=None, help='adb device serial')
ap.add_argument('--timeout', type=int, default=60, help='per-test timeout (seconds)')
ap.add_argument('--chunk', type=int, default=200, help='tests per device-side script')
ap.add_argument('--repush', action='store_true', help='force re-push of bin/lib/tests trees')
args = ap.parse_args()
piglit_root = args.piglit_root.resolve()
build = piglit_root / args.build_dir
dev = args.device_dir.rstrip('/')
args.out.mkdir(parents=True, exist_ok=True)
tests = parse_list(args.list)
if not tests:
print('no tests in list', file=sys.stderr)
return 2
print(f'[1/4] {len(tests)} tests; rewriting paths')
dev_cmds = []
data_refs = []
for name, argv in tests:
dcmd, refs = rewrite_cmd(argv, piglit_root, args.build_dir, dev)
dev_cmds.append((name, dcmd))
data_refs.extend(r for r in refs if not r.split('/')[-2:][0] == 'bin')
print('[2/4] pushing artifacts')
adb_check(['shell', f'mkdir -p {dev} {dev}/lib {dev}/chunks {dev}/logs'], serial=args.serial)
push_tree(build / 'bin', f'{dev}/bin', args.serial, 'bin', args.repush)
adb_check(['shell', f'chmod -R 755 {dev}/bin'], serial=args.serial)
# libs: piglit utils + waffle + MobileGL in one LD_LIBRARY_PATH dir
for lib in sorted((build / 'lib').glob('*.so')):
adb_check(['push', str(lib), f'{dev}/lib/'], serial=args.serial)
adb_check(['push', str(args.waffle_lib), f'{dev}/lib/libwaffle-1.so'], serial=args.serial)
adb_check(['push', str(args.mobilegl_lib), f'{dev}/lib/libMobileGL.so'], serial=args.serial)
# data files referenced by this run (tests/, build-tests/, generated_tests/)
src_abs = str(piglit_root)
build_abs = str(build)
groups = {
(src_abs + '/tests', f'{dev}/tests'): [],
(build_abs + '/tests', f'{dev}/build-tests'): [],
(build_abs + '/generated_tests', f'{dev}/generated_tests'): [],
}
for r in set(data_refs):
for (host_prefix, dev_prefix), bucket in groups.items():
if r.startswith(host_prefix + '/'):
bucket.append(r)
break
for (host_prefix, dev_prefix), bucket in groups.items():
push_files(bucket, host_prefix, dev_prefix, args.serial)
env = backend_env(args.backend, dev, args.use_angle)
env_lines = '\n'.join(f'export {k}={shlex.quote(v)}' for k, v in env.items())
print(f'[3/4] running {len(tests)} tests on {args.backend} '
f'(timeout {args.timeout}s/test, chunks of {args.chunk})')
raw_log = (args.out / 'raw.log').open('wb')
t0 = time.time()
outcomes = {}
chunks = [dev_cmds[i:i + args.chunk] for i in range(0, len(dev_cmds), args.chunk)]
idx_base = 0
for ci, chunk in enumerate(chunks):
lines = ['#!/system/bin/sh', env_lines, f'cd {dev}']
for j, (name, dcmd) in enumerate(chunk):
idx = idx_base + j
quoted = ' '.join(shlex.quote(a) for a in dcmd)
lines.append(f'echo "@@@T {idx} START"')
lines.append(f'timeout -k 5 {args.timeout} {quoted} </dev/null 2>&1')
lines.append(f'echo "@@@T {idx} EXIT $?"')
script = '\n'.join(lines) + '\n'
with tempfile.NamedTemporaryFile('w', suffix='.sh', delete=False) as tf:
tf.write(script)
host_script = tf.name
dev_script = f'{dev}/chunks/chunk{ci}.sh'
adb_check(['push', host_script, dev_script], serial=args.serial)
os.unlink(host_script)
r = adb(['shell', f'sh {dev_script}'], serial=args.serial, capture_output=True)
raw_log.write(r.stdout)
raw_log.flush()
# parse this chunk
cur = None
cur_results = []
cur_out = []
for line in r.stdout.splitlines():
m = MARK_START.search(line)
if m:
cur = int(m.group(1))
cur_results = []
cur_out = []
continue
m = MARK_EXIT.search(line)
if m and cur is not None and int(m.group(1)) == cur:
code = int(m.group(2))
name = dev_cmds[cur][0]
status = classify(code, cur_results, code in TIMEOUT_EXITS)
outcomes[name] = {
'status': status,
'exit': code,
'subtests': {k: v for r_ in cur_results if 'subtest' in r_
for k, v in r_['subtest'].items()},
'tail': b'\n'.join(cur_out[-8:]).decode(errors='replace')
if status in ('crash', 'timeout', 'fail', 'notrun') else '',
}
cur = None
continue
if cur is not None:
cur_out.append(line)
m = RESULT_LINE.search(line)
if m:
try:
cur_results.append(json.loads(m.group(1)))
except json.JSONDecodeError:
pass
# tests whose markers never appeared (adb drop, device reboot)
for j, (name, _) in enumerate(chunk):
outcomes.setdefault(dev_cmds[idx_base + j][0], {
'status': 'missing', 'exit': -1, 'subtests': {}, 'tail': ''})
idx_base += len(chunk)
done = idx_base
print(f' chunk {ci + 1}/{len(chunks)} done ({done}/{len(dev_cmds)}, '
f'{time.time() - t0:.0f}s elapsed)')
raw_log.close()
print('[4/4] writing results')
counts = {}
for name, o in outcomes.items():
counts[o['status']] = counts.get(o['status'], 0) + 1
result_doc = {
'backend': args.backend,
'use_angle': args.use_angle,
'device_dir': dev,
'timeout': args.timeout,
'elapsed_sec': round(time.time() - t0, 1),
'totals': counts,
'tests': outcomes,
}
(args.out / 'results.json').write_text(json.dumps(result_doc, indent=1, sort_keys=True))
lines = [f"backend: {args.backend} tests: {len(outcomes)} elapsed: {result_doc['elapsed_sec']}s"]
lines.append('totals: ' + ', '.join(f'{k}={v}' for k, v in sorted(counts.items())))
for status in ('crash', 'timeout', 'fail', 'missing', 'notrun', 'warn'):
bad = sorted(n for n, o in outcomes.items() if o['status'] == status)
if bad:
lines.append(f'\n== {status} ({len(bad)}):')
lines.extend(f' {n}' for n in bad)
(args.out / 'summary.txt').write_text('\n'.join(lines) + '\n')
print('\n'.join(lines[:2]))
print(f"results: {args.out / 'results.json'}")
return 0
if __name__ == '__main__':
sys.exit(main())