[WIP] (MG_Remote): implement P6.5 TCP and stream transport

This commit is contained in:
2026-09-22 07:19:18 -04:00
parent 810b9b7a9f
commit 52ffc6a7f2
68 changed files with 4934 additions and 928 deletions
+45 -8
View File
@@ -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
+110
View File
@@ -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())
+16 -3
View File
@@ -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__":
+92
View File
@@ -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()
+143
View File
@@ -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())
+102
View File
@@ -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()
+181
View File
@@ -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()
+28
View File
@@ -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)