From c42577a42a77432aff626460cb6816c03788784e Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 20 Sep 2026 00:53:07 -0400 Subject: [PATCH] [CI, MG_IntegrationTest] (Disaggregated): P5f close - reject incomplete or skipped exit evidence --- .github/workflows/test.yml | 19 +++ MobileGL/MG_IntegrationTest/CMakeLists.txt | 6 + .../Harness/split_log_paths.py | 103 ++++++++++----- .../Harness/test_split_log_paths.py | 118 ++++++++++++++++++ 4 files changed, 215 insertions(+), 31 deletions(-) create mode 100644 MobileGL/MG_IntegrationTest/Harness/test_split_log_paths.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8c02edc1..198eb889 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1392,6 +1392,10 @@ jobs: echo "dual-block census run: rc=${rc}" rows=$(awk '!/^[[:space:]]*(#|$)/ { n++ } END { print n+0 }' "${expected}") + if [ "${rows}" -eq 0 ] && [ "${rc}" -ne 0 ]; then + echo "::error::empty dual-block census requires ctest success, including infrastructure/execution status" + exit "${rc}" + fi if [ "${rc}" -eq 0 ] && [ "${rows}" -gt 0 ]; then echo "::error::the dual-block census lane went GREEN with ${rows} expected pair(s) outstanding - the knob stopped arming, or the fields retired and dualblock-expected-fatals.txt was not emptied in the same commit" exit 1 @@ -1399,6 +1403,21 @@ jobs: python3 "${helper}" expect-fatal "${RUNNER_TEMP}/dualblock-lane.json" \ "${RUNNER_TEMP}/dualblock.xml" "${expected}" + - name: P5f per-frame zero residual reads must execute on both backends + working-directory: build-split + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_IPC_ROLE_SPLIT_STATE: "1" + MOBILEGL_IPC_STRICT_ERRORS: "1" + run: | + ctest -L integration-p5f-rsp --show-only=json-v1 > "${RUNNER_TEMP}/rsp-lane.json" + ctest -L integration-p5f-rsp --no-tests=error --output-on-failure \ + --output-junit "${RUNNER_TEMP}/rsp.xml" + python3 "${GITHUB_WORKSPACE}/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py" \ + require-green "${RUNNER_TEMP}/rsp-lane.json" "${RUNNER_TEMP}/rsp.xml" \ + DirectGLES.Split.P5fRsp.F1WireScenario.EachWireFrameHasZeroResidualPulls \ + DirectVulkan.Split.P5fRsp.F1WireScenario.EachWireFrameHasZeroResidualPulls + # The mechanism's own negative control (P5F §6): the same named case must be green with # the knob on and red - on its own assertion text - with it off. - name: Dual-block negative control (P5f f1, P5F section 6) diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 97873c0b..16d2c017 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -2601,3 +2601,9 @@ if (MOBILEGL_BUILD_DISAGGREGATED) ENVIRONMENT "${p5f_rsp_environment}") endforeach() endif() + +if (MOBILEGL_BUILD_DISAGGREGATED) + add_test(NAME SplitLogPaths.ResultAccountingControls + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/Harness/test_split_log_paths.py) + set_tests_properties(SplitLogPaths.ResultAccountingControls PROPERTIES LABELS "unit") +endif() diff --git a/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py b/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py index 988bfbea..8969140c 100644 --- a/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py +++ b/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py @@ -16,7 +16,7 @@ def paths(document): props = {p["name"]: p["value"] for p in test.get("properties", [])} values = [v.split("=", 1)[1] for v in props.get("ENVIRONMENT", []) if v.startswith("MOBILEGL_LOG_FILE_PATH=")] - is_split = test["name"].startswith("DirectGLES.Split.") + is_split = test["name"].startswith(("DirectGLES.Split.", "DirectVulkan.Split.")) if is_split and (len(values) != 1 or not values[0]): raise ValueError(f"{test['name']}: requires exactly one nonempty MOBILEGL_LOG_FILE_PATH") for value in values: @@ -113,6 +113,55 @@ def print_marker_table(title, table): print(f" {len(table[pair]):4d} {pair}") +# These six skips predate P5f closure. Match both the exact entry and its reason; +# losing a GPU/preflight, a required lane marker, or either RSP probe is never allowed. +DUALBLOCK_ALLOWED_SKIPS = { + "DirectGLES.Split.TriangleScenario.TheServerStampedAVerbBoundaryOnThisDrawingFrame": "not the strict-arming lane:", + "DirectGLES.Split.SmallRing.TriangleScenario.TheServerStampedAVerbBoundaryOnThisDrawingFrame": "not the strict-arming lane:", + "DirectGLES.Split.PersistentCoherentMapScenario.TheMapLandsInTheArmItsLaneDeclares": "not the counting lane: MGITEST_PMAP_LANE", + "DirectGLES.Split.SmallRing.PersistentCoherentMapScenario.TheMapLandsInTheArmItsLaneDeclares": "not the counting lane: MGITEST_PMAP_LANE", + "DirectVulkan.Split.Fm.ClipDistanceScenario.ADisabledClipDistanceRemovesNothing": "clips by a DISABLED gl_ClipDistance", + "DirectVulkan.Split.Fm.ClipDistanceScenario.TheEnablesAreIndependentPerDistance": "clips by every declared gl_ClipDistance regardless of the enables", +} + + +def complete_results(document, junit_path, allowed_skips=None): + expected = [test["name"] for test in document.get("tests", [])] + if not expected or len(expected) != len(set(expected)): + raise ValueError("result accounting: discovery is empty or has duplicate names") + cases = ET.parse(junit_path).getroot().findall(".//testcase") + by_name = {} + for case in cases: + name = case.get("name") + if name in by_name: + raise ValueError(f"result accounting: duplicate JUnit entry {name}") + by_name[name] = case + missing, extra = set(expected) - set(by_name), set(by_name) - set(expected) + if missing or extra: + raise ValueError(f"result accounting: missing {sorted(missing)}, unexpected {sorted(extra)}") + allowed_skips = allowed_skips or {} + for name, case in by_name.items(): + if case.find("skipped") is not None: + reason = allowed_skips.get(name) + text = " ".join(" ".join(case.itertext()).split()) + if reason is None or reason not in text: + raise ValueError(f"result accounting: unexpected skip/reason for {name}") + elif case.get("status") in ("notrun", "disabled"): + raise ValueError(f"result accounting: {name} did not execute ({case.get('status')})") + return by_name + + +def require_green(document, junit_path, required): + names = {test["name"] for test in document.get("tests", [])} + if not required or names != set(required): + raise ValueError(f"required green entries differ: expected {sorted(required)}, discovered {sorted(names)}") + by_name = complete_results(document, junit_path) + for name, case in by_name.items(): + if case.find("failure") is not None or case.find("error") is not None or case.get("status") == "fail": + raise ValueError(f"required green entry failed: {name}") + print(f"SplitLogPaths require-green: {len(by_name)} PASS, 0 skip, 0 failed, complete discovery") + + def expect_fatal(document, junit_path, expected_path): """THE DUAL-BLOCK LANE'S CENSUS AND TWO-SIDED RATCHET (P5f f1, P5F §4/§6). @@ -137,41 +186,30 @@ def expect_fatal(document, junit_path, expected_path): empty expected file IS the P5f exit state, which this mode then verifies rather than obstructs.""" logs = marker_log_paths(document) - cases = ET.parse(junit_path).getroot().findall(".//testcase") - by_name = {} - for case in cases: - by_name.setdefault(case.get("name"), []).append(case) - + by_name = complete_results(document, junit_path, DUALBLOCK_ALLOWED_SKIPS) problems = [] fatal, admitted, escalated = {}, {}, {} - scanned = 0 - skipped = 0 - green = 0 - red = 0 - for name, entries in sorted(by_name.items()): - if any(c.find("skipped") is not None for c in entries): + scanned = skipped = green = red = 0 + for name, case in sorted(by_name.items()): + is_skipped = case.find("skipped") is not None + failed = case.find("failure") is not None or case.find("error") is not None or case.get("status") == "fail" + if is_skipped: skipped += 1 - continue - failed = any(c.find("failure") is not None or c.get("status") == "fail" - for c in entries) - if not failed: + elif failed: + red += 1 + else: green += 1 - continue - red += 1 path = logs.get(name) if path is None: - problems.append(f"{name} is RED and declares no MOBILEGL_LOG_FILE_PATH, so the " - "red cannot be shown to be a named Fatal - an unnamed crash is " - "exactly what this lane exists to forbid") - continue + if failed: + problems.append(f"{name} is RED and declares no private log") + continue # metadata/monolith-arm entries have no marker channel if not Path(path).is_file(): - problems.append(f"{name} is RED and its private log {path} does not exist - the " - "process died before it could write one, which is an unnamed crash") + problems.append(f"{name}: declared private log {path} does not exist") continue scanned += 1 - text = Path(path).read_text(errors="replace") entry_fatal = set() - for tag, field, verb, why in MARKER_RE.findall(text): + for tag, field, verb, why in MARKER_RE.findall(Path(path).read_text(errors="replace")): pair = f"{field}@{verb}" if tag == "Fatal": entry_fatal.add(pair) @@ -180,13 +218,13 @@ def expect_fatal(document, junit_path, expected_path): escalated.setdefault(pair, set()).add(name) else: admitted.setdefault(pair, set()).add(name) - if not entry_fatal: - problems.append(f"{name} is RED but its private log carries no " - "Fatal{UnmigratedPipeInput} marker - an unnamed failure, not a " - "census entry") + if failed and not entry_fatal: + problems.append(f"{name} is RED but its private log carries no Fatal{{UnmigratedPipeInput}} marker") + if entry_fatal and not failed: + problems.append(f"{name} did not fail but its private log carries Fatal markers") print(f"SplitLogPaths expect-fatal: {len(by_name)} entrie(s) in the run - " - f"{green} green, {skipped} skipped, {red} red ({scanned} with a private log scanned)") + f"{green} green, {skipped} skipped, {red} red ({scanned} private logs scanned)") print_marker_table("Fatal{UnmigratedPipeInput", fatal) if admitted or escalated: problems.append("%d Admitted marker(s) under the dual-block knob - the unconditional " @@ -294,6 +332,9 @@ def main(): if mode == "expect-fatal": expect_fatal(json.loads(Path(sys.argv[2]).read_text()), sys.argv[3], sys.argv[4]) return + if mode == "require-green": + require_green(json.loads(Path(sys.argv[2]).read_text()), sys.argv[3], sys.argv[4:]) + return selected = paths(json.loads(Path(sys.argv[2]).read_text())) selected = {name: path for name, path in selected.items() if re.search(sys.argv[3], name)} # The SAME exclusion run_control hands ctest -E. Without it this helper and ctest disagree diff --git a/MobileGL/MG_IntegrationTest/Harness/test_split_log_paths.py b/MobileGL/MG_IntegrationTest/Harness/test_split_log_paths.py new file mode 100644 index 00000000..46a086f6 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/test_split_log_paths.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Regression controls for complete, non-vacuous P5f result accounting.""" +import contextlib +import importlib.util +import io +import os +from pathlib import Path +import tempfile +import unittest +import xml.etree.ElementTree as ET + +module_path = Path(os.environ.get("P5F_TEST_HELPER", Path(__file__).with_name("split_log_paths.py"))) +spec = importlib.util.spec_from_file_location("tested_split_logs", module_path) +helper = importlib.util.module_from_spec(spec) +spec.loader.exec_module(helper) +A = "DirectGLES.Split.P5fRsp.F1WireScenario.EachWireFrameHasZeroResidualPulls" +B = "DirectVulkan.Split.P5fRsp.F1WireScenario.EachWireFrameHasZeroResidualPulls" + + +class ResultAccountingTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.expected = self.root / "expected.txt" + self.expected.write_text("# empty P5f census\n") + self.xml = self.root / "run.xml" + self.document = self.discovery([A, B]) + self.results([(A, "run", None), (B, "run", None)]) + + def tearDown(self): + self.temp.cleanup() + + def discovery(self, names): + tests = [] + for i, name in enumerate(names): + log = self.root / (str(i) + ".log") + log.write_text("") + tests.append({"name": name, "properties": [{"name": "ENVIRONMENT", "value": [ + "MOBILEGL_LOG_FILE_PATH=" + str(log)]}]}) + return {"tests": tests} + + def results(self, rows): + suite = ET.Element("testsuite") + for name, status, kind in rows: + case = ET.SubElement(suite, "testcase", name=name, status=status) + if kind: + ET.SubElement(case, kind) + ET.ElementTree(suite).write(self.xml) + + def census(self): + with contextlib.redirect_stdout(io.StringIO()): + helper.expect_fatal(self.document, self.xml, self.expected) + + def test_complete_green_census(self): + self.census() + + def test_missing_junit_entry(self): + self.results([(A, "run", None)]) + with self.assertRaisesRegex(ValueError, "missing"): + self.census() + + def test_empty_discovery(self): + self.document = {"tests": []} + self.results([]) + with self.assertRaisesRegex(ValueError, "empty"): + self.census() + + def test_extra_and_duplicate_results(self): + for rows in [[(A, "run", None), (B, "run", None), ("extra", "run", None)], + [(A, "run", None), (B, "run", None), (B, "run", None)]]: + self.results(rows) + with self.assertRaises(ValueError): + self.census() + + def test_notrun_or_disabled_is_not_pass(self): + for status in ["notrun", "disabled"]: + self.results([(A, "run", None), (B, status, None)]) + with self.assertRaisesRegex(ValueError, "did not execute"): + self.census() + + def test_rsp_skip_is_forbidden(self): + self.results([(A, "run", None), (B, "notrun", "skipped")]) + with self.assertRaisesRegex(ValueError, "unexpected skip"): + self.census() + + def test_known_skip_requires_its_exact_reason(self): + name, reason = next(iter(helper.DUALBLOCK_ALLOWED_SKIPS.items())) + self.document = self.discovery([name]) + self.results([(name, "notrun", "skipped")]) + with self.assertRaisesRegex(ValueError, "unexpected skip"): + self.census() + tree = ET.parse(self.xml) + ET.SubElement(tree.getroot().find("testcase"), "system-out").text = reason + tree.write(self.xml) + self.census() + + def test_admitted_or_fatal_on_passed_case_is_not_hidden(self): + for marker in ['Admitted{UnmigratedPipeInput, "GetProgramObject@Clear"}', + 'Fatal{UnmigratedPipeInput, "GetProgramObject@Clear"}']: + (self.root / "0.log").write_text(marker) + with self.assertRaises(ValueError): + self.census() + + def test_required_pair_must_be_exact_and_both_pass(self): + with contextlib.redirect_stdout(io.StringIO()): + helper.require_green(self.document, self.xml, [A, B]) + for row in [(B, "notrun", "skipped"), (B, "fail", "failure")]: + self.results([(A, "run", None), row]) + with self.assertRaises(ValueError): + helper.require_green(self.document, self.xml, [A, B]) + self.document = self.discovery([A]) + self.results([(A, "run", None)]) + with self.assertRaisesRegex(ValueError, "entries differ"): + helper.require_green(self.document, self.xml, [A, B]) + + +if __name__ == "__main__": + unittest.main()