mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 10:03:23 +09:00
[WIP] (MG_Remote): implement P6.5 TCP and stream transport
This commit is contained in:
@@ -17,6 +17,11 @@ skipped are three different answers and the caller needs all three.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import re
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
@@ -25,8 +30,33 @@ import xml.etree.ElementTree as ET
|
||||
ARM_PREFIXES = {
|
||||
'--require-split-ran': 'DirectGLES.Split.',
|
||||
'--require-spawn-ran': 'DirectGLES.Spawn.',
|
||||
'--require-tcp-ran': 'DirectGLES.Tcp.',
|
||||
'--require-tcp-device-ran': 'DirectGLES.TcpDevice.',
|
||||
}
|
||||
|
||||
TCP_ARM = re.compile(r'control=tcp data=stream server=\S+ pid=[1-9][0-9]*\b')
|
||||
|
||||
|
||||
def require_tcp_proof(path, prefix, discovery):
|
||||
helper_path = Path(__file__).resolve().parents[2] / 'MobileGL/MG_IntegrationTest/Harness/split_log_paths.py'
|
||||
spec = importlib.util.spec_from_file_location('split_log_paths', helper_path)
|
||||
helper = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(helper)
|
||||
logs = helper.marker_log_paths(json.loads(Path(discovery).read_text()))
|
||||
for case in ET.parse(path).getroot().iter('testcase'):
|
||||
if not case.get('name', '').startswith(prefix):
|
||||
continue
|
||||
if case.find('skipped') is not None or case.get('status') in ('notrun', 'disabled'):
|
||||
continue
|
||||
# The console sink is compiled out of CI builds, so system-out cannot
|
||||
# establish the transport. Read this entry's private role logs instead.
|
||||
name = case.get('name')
|
||||
if name not in logs:
|
||||
raise ValueError(f'{name}: discovery has no private log path')
|
||||
output = helper.read_role_logs(logs[name])
|
||||
if not TCP_ARM.search(output):
|
||||
raise ValueError(f"{case.get('name')}: missing TCP/stream endpoint and server pid arm proof")
|
||||
|
||||
|
||||
def tally(path, prefix=None):
|
||||
passed = failed = skipped = 0
|
||||
@@ -43,18 +73,25 @@ def tally(path, prefix=None):
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) not in (2, 3) or (len(sys.argv) == 3 and sys.argv[2] not in ARM_PREFIXES):
|
||||
print("usage: junit_tally.py <junit.xml> [" + " | ".join(sorted(ARM_PREFIXES)) + "]",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('junit')
|
||||
arms = parser.add_mutually_exclusive_group()
|
||||
for flag, prefix in ARM_PREFIXES.items():
|
||||
arms.add_argument(flag, dest='prefix', action='store_const', const=prefix)
|
||||
parser.add_argument('--discovery', help='CTest --show-only=json-v1 output (required for TCP proof)')
|
||||
args = parser.parse_args()
|
||||
if args.prefix in ('DirectGLES.Tcp.', 'DirectGLES.TcpDevice.') and not args.discovery:
|
||||
parser.error('TCP arm proof requires --discovery to locate each private role log')
|
||||
try:
|
||||
prefix = ARM_PREFIXES[sys.argv[2]] if len(sys.argv) == 3 else None
|
||||
passed, failed, skipped = tally(sys.argv[1], prefix=prefix)
|
||||
prefix = args.prefix
|
||||
passed, failed, skipped = tally(args.junit, prefix=prefix)
|
||||
if prefix in ('DirectGLES.Tcp.', 'DirectGLES.TcpDevice.'):
|
||||
require_tcp_proof(args.junit, prefix, args.discovery)
|
||||
except Exception as exc: # a malformed file is not "zero of everything"
|
||||
print(f"junit_tally: cannot parse {sys.argv[1]}: {exc}", file=sys.stderr)
|
||||
print(f"junit_tally: cannot prove {args.junit}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"{passed} {failed} {skipped}")
|
||||
if len(sys.argv) == 3 and (passed == 0 or failed):
|
||||
if prefix is not None and (passed == 0 or failed):
|
||||
print(f'{prefix} baseline FAILED: no successful entries with that arm prefix, or an '
|
||||
f'already-red selection', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Summarize P65LinkMetrics frame logs without confusing traffic with link throughput.
|
||||
|
||||
Example: p65_link_metrics.py --arm spawn:/tmp/spawn.client.log \
|
||||
--arm tcp-credit1:/tmp/tcp.client.log --warmup-frames 5 --output /tmp/p65-metrics.json
|
||||
|
||||
Enable MOBILEGL_PIPE_STATS=1 in each measured arm. RTT percentiles are upper
|
||||
bounds of fixed power-of-two microsecond buckets, not exact sample quantiles.
|
||||
The separately measured 64 MiB Stage burst throughput can be supplied with
|
||||
--link-mib-per-second; ordinary frame traffic is never called link throughput.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FIELDS = re.compile(r"(\w+)=([^\s]+)")
|
||||
|
||||
|
||||
def summarize(path: Path, warmup: int, throughput: float | None = None) -> dict:
|
||||
frames = []
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
if "P65LinkMetrics " not in line:
|
||||
continue
|
||||
record = dict(FIELDS.findall(line.split("P65LinkMetrics ", 1)[1]))
|
||||
if record.get("kind") != "frame" or int(record["frame"]) <= warmup:
|
||||
continue
|
||||
frames.append(record)
|
||||
if not frames:
|
||||
raise ValueError(f"{path}: no measured frames after warmup={warmup}")
|
||||
histogram = [0] * 32
|
||||
count = samples = stage = wall = cpu = 0
|
||||
weighted_rtt_us = 0.0
|
||||
for frame in frames:
|
||||
buckets = [int(n) for n in frame["rtt_hist_us_pow2"].split(",")]
|
||||
if len(buckets) != 32 or sum(buckets) != int(frame["rtt_samples"]):
|
||||
raise ValueError(f"{path}: malformed RTT histogram in frame {frame['frame']}")
|
||||
histogram = [a + b for a, b in zip(histogram, buckets)]
|
||||
count += int(frame["wait_replies"])
|
||||
n = int(frame["rtt_samples"])
|
||||
samples += n
|
||||
weighted_rtt_us += float(frame["rtt_mean_us"]) * n
|
||||
stage += int(frame["stage_bytes"])
|
||||
wall += int(frame["wall_ns"])
|
||||
cpu += int(frame["client_thread_cpu_ns"])
|
||||
def percentile(q: float) -> int:
|
||||
target = math.ceil(samples * q)
|
||||
if target == 0:
|
||||
return 0
|
||||
seen = 0
|
||||
for i, bucket in enumerate(histogram):
|
||||
seen += bucket
|
||||
if seen >= target:
|
||||
return 1 << i
|
||||
raise AssertionError("histogram count is inconsistent")
|
||||
result = {
|
||||
"log": str(path), "frames": len(frames), "warmup_frames": warmup,
|
||||
"wait_replies": count, "wait_replies_per_frame": count / len(frames),
|
||||
"rtt_samples": samples,
|
||||
"rtt_mean_us": weighted_rtt_us / samples if samples else None,
|
||||
"rtt_p50_upper_us": percentile(0.5), "rtt_p99_upper_us": percentile(0.99),
|
||||
"rtt_histogram_upper_us": [1 << i for i in range(32)],
|
||||
"rtt_histogram_counts": histogram,
|
||||
"stage_bytes": stage, "stage_bytes_per_frame": stage / len(frames),
|
||||
"frame_traffic_mib_per_second": stage * 1e9 / wall / (1024 ** 2) if wall else None,
|
||||
"fps": len(frames) * 1e9 / wall if wall else None,
|
||||
"client_thread_cpu_ms_per_frame": cpu / len(frames) / 1e6,
|
||||
}
|
||||
if throughput is not None:
|
||||
traffic = result["frame_traffic_mib_per_second"]
|
||||
result["measured_link_mib_per_second"] = throughput
|
||||
result["link_bandwidth_fraction"] = traffic / throughput if traffic is not None else None
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--arm", action="append", required=True, metavar="LABEL:LOG")
|
||||
parser.add_argument("--warmup-frames", type=int, default=0)
|
||||
parser.add_argument("--link-mib-per-second", type=float)
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.warmup_frames < 0 or (args.link_mib_per_second is not None and args.link_mib_per_second <= 0):
|
||||
parser.error("warmup must be nonnegative and measured throughput must be positive")
|
||||
report = {}
|
||||
for arm in args.arm:
|
||||
label, sep, filename = arm.partition(":")
|
||||
if not sep or not label or label in report:
|
||||
parser.error("each --arm must have a unique LABEL:LOG")
|
||||
try:
|
||||
report[label] = summarize(Path(filename), args.warmup_frames, args.link_mib_per_second)
|
||||
except (OSError, ValueError, KeyError) as error:
|
||||
parser.error(str(error))
|
||||
if "spawn" in report:
|
||||
baseline = report["spawn"]["client_thread_cpu_ms_per_frame"]
|
||||
for label, arm in report.items():
|
||||
if label != "spawn":
|
||||
arm["client_thread_cpu_delta_vs_spawn_ms"] = arm["client_thread_cpu_ms_per_frame"] - baseline
|
||||
text = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
|
||||
if args.output:
|
||||
args.output.write_text(text, encoding="utf-8")
|
||||
else:
|
||||
print(text, end="")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -29,14 +29,16 @@ CASE = re.compile(r"DirectGLES\.[A-Za-z0-9]+\.(?:F1\.|Ct\.)?([A-Za-z0-9_]+Scenar
|
||||
|
||||
|
||||
def lane_cases(build_dir, label):
|
||||
out = subprocess.run(["ctest", "-N", "-L", label], cwd=build_dir,
|
||||
capture_output=True, text=True).stdout
|
||||
out = subprocess.run(["ctest", "-N", "-L", "^" + label + "$"], cwd=build_dir,
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
return {m.group(1) for m in CASE.finditer(out)}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("build_dir")
|
||||
parser.add_argument("--require-device", action="store_true",
|
||||
help="Also require the configured integration-tcp-device lane")
|
||||
args = parser.parse_args()
|
||||
|
||||
split = lane_cases(args.build_dir, "integration-split")
|
||||
@@ -63,7 +65,18 @@ def main():
|
||||
print("::error::cases in integration-spawn that integration-split does not run: " +
|
||||
", ".join(extra) + ". The split arm is the control; a case only the spawn arm runs "
|
||||
"has no baseline to be compared against.", file=sys.stderr)
|
||||
return 1 if (missing or extra) else 0
|
||||
failed = bool(missing or extra)
|
||||
labels = ["integration-tcp"]
|
||||
if args.require_device:
|
||||
labels.append("integration-tcp-device")
|
||||
for label in labels:
|
||||
cases = lane_cases(args.build_dir, label)
|
||||
missing, extra = sorted(spawn - cases), sorted(cases - spawn)
|
||||
print(f"{label} parity: spawn {len(spawn)}, tcp {len(cases)}")
|
||||
if not cases or missing or extra:
|
||||
print(f"::error::{label}: missing={missing}, extra={extra}", file=sys.stderr)
|
||||
failed = True
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Executable negative controls for TCP lane accounting and supervisor lifecycle."""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def module(name):
|
||||
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(name + '.py'))
|
||||
loaded = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(loaded)
|
||||
return loaded
|
||||
|
||||
|
||||
class Accounting(unittest.TestCase):
|
||||
def test_each_tcp_case_needs_a_real_endpoint_and_pid(self):
|
||||
tally = module('junit_tally')
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
name = 'DirectGLES.Tcp.TriangleScenario.Draw'
|
||||
junit, discovery, base = root / 'junit.xml', root / 'lane.json', root / 'case.log'
|
||||
junit.write_text(f'<testsuite><testcase name="{name}" /></testsuite>')
|
||||
discovery.write_text(json.dumps({'tests': [{'name': name, 'properties': [
|
||||
{'name': 'ENVIRONMENT', 'value': ['MOBILEGL_LOG_FILE_PATH=' + str(base)]}]}]}))
|
||||
role = root / 'case.client.log'
|
||||
role.write_text('control=tcp data=stream server=127.0.0.1:40613 pid=42\n')
|
||||
tally.require_tcp_proof(junit, 'DirectGLES.Tcp.', discovery)
|
||||
for bad in ('', 'control=tcp data=shm server=127.0.0.1:40613 pid=42',
|
||||
'control=tcp data=stream server=127.0.0.1:40613 pid=0',
|
||||
'spawn ARMED - the server role runs in pid 42'):
|
||||
role.write_text(bad)
|
||||
with self.assertRaises(ValueError):
|
||||
tally.require_tcp_proof(junit, 'DirectGLES.Tcp.', discovery)
|
||||
|
||||
def test_equal_counts_do_not_hide_a_missing_case(self):
|
||||
parity = module('spawn_lane_parity')
|
||||
a, b = 'TriangleScenario.Draw', 'TriangleScenario.Read'
|
||||
with patch.object(parity, 'lane_cases', side_effect=[{a}, {a}, {b}]), \
|
||||
patch.object(sys, 'argv', ['spawn_lane_parity.py', 'unused']):
|
||||
self.assertEqual(parity.main(), 1)
|
||||
with patch.object(parity, 'lane_cases', return_value={a}), \
|
||||
patch.object(sys, 'argv', ['spawn_lane_parity.py', 'unused']):
|
||||
self.assertEqual(parity.main(), 0)
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith('linux'), 'supervisor fixture uses Linux /proc')
|
||||
class Supervisor(unittest.TestCase):
|
||||
def test_readiness_does_not_consume_a_connection_and_stop_owns_only_its_pid(self):
|
||||
fixture = module('tcp_server_fixture')
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
server = root / 'server'
|
||||
server.write_text('#!' + sys.executable + '\n'
|
||||
'import socket,sys\n'
|
||||
'port=int(sys.argv[1].rsplit(":",1)[1])\n'
|
||||
's=socket.socket();s.bind(("127.0.0.1",port));s.listen()\n'
|
||||
'i=0\n'
|
||||
'while True:\n'
|
||||
' c,_=s.accept();i+=1;c.sendall(str(i).encode());c.close()\n')
|
||||
server.chmod(0o755)
|
||||
with socket.socket() as reservation:
|
||||
reservation.bind(('127.0.0.1', 0))
|
||||
port = reservation.getsockname()[1]
|
||||
state = root / 'state.json'
|
||||
subprocess.run([sys.executable, str(Path(fixture.__file__)), 'start',
|
||||
'--server', str(server), '--endpoint', f'tcp://127.0.0.1:{port}',
|
||||
'--state', str(state)], check=True)
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port)) as peer:
|
||||
self.assertEqual(peer.recv(10), b'1')
|
||||
# An unrelated live PID with a different start token is not ours.
|
||||
unrelated = root / 'unrelated.json'
|
||||
unrelated.write_text(json.dumps({'pid': os.getpid(), 'start': 'not-this-process'}))
|
||||
fixture.stop(unrelated)
|
||||
finally:
|
||||
fixture.stop(state)
|
||||
with self.assertRaises(OSError):
|
||||
socket.create_connection(('127.0.0.1', port), timeout=.2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the four real arming scenarios with server log forwarding enabled and disabled."""
|
||||
import argparse
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
CASES = {
|
||||
'unlocated': ('DirectGLES', 'UnlocatedIoBlockScenario.TheEmulationIsActuallyArmedWhenTheEnvironmentPinsItOn',
|
||||
{'MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS': '1'}, 'WITHOUT their layout(location) qualifier'),
|
||||
'primitives': ('DirectVulkan', 'PrimitivesGeneratedNoXfbScenario.TheRerouteIsActuallyArmedWhenTheEnvironmentPinsItOn',
|
||||
{'MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE': '1'}, 'PRIMITIVES_GENERATED reroute engaged'),
|
||||
'point-size': ('DirectGLES', 'PointSizeDemotionScenario.TheDemotionIsActuallyArmedWhenTheEnvironmentPinsItOn',
|
||||
{'MOBILEGL_POINT_SIZE_DEMOTION': '1'}, 'demoted to an ordinary varying'),
|
||||
'verify': ('DirectGLES', 'PipeVerifyArmingScenario.Armed',
|
||||
{'MOBILEGL_PIPE_VERIFY': '1', 'MGITEST_PIPE_ARMING_LANE': '1'}, 'MGPipe: verify armed'),
|
||||
}
|
||||
|
||||
|
||||
def read(path):
|
||||
return path.read_text(errors='replace') if path.exists() else ''
|
||||
|
||||
|
||||
def run_arm(build, out, case, forward):
|
||||
backend, test, knobs, marker = CASES[case]
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
with socket.socket() as reserve:
|
||||
reserve.bind(('127.0.0.1', 0))
|
||||
port = reserve.getsockname()[1]
|
||||
endpoint = f'tcp://127.0.0.1:{port}'
|
||||
env = dict(os.environ, MOBILEGL_TRANSPORT='spawn', MOBILEGL_BACKEND_TYPE=backend,
|
||||
MOBILEGL_IPC_REQUIRE_SAME_BUILD='1', MOBILEGL_IPC_TOKEN='', EGL_PLATFORM='surfaceless',
|
||||
LIBGL_ALWAYS_SOFTWARE='1', MOBILEGL_ITEST_REQUIRE_GPU='1', **knobs)
|
||||
for name in ('MOBILEGL_PIPE_VERIFY_CORRUPT', 'MOBILEGL_IPC_CONTROL', 'MOBILEGL_IPC_DIAL'):
|
||||
env.pop(name, None)
|
||||
if Path('/usr/share/glvnd/egl_vendor.d/50_mesa.json').exists():
|
||||
env['__EGL_VENDOR_LIBRARY_FILENAMES'] = '/usr/share/glvnd/egl_vendor.d/50_mesa.json'
|
||||
if Path('/usr/share/vulkan/icd.d/lvp_icd.json').exists():
|
||||
env['VK_ICD_FILENAMES'] = '/usr/share/vulkan/icd.d/lvp_icd.json'
|
||||
peer_env = dict(env, MOBILEGL_IPC_ROLE='server', MOBILEGL_IPC_DIAL='no',
|
||||
MOBILEGL_IPC_LOG_FORWARD=str(int(forward)), MOBILEGL_LOG_FILE_PATH=str(out / 'peer.log'))
|
||||
with (out / 'supervisor.log').open('wb') as output:
|
||||
server = subprocess.Popen([str(build / 'libMobileGLServer.so'), endpoint, '--serve'],
|
||||
env=peer_env, stdout=output, stderr=output, start_new_session=True)
|
||||
try:
|
||||
for _ in range(100):
|
||||
if server.poll() is not None:
|
||||
raise RuntimeError('supervisor exited before listening: ' + read(out / 'supervisor.log'))
|
||||
with socket.socket() as probe:
|
||||
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
probe.bind(('127.0.0.1', port))
|
||||
except OSError as error:
|
||||
if error.errno == errno.EADDRINUSE:
|
||||
break
|
||||
raise
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise RuntimeError('supervisor did not listen')
|
||||
client_env = dict(env, MOBILEGL_IPC_CONTROL=endpoint, MOBILEGL_IPC_DATA='stream',
|
||||
MOBILEGL_IPC_LOG_FORWARD='1', MOBILEGL_LOG_FILE_PATH=str(out / 'client.log'),
|
||||
MGITEST_SPLIT_LANE='1', MGITEST_TCP_LANE='1')
|
||||
result = subprocess.run([str(build / 'MobileGL/MG_IntegrationTest/MobileGLIntegrationTest'),
|
||||
'--gtest_filter=' + test, '--gtest_output=xml:' + str(out / 'result.xml')],
|
||||
env=client_env, capture_output=True, text=True, timeout=120)
|
||||
(out / 'stdout.log').write_text(result.stdout + result.stderr)
|
||||
finally:
|
||||
try:
|
||||
os.killpg(server.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
server.wait(timeout=10)
|
||||
passed = failed = skipped = 0
|
||||
if (out / 'result.xml').exists():
|
||||
for entry in ET.parse(out / 'result.xml').getroot().iter('testcase'):
|
||||
if entry.find('skipped') is not None or entry.get('status') == 'notrun':
|
||||
skipped += 1
|
||||
elif entry.find('failure') is not None:
|
||||
failed += 1
|
||||
else:
|
||||
passed += 1
|
||||
client_log = read(out / 'client.client.log')
|
||||
return {'case': test, 'returncode': result.returncode, 'passed': passed, 'failed': failed, 'skipped': skipped,
|
||||
'tcp_armed': bool(re.search(r'control=tcp data=stream server=\S+ pid=[1-9][0-9]*', client_log)),
|
||||
'marker_in_client': marker in client_log,
|
||||
'marker_in_server': marker in read(out / 'peer.server.log'),
|
||||
'marker_forwarded': marker in read(out / 'client.server.log'),
|
||||
'output': str(out)}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--build-dir', type=Path, required=True)
|
||||
parser.add_argument('--verify-build-dir', type=Path)
|
||||
parser.add_argument('--out', type=Path, required=True)
|
||||
parser.add_argument('--case', action='append', choices=CASES,
|
||||
help='Explicit subset; the default runs all four and requires a verify build')
|
||||
args = parser.parse_args()
|
||||
selected = args.case or list(CASES)
|
||||
if 'verify' in selected and not args.verify_build_dir:
|
||||
parser.error('the verify scenario requires --verify-build-dir configured with MOBILEGL_PIPE_VERIFY=ON')
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
results = {}
|
||||
for case in selected:
|
||||
build = args.verify_build_dir if case == 'verify' else args.build_dir
|
||||
arms = {}
|
||||
for forward in (True, False):
|
||||
name = 'forward-on' if forward else 'forward-off'
|
||||
try:
|
||||
arms[name] = run_arm(build, args.out / case / name, case, forward)
|
||||
except Exception as error:
|
||||
arms[name] = {'error': str(error)}
|
||||
on, off = arms['forward-on'], arms['forward-off']
|
||||
if case == 'verify':
|
||||
# PipeFill owns this marker in the client. Disabling peer forwarding
|
||||
# must leave it visible; requiring red here would test a false design.
|
||||
arms['expectation'] = 'client-owned marker remains green with peer forwarding disabled'
|
||||
arms['proved'] = all(arm.get('passed') == 1 and arm.get('returncode') == 0
|
||||
and arm.get('tcp_armed') and arm.get('marker_in_client')
|
||||
for arm in (on, off))
|
||||
else:
|
||||
arms['expectation'] = 'server-owned marker passes with forwarding and fails without it'
|
||||
arms['proved'] = bool(on.get('passed') == 1 and on.get('returncode') == 0
|
||||
and on.get('tcp_armed') and on.get('marker_in_server') and on.get('marker_forwarded')
|
||||
and off.get('failed') == 1 and not off.get('skipped') and off.get('tcp_armed')
|
||||
and off.get('marker_in_server') and not off.get('marker_forwarded')
|
||||
and not off.get('marker_in_client'))
|
||||
results[case] = arms
|
||||
print(case + ': ' + ('PASS' if arms['proved'] else 'NOT PROVEN'), flush=True)
|
||||
(args.out / 'summary.json').write_text(json.dumps(results, indent=2))
|
||||
return 0 if all(result['proved'] for result in results.values()) else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Start/stop the loopback supervisor used by the integration-tcp CTest fixture."""
|
||||
import argparse
|
||||
import errno
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
def process_start(pid):
|
||||
try:
|
||||
return Path(f'/proc/{pid}/stat').read_text().rsplit(')', 1)[1].split()[19]
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def stop(state):
|
||||
if not state.exists():
|
||||
return
|
||||
saved = json.loads(state.read_text())
|
||||
pid = saved['pid']
|
||||
# PID reuse must never let a stale fixture file kill an unrelated process.
|
||||
if process_start(pid) == saved['start']:
|
||||
os.killpg(pid, signal.SIGTERM)
|
||||
for _ in range(50):
|
||||
if process_start(pid) != saved['start']:
|
||||
break
|
||||
time.sleep(.05)
|
||||
else:
|
||||
try:
|
||||
os.killpg(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
state.unlink()
|
||||
|
||||
|
||||
def start(args):
|
||||
endpoint = urlsplit(args.endpoint)
|
||||
if endpoint.scheme != 'tcp' or not ipaddress.ip_address(endpoint.hostname).is_loopback:
|
||||
raise ValueError('the local fixture requires a numeric loopback TCP endpoint')
|
||||
family = socket.AF_INET6 if ':' in endpoint.hostname else socket.AF_INET
|
||||
address = (endpoint.hostname, endpoint.port)
|
||||
# Do not connect as a readiness probe: it would consume the first half of
|
||||
# AcceptPair, so the next real client would be paired with the probe.
|
||||
with socket.socket(family) as probe:
|
||||
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
probe.bind(address)
|
||||
if args.state.exists():
|
||||
raise RuntimeError(f'stale fixture state; run stop first: {args.state}')
|
||||
env = dict(os.environ, MOBILEGL_IPC_ROLE='server', MOBILEGL_IPC_DIAL='no',
|
||||
MOBILEGL_IPC_TOKEN='', MOBILEGL_IPC_LOG_FORWARD='1')
|
||||
for key in ('MOBILEGL_IPC_CONTROL', 'MOBILEGL_IPC_ENDPOINT', 'MOBILEGL_BACKEND_TYPE'):
|
||||
env.pop(key, None)
|
||||
log_path = args.state.with_suffix('.log')
|
||||
with log_path.open('wb') as log:
|
||||
child = subprocess.Popen([args.server, args.endpoint, '--serve'], env=env,
|
||||
stdin=subprocess.DEVNULL, stdout=log, stderr=log,
|
||||
start_new_session=True)
|
||||
args.state.write_text(json.dumps({'pid': child.pid, 'start': process_start(child.pid)}))
|
||||
try:
|
||||
for _ in range(100):
|
||||
if child.poll() is not None:
|
||||
raise RuntimeError(f'supervisor exited {child.returncode}: {log_path.read_text(errors="replace")}')
|
||||
with socket.socket(family) as probe:
|
||||
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
probe.bind(address)
|
||||
except OSError as error:
|
||||
if error.errno == errno.EADDRINUSE:
|
||||
print(f'TCP supervisor pid={child.pid} endpoint={args.endpoint}')
|
||||
return
|
||||
raise
|
||||
time.sleep(.05)
|
||||
raise RuntimeError(f'supervisor did not listen: {log_path}')
|
||||
except BaseException:
|
||||
stop(args.state)
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('command', choices=('start', 'stop'))
|
||||
parser.add_argument('--state', type=Path, required=True)
|
||||
parser.add_argument('--server')
|
||||
parser.add_argument('--endpoint')
|
||||
args = parser.parse_args()
|
||||
if args.command == 'start':
|
||||
if not args.server or not args.endpoint:
|
||||
parser.error('start requires --server and --endpoint')
|
||||
start(args)
|
||||
else:
|
||||
stop(args.state)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise a real TCP supervisor's Busy, token, version, and build-policy controls."""
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def read_exact(peer, size):
|
||||
result = bytearray()
|
||||
while len(result) < size:
|
||||
chunk = peer.recv(size - len(result))
|
||||
if not chunk:
|
||||
raise RuntimeError('control channel closed before a complete frame')
|
||||
result.extend(chunk)
|
||||
return result
|
||||
|
||||
|
||||
def receive(peer, schema):
|
||||
magic, size = struct.unpack('<4sI', read_exact(peer, 8))
|
||||
if magic != b'MGLF' or size > 64 * 1024 * 1024:
|
||||
raise RuntimeError('invalid control frame')
|
||||
data = read_exact(peer, size)
|
||||
envelope = schema['CtrlEnvelope'].CtrlEnvelope.GetRootAs(data, 0)
|
||||
table = envelope.Msg()
|
||||
kind = envelope.MsgType()
|
||||
if kind == schema['CtrlMsg'].CtrlMsg.Refuse:
|
||||
refusal = schema['Refuse'].Refuse()
|
||||
refusal.Init(table.Bytes, table.Pos)
|
||||
return {'code': refusal.Code(), 'expected': refusal.Expected(),
|
||||
'detail': (refusal.Detail() or b'').decode()}
|
||||
if kind == schema['CtrlMsg'].CtrlMsg.Welcome:
|
||||
welcome = schema['Welcome'].Welcome()
|
||||
welcome.Init(table.Bytes, table.Pos)
|
||||
return {'welcome': welcome.ServerPid(), 'fingerprint': welcome.WireFingerprint()}
|
||||
raise RuntimeError(f'unexpected first control reply {kind}')
|
||||
|
||||
|
||||
def hello(schema, flatbuffers, fingerprint=0, major=1, token='p65-smoke-token'):
|
||||
builder = flatbuffers.Builder(512)
|
||||
build = builder.CreateString('intentionally-different-build-for-p65-control')
|
||||
auth = builder.CreateString(token)
|
||||
terms = schema['LinkTerms']
|
||||
terms.Start(builder)
|
||||
terms.AddDataPlane(builder, 1)
|
||||
link = terms.End(builder)
|
||||
message = schema['Hello']
|
||||
message.Start(builder)
|
||||
message.AddAbiMajor(builder, major)
|
||||
message.AddBuildFingerprint(builder, build)
|
||||
message.AddBackendType(builder, 0) # DirectGLES
|
||||
message.AddPid(builder, os.getpid())
|
||||
message.AddWireFingerprint(builder, fingerprint)
|
||||
message.AddLinkTerms(builder, link)
|
||||
message.AddToken(builder, auth)
|
||||
message.AddDialMode(builder, 2) # Connect
|
||||
body = message.End(builder)
|
||||
envelope = schema['CtrlEnvelope']
|
||||
envelope.Start(builder)
|
||||
envelope.AddMsgType(builder, schema['CtrlMsg'].CtrlMsg.Hello)
|
||||
envelope.AddMsg(builder, body)
|
||||
builder.Finish(envelope.End(builder), b'MGLC')
|
||||
payload = bytes(builder.Output())
|
||||
return struct.pack('<4sI', b'MGLF', len(payload)) + payload
|
||||
|
||||
|
||||
@contextmanager
|
||||
def supervisor(server, log, same_build=False):
|
||||
with socket.socket() as reserve:
|
||||
reserve.bind(('127.0.0.1', 0))
|
||||
port = reserve.getsockname()[1]
|
||||
env = dict(os.environ, MOBILEGL_IPC_ROLE='server', MOBILEGL_IPC_DIAL='no',
|
||||
MOBILEGL_IPC_TOKEN='p65-smoke-token', MOBILEGL_IPC_REQUIRE_SAME_BUILD=str(int(same_build)),
|
||||
MOBILEGL_IPC_LOG_FORWARD='0', MOBILEGL_LOG_FILE_PATH=str(log.with_suffix('.library.log')))
|
||||
for name in ('MOBILEGL_IPC_CONTROL', 'MOBILEGL_IPC_ENDPOINT', 'MOBILEGL_BACKEND_TYPE'):
|
||||
env.pop(name, None)
|
||||
with log.open('wb') as output:
|
||||
child = subprocess.Popen([server, f'tcp://127.0.0.1:{port}', '--serve'], env=env,
|
||||
stdout=output, stderr=output, start_new_session=True)
|
||||
try:
|
||||
yield port, child
|
||||
finally:
|
||||
try:
|
||||
os.killpg(child.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
child.wait(timeout=10)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def pair(port):
|
||||
deadline = time.monotonic() + 5
|
||||
while True:
|
||||
try:
|
||||
control = socket.create_connection(('127.0.0.1', port), timeout=3)
|
||||
break
|
||||
except ConnectionRefusedError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise
|
||||
time.sleep(.02)
|
||||
try:
|
||||
data = socket.create_connection(('127.0.0.1', port), timeout=3)
|
||||
try:
|
||||
yield control
|
||||
finally:
|
||||
data.close()
|
||||
finally:
|
||||
control.close()
|
||||
|
||||
|
||||
def exchange(port, schema, message):
|
||||
# A prior rejected child may still be reaching _exit when the next pair
|
||||
# arrives. Only Busy may be retried here, and never in the Busy assertion.
|
||||
for _ in range(30):
|
||||
with pair(port) as control:
|
||||
control.sendall(message)
|
||||
reply = receive(control, schema)
|
||||
if reply.get('code') != 7:
|
||||
return reply
|
||||
time.sleep(.05)
|
||||
raise RuntimeError('supervisor remained Busy after the previous peer closed')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--server', required=True)
|
||||
parser.add_argument('--flatc', default='flatc')
|
||||
parser.add_argument('--out', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix='mgl-tcp-schema-') as directory:
|
||||
subprocess.run([args.flatc, '--python', '-o', directory,
|
||||
str(ROOT / 'MobileGL/MG_Remote/Protocol/protocol.fbs')], check=True)
|
||||
sys.path[:0] = [directory, str(ROOT / '3rdparty/flatbuffers/python')]
|
||||
import flatbuffers
|
||||
schema = {name: importlib.import_module('MobileGL.Wire.' + name)
|
||||
for name in ('CtrlEnvelope', 'CtrlMsg', 'Hello', 'LinkTerms', 'Refuse', 'Welcome')}
|
||||
evidence = {}
|
||||
with supervisor(args.server, args.out / 'supervisor.log') as (port, process):
|
||||
with pair(port) as held:
|
||||
with pair(port) as second:
|
||||
busy = receive(second, schema)
|
||||
assert busy.get('code') == 7, busy
|
||||
evidence['busy'] = busy
|
||||
token = exchange(port, schema, hello(schema, flatbuffers, token='wrong-token'))
|
||||
assert token.get('code') == 4, token
|
||||
evidence['wrong_token'] = token
|
||||
version = exchange(port, schema, hello(schema, flatbuffers, major=99))
|
||||
assert version.get('code') == 1, version
|
||||
evidence['wire_major_99'] = version
|
||||
layout = exchange(port, schema, hello(schema, flatbuffers))
|
||||
assert layout.get('code') == 2 and layout.get('expected', 0), layout
|
||||
fingerprint = layout['expected']
|
||||
evidence['wrong_layout'] = layout
|
||||
accepted = exchange(port, schema, hello(schema, flatbuffers, fingerprint=fingerprint))
|
||||
assert accepted.get('welcome', 0) and accepted['welcome'] != os.getpid(), accepted
|
||||
assert accepted.get('fingerprint') == fingerprint, accepted
|
||||
evidence['different_build_connect'] = accepted
|
||||
assert process.poll() is None, 'a rejected session killed the supervisor'
|
||||
with supervisor(args.server, args.out / 'same-build-supervisor.log', same_build=True) as (port, _):
|
||||
strict = exchange(port, schema, hello(schema, flatbuffers, fingerprint=fingerprint))
|
||||
assert strict.get('code') == 3, strict
|
||||
evidence['same_build_required'] = strict
|
||||
(args.out / 'protocol-controls.json').write_text(json.dumps(evidence, indent=2))
|
||||
print(json.dumps(evidence, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -692,6 +692,33 @@ def banner(name, title, sources):
|
||||
return GENERATED_BANNER.format(name=name, title=title, sources=sources)
|
||||
|
||||
|
||||
def gen_wire_layout(calls, payloads):
|
||||
"""Compiler-evaluated wire layout, including same-width member reorders."""
|
||||
out = [banner("PipeWireLayout.inc", "P6.5: wire layout and ordered catalogue.",
|
||||
"PipeFields.def and PipeCalls.def")]
|
||||
out += ["struct WireLayoutMember { const char* Name; Uint64 Offset; Uint64 Size; };",
|
||||
"inline constexpr WireLayoutMember kMGPipeWireLayoutMembers[] = {"]
|
||||
fields = parse_field_lists()
|
||||
for payload in payloads:
|
||||
out.append(' {"%s", 0, sizeof(%s)},' % (payload, payload))
|
||||
for field in fields[payload]:
|
||||
out.append(' {"%s.%s", offsetof(%s, %s), sizeof(((%s*)nullptr)->%s)},' %
|
||||
(payload, field, payload, field, payload, field))
|
||||
for payload, names in (("MGPipeHandle", ("Slot", "Gen")),
|
||||
("MGPWireRecHeader", ("Op", "Flags", "Size"))):
|
||||
out.append(' {"%s", 0, sizeof(%s)},' % (payload, payload))
|
||||
for field in names:
|
||||
out.append(' {"%s.%s", offsetof(%s, %s), sizeof(((%s*)nullptr)->%s)},' %
|
||||
(payload, field, payload, field, payload, field))
|
||||
out += ["};", "struct WireCallLayout { const char* Name; const char* Payload; Uint64 Op; Uint64 Flags; Uint64 Wait; };",
|
||||
"inline constexpr WireCallLayout kMGPipeWireCallLayouts[] = {"]
|
||||
for call in calls:
|
||||
out.append(' {"%s", "%s", static_cast<Uint64>(MGPWireOp::%s), static_cast<Uint64>(%s), static_cast<Uint64>(%s)},' %
|
||||
(call.Name, call.Payload, call.Name, " | ".join(call.Flags), call.Wait))
|
||||
out += ["};", ""]
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def gen_tables(calls):
|
||||
screen = [c for c in calls if c.IsScreen]
|
||||
context = [c for c in calls if not c.IsScreen]
|
||||
@@ -1698,6 +1725,7 @@ def main():
|
||||
write(os.path.join(GENERATED_DIR, "PipeThunks.inc"), gen_thunks(calls), args.check, changed)
|
||||
write(os.path.join(GENERATED_DIR, "PipeWire.inc"),
|
||||
gen_wire(calls, parse_field_lists().get("ResidualValueBlock")), args.check, changed)
|
||||
write(os.path.join(GENERATED_DIR, "PipeWireLayout.inc"), gen_wire_layout(calls, payloads), args.check, changed)
|
||||
write(os.path.join(GENERATED_DIR, "PipeVerify.inc"), gen_verify(payloads), args.check, changed)
|
||||
write(os.path.join(GENERATED_DIR, "PipeFilled.inc"), gen_filled(accessors, calls, sticky, emitted),
|
||||
args.check, changed)
|
||||
|
||||
Reference in New Issue
Block a user