mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
[CI, MG_IntegrationTest] (Disaggregated): P5f close - reject incomplete or skipped exit evidence
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user