mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
- retires RemoteRunAhead.ClearingTheVerbBarrierDisarmsRunAheadEvenWithTheCapBit
376 lines
19 KiB
Python
376 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
# MobileGL - tools/trace_replay/test_run_trace_case.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
|
|
|
|
"""Canned-log tests of run_trace_case.cmake's split and verify assertions.
|
|
|
|
Each case runs the script exactly as ctest does (`cmake -D... -P run_trace_case.cmake`) with a
|
|
stub replay that copies canned library logs into the output directory. A bad log must turn the
|
|
case red with the script's own sentence and leave no split-evidence.txt; a good one must pass and
|
|
leave evidence that scripts/ci/retrace_split_evidence.py accepts. No build, GPU or trace needed.
|
|
"""
|
|
import contextlib
|
|
import io
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import unittest
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
SCRIPT = HERE / 'run_trace_case.cmake'
|
|
CHECKER = HERE.parents[1] / 'scripts' / 'ci' / 'retrace_split_evidence.py'
|
|
VERIFY_REASON = HERE.parents[1] / 'scripts' / 'ci' / 'retrace_verify_reason.py'
|
|
sys.path.insert(0, str(CHECKER.parent))
|
|
import retrace_split_evidence as checker # noqa: E402
|
|
|
|
# The stub replay. It refuses to run when the wire counters disagree with what the leg asked for,
|
|
# so removing the script's MOBILEGL_PIPE_STATS export turns every wire case red, and exporting it
|
|
# on a leg that did not ask turns that leg red too.
|
|
STUB = r'''#!/usr/bin/env python3
|
|
import json, os, shutil, sys
|
|
args = sys.argv[1:]
|
|
out = args[args.index('--output') + 1]
|
|
split = os.environ.get('MOBILEGL_TRANSPORT', '') not in ('', 'monolith')
|
|
wire = os.environ.get('MGTRACE_WIRE_EVIDENCE', '') not in ('', '0')
|
|
stats = os.environ.get('MOBILEGL_PIPE_STATS', '')
|
|
if wire and split and (stats != '1' or os.environ.get('MOBILEGL_PIPE_STATS_PERIOD') != '1'):
|
|
sys.stderr.write('stub: MOBILEGL_PIPE_STATS did not reach the replay\n')
|
|
sys.exit(3)
|
|
if not wire and stats:
|
|
sys.stderr.write('stub: MOBILEGL_PIPE_STATS reached a leg that did not ask for it\n')
|
|
sys.exit(4)
|
|
canned = os.environ.get('STUB_CANNED_DIR', '')
|
|
if canned:
|
|
for name in os.listdir(canned):
|
|
shutil.copy(os.path.join(canned, name), os.path.join(out, name))
|
|
with open(os.path.join(out, 'retrace.log'), 'w') as log:
|
|
log.write('stub replay\n')
|
|
if os.environ.get('STUB_ABORT'):
|
|
sys.exit(134)
|
|
with open(os.path.join(out, 'result.json'), 'w') as result:
|
|
json.dump({'passed': True, 'ssim': 1.0}, result)
|
|
'''
|
|
|
|
PREFIX = '[12:00:00] [Linux mobilegl_trace_/INFO]: '
|
|
MARKER = {
|
|
'inproc': 'Config: MOBILEGL_TRANSPORT=inproc - the MGPipe record stream crosses a real ring to an apply thread',
|
|
'spawn': 'Config: MOBILEGL_TRANSPORT=spawn - the MGPipe record stream crosses a real ring to an apply '
|
|
'thread in ANOTHER PROCESS',
|
|
}
|
|
SPAWN_ARMED = 'MG_Remote client: spawn ARMED - the server role runs in pid 4242, reached at /tmp/x'
|
|
TCP_ARMED = 'MG_Remote client: control=tcp data=stream server=127.0.0.1:1 pid=4243 dial=connect'
|
|
SENT = ['P65LinkMetrics kind=frame-sent frame=1 records=40 by_op=2=4,57=1,59=30,67=1',
|
|
'P65LinkMetrics kind=frame-sent frame=2 records=31 by_op=57=1,59=28,67=1']
|
|
STATS_ON = 'MGPipe stats: counters ON (MOBILEGL_PIPE_STATS), summary every 1 frames'
|
|
STATS = ['MGPipe stats: frames=1 window=1 draws=30 draws/f=30.00 acc=12 acc/draw=0.40 bytes/f[a=1]',
|
|
'MGPipe stats: frames=2 window=1 draws=28 draws/f=28.00 acc=10 acc/draw=0.36 bytes/f[a=1]']
|
|
SERVER_START = 'MG_Remote server: apply thread started'
|
|
|
|
|
|
def quiet_check(argv):
|
|
"""The checker's exit status, with its ::error:: lines kept off a green run's annotations."""
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
return checker.main(argv)
|
|
|
|
|
|
def lines(*rows):
|
|
return ''.join(PREFIX + row + '\n' for row in rows)
|
|
|
|
|
|
def good_logs(transport):
|
|
client = [MARKER['spawn' if transport == 'tcp' else transport]]
|
|
if transport == 'spawn':
|
|
client.append(SPAWN_ARMED)
|
|
if transport == 'tcp':
|
|
client.append(TCP_ARMED)
|
|
client += SENT
|
|
server = [SERVER_START, STATS_ON] + ([] if transport == 'tcp' else STATS)
|
|
return {'mobilegl.client.log': lines(*client), 'mobilegl.server.log': lines(*server)}
|
|
|
|
|
|
def squash(text):
|
|
return re.sub(r'\s+', ' ', text)
|
|
|
|
|
|
@unittest.skipIf(os.name == 'nt', 'the stub replay is a POSIX executable script')
|
|
class RunTraceCaseTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory(prefix='run-trace-case-')
|
|
self.root = Path(self.temp.name)
|
|
payload = self.root / 't.trace'
|
|
payload.write_text('not a real trace\n')
|
|
self.archive = self.root / 't.tar.gz'
|
|
with tarfile.open(self.archive, 'w:gz') as tar:
|
|
tar.add(payload, arcname='t.trace')
|
|
self.stub = self.root / 'stub_replay'
|
|
self.stub.write_text(STUB)
|
|
self.stub.chmod(0o755)
|
|
self.out = self.root / 'case'
|
|
|
|
def tearDown(self):
|
|
self.temp.cleanup()
|
|
|
|
def run_case(self, env, logs, tcp=False, squashed=True):
|
|
canned = self.root / 'canned'
|
|
shutil.rmtree(canned, ignore_errors=True)
|
|
canned.mkdir()
|
|
for name, text in logs.items():
|
|
(canned / name).write_text(text)
|
|
environment = {key: value for key, value in os.environ.items()
|
|
if not key.startswith(('MOBILEGL_', 'MGTRACE_'))}
|
|
environment.update(env)
|
|
environment['STUB_CANNED_DIR'] = str(canned)
|
|
command = ['cmake', f'-DTRACE_REPLAY_EXE={self.stub}', f'-DMOBILEGL_LIBRARY={self.root}/libMobileGL.so',
|
|
f'-DTRACE_ARCHIVE={self.archive}', '-DTRACE_FILE=t.trace',
|
|
f'-DTRACE_GOLDEN={self.root}/golden.png', f'-DTRACE_OUTPUT_DIR={self.out}',
|
|
f'-DTRACE_ARTIFACT_DIR={self.root}/artifacts', '-DTRACE_BACKEND=DirectGLES',
|
|
'-DTRACE_CASE_NAME=Canned', '-DTRACE_TARGET_CALL=1', '-DTRACE_WIDTH=1', '-DTRACE_HEIGHT=1']
|
|
if tcp:
|
|
command += ['-DTRACE_IPC_CONTROL=tcp://127.0.0.1:1', '-DTRACE_IPC_TOKEN=x']
|
|
command += ['-P', str(SCRIPT)]
|
|
result = subprocess.run(command, env=environment, capture_output=True, text=True)
|
|
output = result.stdout + result.stderr
|
|
return result.returncode, squash(output) if squashed else output
|
|
|
|
def split_env(self, transport, wire=False):
|
|
env = {'MOBILEGL_TRANSPORT': 'spawn' if transport == 'tcp' else transport,
|
|
'MOBILEGL_IPC_SERVER_PATH': str(self.root / 'libMobileGLServer.so')}
|
|
if wire:
|
|
env['MGTRACE_WIRE_EVIDENCE'] = '1'
|
|
return env
|
|
|
|
@property
|
|
def evidence(self):
|
|
return self.out / 'output' / 'split-evidence.txt'
|
|
|
|
def expect_red(self, env, logs, sentence, tcp=False):
|
|
rc, output = self.run_case(env, logs, tcp=tcp)
|
|
self.assertNotEqual(rc, 0, output)
|
|
self.assertIn(squash(sentence), output)
|
|
self.assertFalse(self.evidence.exists(), 'a red leg must leave no evidence file')
|
|
|
|
def expect_green(self, env, logs, transport, tcp=False, wire=False):
|
|
rc, output = self.run_case(env, logs, tcp=tcp)
|
|
self.assertEqual(rc, 0, output)
|
|
self.assertEqual(quiet_check([str(self.evidence), '--transport', transport, '--case', 'Canned',
|
|
'--backend', 'DirectGLES'] + (['--require-wire'] if wire else [])), 0)
|
|
return output
|
|
|
|
# Red: each assertion of the transport block, fed the log that must trip it.
|
|
def test_r1_a_library_without_a_transport_writes_no_client_log(self):
|
|
self.expect_red(self.split_env('inproc'), {'mobilegl.log': lines('Config: loaded')},
|
|
'so there is no evidence the transport ever resolved')
|
|
|
|
def test_r2_the_env_dump_line_is_not_the_marker(self):
|
|
logs = {'mobilegl.client.log': lines('Config: Accepted env variable: MOBILEGL_TRANSPORT=inproc'),
|
|
'mobilegl.server.log': lines(SERVER_START)}
|
|
self.expect_red(self.split_env('inproc'), logs, 'never reported resolving it')
|
|
|
|
def test_r3_spawn_without_a_separate_server_pid(self):
|
|
logs = good_logs('spawn')
|
|
logs['mobilegl.client.log'] = lines(MARKER['spawn'], *SENT)
|
|
self.expect_red(self.split_env('spawn'), logs, 'spawn ARMED - the server role runs in pid')
|
|
|
|
def test_r4_tcp_without_endpoint_and_pid(self):
|
|
logs = good_logs('tcp')
|
|
logs['mobilegl.client.log'] = lines(MARKER['spawn'], *SENT)
|
|
self.expect_red(self.split_env('tcp'), logs, 'TCP session has no endpoint and server pid arm proof',
|
|
tcp=True)
|
|
|
|
def test_r5_no_server_log(self):
|
|
logs = good_logs('inproc')
|
|
del logs['mobilegl.server.log']
|
|
self.expect_red(self.split_env('inproc'), logs, 'but the run wrote no')
|
|
|
|
def test_r6_a_server_fatal(self):
|
|
logs = good_logs('inproc')
|
|
logs['mobilegl.server.log'] += lines('MGPipe: Fatal{UnmigratedVerb, "X"}')
|
|
self.expect_red(self.split_env('inproc'), logs, 'MGPipe Fatal(s) under MOBILEGL_TRANSPORT')
|
|
|
|
def test_r7_a_floor_line(self):
|
|
logs = good_logs('inproc')
|
|
logs['mobilegl.server.log'] += lines('MGWIRE-FLOOR unsound-serial-complete serial=3')
|
|
self.expect_red(self.split_env('inproc'), logs, 'MGWIRE-FLOOR unsound-serial-complete line(s)')
|
|
|
|
def test_r8_no_frame_sent_lines(self):
|
|
logs = good_logs('inproc')
|
|
logs['mobilegl.client.log'] = lines(MARKER['inproc'])
|
|
self.expect_red(self.split_env('inproc', wire=True), logs, 'committed no DrawVbo record to the wire')
|
|
|
|
def test_r9_frames_sent_without_a_draw(self):
|
|
logs = good_logs('inproc')
|
|
logs['mobilegl.client.log'] = lines(MARKER['inproc'], 'P65LinkMetrics kind=frame-sent frame=1 '
|
|
'records=2 by_op=57=1,67=1,159=4,590=2')
|
|
self.expect_red(self.split_env('inproc', wire=True), logs, 'committed no DrawVbo record to the wire')
|
|
|
|
def test_r10_the_server_drew_nothing(self):
|
|
logs = good_logs('inproc')
|
|
logs['mobilegl.server.log'] = lines(SERVER_START, STATS_ON,
|
|
'MGPipe stats: frames=1 window=1 draws=0 draws/f=0.00 acc=0')
|
|
self.expect_red(self.split_env('inproc', wire=True), logs, 'backend executed no draw')
|
|
|
|
def test_r11_an_unknown_transport(self):
|
|
env = self.split_env('inproc')
|
|
env['MOBILEGL_TRANSPORT'] = 'unix:/x'
|
|
self.expect_red(env, good_logs('inproc'), 'this runner has no marker for it')
|
|
|
|
def test_r12_wire_evidence_without_a_split_transport(self):
|
|
self.expect_red({'MGTRACE_WIRE_EVIDENCE': '1', 'MOBILEGL_TRANSPORT': 'monolith'}, {},
|
|
'a run with no wire has no wire volume to count')
|
|
|
|
def test_r13_the_stub_refuses_counters_on_a_leg_that_did_not_ask(self):
|
|
env = self.split_env('inproc')
|
|
env['MOBILEGL_PIPE_STATS'] = '1'
|
|
self.expect_red(env, good_logs('inproc'), 'stub: MOBILEGL_PIPE_STATS reached a leg that did not ask for it')
|
|
|
|
# Green: the same logs as a real leg writes, on every arm.
|
|
def test_g1_inproc_with_wire(self):
|
|
output = self.expect_green(self.split_env('inproc', wire=True), good_logs('inproc'), 'inproc', wire=True)
|
|
self.assertIn('wire_draw_records=58, server_draws=58', output)
|
|
|
|
def test_g1b_inproc_marker_only(self):
|
|
self.expect_green(self.split_env('inproc'), good_logs('inproc'), 'inproc')
|
|
values = checker.parse(self.evidence.read_text())
|
|
self.assertEqual((values['wire'], values['wire_draw_records']), ('OFF', '-'))
|
|
self.assertNotEqual(quiet_check([str(self.evidence), '--transport', 'inproc', '--require-wire']), 0)
|
|
|
|
def test_g2_spawn_with_wire(self):
|
|
self.expect_green(self.split_env('spawn', wire=True), good_logs('spawn'), 'spawn', wire=True)
|
|
|
|
def test_g3_tcp_with_wire(self):
|
|
self.expect_green(self.split_env('tcp', wire=True), good_logs('tcp'), 'spawn', tcp=True, wire=True)
|
|
self.assertEqual(checker.parse(self.evidence.read_text())['server_draws'], 'n/a')
|
|
|
|
def test_g4_monolith_leaves_no_evidence(self):
|
|
rc, output = self.run_case({'MOBILEGL_TRANSPORT': 'monolith'}, {'mobilegl.log': lines('Config: loaded')})
|
|
self.assertEqual(rc, 0, output)
|
|
self.assertFalse(self.evidence.exists())
|
|
self.assertNotEqual(quiet_check([str(self.evidence), '--transport', 'inproc']), 0,
|
|
'a leg whose transport block was skipped must fail the checker')
|
|
|
|
# MGTRACE_EXPECT_IPC: the client's IPC line must carry every token, whole.
|
|
IPC = 'Config: IPC ring=8MiB run-ahead={ra} present-credit=1 strict={st} audit=0 role-split-state={rs} x'
|
|
|
|
def ipc_logs(self, ra='1', st='0', rs='1', line=True):
|
|
logs = good_logs('spawn')
|
|
if line:
|
|
logs['mobilegl.client.log'] += lines(self.IPC.format(ra=ra, st=st, rs=rs))
|
|
return logs
|
|
|
|
def ipc_env(self, expect='strict=0 role-split-state=1 run-ahead=1'):
|
|
env = self.split_env('spawn')
|
|
env.update({'MGTRACE_EXPECT_IPC': expect, 'MGTRACE_EXPECT_IPC_REASON': 'restore the inproc rows'})
|
|
return env
|
|
|
|
def test_i1_the_expected_defaults_are_met(self):
|
|
output = self.expect_green(self.ipc_env(), self.ipc_logs(), 'spawn')
|
|
self.assertIn('IPC expectation met', output)
|
|
self.assertEqual(checker.parse(self.evidence.read_text())['ipc_expected'],
|
|
'strict=0 role-split-state=1 run-ahead=1')
|
|
|
|
def test_i2_a_changed_default_names_the_reason(self):
|
|
self.expect_red(self.ipc_env(), self.ipc_logs(rs='0'), 'lacks role-split-state=1')
|
|
self.expect_red(self.ipc_env(), self.ipc_logs(rs='0'), 'restore the inproc rows')
|
|
|
|
def test_i3_no_ipc_line(self):
|
|
self.expect_red(self.ipc_env(), self.ipc_logs(line=False), "carries no 'Config: IPC' line")
|
|
|
|
def test_i4_a_token_is_matched_whole(self):
|
|
self.expect_red(self.ipc_env(), self.ipc_logs(ra='10'), 'lacks run-ahead=1')
|
|
|
|
def test_i5_an_expectation_on_a_monolith_run(self):
|
|
self.expect_red({'MOBILEGL_TRANSPORT': 'monolith', 'MGTRACE_EXPECT_IPC': 'strict=1'}, {},
|
|
'a monolith run logs no IPC configuration to check')
|
|
|
|
# The verify block, which shares the script.
|
|
def test_v1_verify_without_the_armed_line(self):
|
|
self.expect_red({'MOBILEGL_PIPE_VERIFY': '1'}, {'mobilegl.log': lines('Config: loaded')},
|
|
'never reported "MGPipe: verify armed"')
|
|
|
|
def test_v2_verify_with_a_divergence(self):
|
|
logs = {'mobilegl.log': lines('MGPipe: verify armed',
|
|
'MGPipe: Fatal{PipeVerifyDiffer, "GetRenderStateParameters@Clear"}')}
|
|
self.expect_red({'MOBILEGL_PIPE_VERIFY': '1'}, logs, 'MGPipe Fatal(s) under MOBILEGL_PIPE_VERIFY')
|
|
|
|
def test_v4_an_aborted_verify_run_names_its_divergence(self):
|
|
# The comparator aborts the replay before result.json, so the script dumps the library log;
|
|
# the retrace-verify control requires that dump to carry the divergence it caused, through
|
|
# the same scripts/ci/retrace_verify_reason.py the workflow runs. Another Fatal is not it,
|
|
# and neither is the comparator's divergence on a field the control did not corrupt.
|
|
fatal = 'MGPipe: Fatal{PipeVerifyDiffer, "GetRenderStateParameters@Clear"} field=3 where=entry'
|
|
other = 'MGPipe: Fatal{UnmigratedPipeInput, "GetPixelStoreParameters@ReadPixels"}'
|
|
other_field = 'MGPipe: Fatal{PipeVerifyDiffer, "GetPixelStoreParameters@ReadPixels"} field=1 where=entry'
|
|
env = {'MOBILEGL_PIPE_VERIFY': '1', 'STUB_ABORT': '1'}
|
|
rc, output = self.run_case(env, {'mobilegl.log': lines('MGPipe: verify armed', fatal)})
|
|
self.assertNotEqual(rc, 0, output)
|
|
transcript = self.root / 'control.txt'
|
|
for log, expected in ((lines('MGPipe: verify armed', fatal), 0), (lines('MGPipe: verify armed'), 1),
|
|
(lines('MGPipe: verify armed', other), 1),
|
|
(lines('MGPipe: verify armed', other_field), 1)):
|
|
_, raw = self.run_case(env, {'mobilegl.log': log}, squashed=False)
|
|
transcript.write_text(''.join('1: ' + row + '\n' for row in raw.splitlines()))
|
|
check = subprocess.run([sys.executable, str(VERIFY_REASON), str(transcript)],
|
|
capture_output=True, text=True)
|
|
self.assertEqual(check.returncode, expected, raw + check.stdout)
|
|
|
|
def test_v3_verify_green(self):
|
|
rc, output = self.run_case({'MOBILEGL_PIPE_VERIFY': '1'}, {'mobilegl.log': lines('MGPipe: verify armed')})
|
|
self.assertEqual(rc, 0, output)
|
|
self.assertIn('armed, zero divergences', output)
|
|
|
|
|
|
class EvidenceCheckerTests(unittest.TestCase):
|
|
GOOD = ('case=Canned\nbackend=DirectGLES\ntransport=spawn\ncontrol=-\nmarker=m\n'
|
|
'second=spawn ARMED - the server role runs in pid \nserver_log=1\nfatal_lines=0\nfloor_lines=0\n'
|
|
'wire=ON\ndraw_opcode=59\nwire_draw_records=58\nserver_draws=58\n')
|
|
|
|
def check(self, text, *args):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
path = Path(directory) / 'split-evidence.txt'
|
|
if text is not None:
|
|
path.write_text(text)
|
|
return quiet_check([str(path)] + list(args))
|
|
|
|
def test_good_file_passes(self):
|
|
self.assertEqual(self.check(self.GOOD, '--transport', 'spawn', '--require-wire'), 0)
|
|
|
|
def test_missing_file_fails(self):
|
|
self.assertEqual(self.check(None, '--transport', 'spawn'), 1)
|
|
|
|
def test_transport_mismatch_fails(self):
|
|
self.assertEqual(self.check(self.GOOD.replace('transport=spawn', 'transport=inproc'),
|
|
'--transport', 'spawn'), 1)
|
|
|
|
def test_case_mismatch_fails(self):
|
|
self.assertEqual(self.check(self.GOOD, '--transport', 'spawn', '--case', 'OpenRA'), 1)
|
|
|
|
def test_zero_wire_records_fail(self):
|
|
self.assertEqual(self.check(self.GOOD.replace('wire_draw_records=58', 'wire_draw_records=0'),
|
|
'--transport', 'spawn'), 1)
|
|
|
|
def test_zero_server_draws_fail(self):
|
|
self.assertEqual(self.check(self.GOOD.replace('server_draws=58', 'server_draws=0'),
|
|
'--transport', 'spawn'), 1)
|
|
|
|
def test_spawn_without_second_sentence_fails(self):
|
|
self.assertEqual(self.check(self.GOOD.replace('second=spawn ARMED - the server role runs in pid ',
|
|
'second=-'), '--transport', 'spawn'), 1)
|
|
|
|
def test_a_fatal_count_fails(self):
|
|
self.assertEqual(self.check(self.GOOD.replace('fatal_lines=0', 'fatal_lines=2'),
|
|
'--transport', 'spawn'), 1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|