[CI, MG_IntegrationTest] (Disaggregated): P5f close - reject incomplete or skipped exit evidence

This commit is contained in:
2026-09-20 00:53:07 -04:00
parent 53699b5aa9
commit c42577a42a
4 changed files with 215 additions and 31 deletions
+19
View File
@@ -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)
@@ -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()
@@ -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
@@ -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()