diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c34d4a77..51c02403 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1822,8 +1822,11 @@ jobs: # # An unbarriered pull is always fatal, and strict also stops any barriered pull that is not # admitted (ARCHITECTURE.md ยง9.2.1). The lane must be green, and its markers must match the - # admitted list and strict-expected-markers.txt in both directions. + # admitted list and strict-expected-markers.txt in both directions. On the disaggregation + # branches the dual-block step below runs these entries in this same environment and makes + # the same checks, so this step runs on dev only. - name: Strict split lane + if: env.MOBILEGL_CI_DISAGGREGATED != 'ON' working-directory: ${{ env.SPLIT_BUILD_DIR }} env: MOBILEGL_ITEST_REQUIRE_GPU: "1" @@ -1927,7 +1930,13 @@ jobs: # is ratcheted two-sided against Harness/dualblock-expected-fatals.txt: a new pair is a # debt somebody now owes, a vanished pair must be removed in the commit that retired it. # With the census empty, this lane is a hard green gate. + # On the disaggregation branches the first run is also the strict split lane: the knob is + # left at its default, which every passed entry's own log must show is the dual block, so + # the environment is the strict lane's exactly. Its checks are made on that run (the + # integration-split part of the JUnit becomes strict-split.xml); a default that changed + # reruns the census with the knob set, as on dev. - name: Dual-block census lane + id: dualblock working-directory: ${{ env.SPLIT_BUILD_DIR }} env: MOBILEGL_ITEST_REQUIRE_GPU: "1" @@ -1938,11 +1947,13 @@ jobs: ulimit -c unlimited helper="${GITHUB_WORKSPACE}/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py" expected="${GITHUB_WORKSPACE}/MobileGL/MG_IntegrationTest/Harness/dualblock-expected-fatals.txt" + strict_expected="${GITHUB_WORKSPACE}/MobileGL/MG_IntegrationTest/Harness/strict-expected-markers.txt" ctest -L 'integration-split|integration-magma-split' --show-only=json-v1 \ > "${RUNNER_TEMP}/dualblock-lane.json" # Stale logs from an earlier step would be read as this run's evidence (ID-119's rule). + reset_logs() { python3 - "${RUNNER_TEMP}/dualblock-lane.json" "${helper}" <<'PY' import importlib.util, json, os, sys spec = importlib.util.spec_from_file_location("slp", sys.argv[2]) @@ -1955,13 +1966,71 @@ jobs: pass print(f"reset {len(set(logs.values()))} private log(s) across {len(logs)} entries") PY + } + census_run() { + rc=0 + ctest -L 'integration-split|integration-magma-split' --no-tests=error -j 4 \ + --output-junit "${RUNNER_TEMP}/dualblock.xml" \ + > "${RUNNER_TEMP}/dualblock-split.txt" 2>&1 || rc=$? + tail -5 "${RUNNER_TEMP}/dualblock-split.txt" + echo "dual-block census run: rc=${rc}" + } + reset_logs - rc=0 - ctest -L 'integration-split|integration-magma-split' --no-tests=error -j 4 \ - --output-junit "${RUNNER_TEMP}/dualblock.xml" \ - > "${RUNNER_TEMP}/dualblock-split.txt" 2>&1 || rc=$? - tail -5 "${RUNNER_TEMP}/dualblock-split.txt" - echo "dual-block census run: rc=${rc}" + if [ "${MOBILEGL_CI_DISAGGREGATED}" != ON ]; then + census_run + else + # Run A: the strict split lane's environment, with the role-split state at its default. + saved_role="${MOBILEGL_IPC_ROLE_SPLIT_STATE}" + unset MOBILEGL_IPC_ROLE_SPLIT_STATE + census_run + export MOBILEGL_IPC_ROLE_SPLIT_STATE="${saved_role}" + + # The strict lane's JUnit is the integration-split part of this run, every name once. + ctest -L integration-split --show-only=json-v1 > "${RUNNER_TEMP}/lane.json" + selected=0 + python3 "${GITHUB_WORKSPACE}/scripts/ci/junit_tally.py" "${RUNNER_TEMP}/dualblock.xml" \ + --select-from "${RUNNER_TEMP}/lane.json" --write-selected "${RUNNER_TEMP}/strict-split.xml" || selected=$? + python3 "${GITHUB_WORKSPACE}/scripts/gen_pipe_field_ownership.py" --print-admitted \ + > "${RUNNER_TEMP}/allowed.txt" + echo "allowlist (derived): $(wc -l < "${RUNNER_TEMP}/allowed.txt") admitted pair(s)" + if [ "${rc}" -ne 0 ]; then + echo "::error::the strict run of integration-split and integration-magma-split under MOBILEGL_IPC_STRICT_ERRORS=1 is RED on a run-ahead server. It is a hard green gate; the census below names the field and the verb of every integration-split entry that aborted, and dualblock-split.txt holds both labels' output." + python3 "${helper}" markers "${RUNNER_TEMP}/lane.json" '.' \ + "${RUNNER_TEMP}/allowed.txt" "${strict_expected}" || true + exit 1 + fi + if [ "${selected}" -ne 0 ]; then + echo "::error::the dual-block run's JUnit does not hold every integration-split entry exactly once, so it cannot stand for the strict split lane" + exit 1 + fi + # The strict lane's two-sided marker census and its arming proof, as in its own step. + python3 "${helper}" markers "${RUNNER_TEMP}/lane.json" '.' \ + "${RUNNER_TEMP}/allowed.txt" "${strict_expected}" no-fatal + if ! grep -q 'DirectGLES\.Split\.StrictArming\.' "${RUNNER_TEMP}/dualblock-split.txt"; then + echo "::error::the strict run did not run DirectGLES.Split.StrictArming., so nothing in this run proves the server ever stamped a verb boundary. A green lane without it is indistinguishable from a lane where strict was never armed." + exit 1 + fi + echo "integration-split under strict is GREEN, two-sided, with the arming control present" + + # Run A is the census run when every passed entry resolved the dual block (and strict + # and run-ahead) on its own; a default that changed reruns with the knob set. + ipc=0 + python3 "${helper}" ipc-config "${RUNNER_TEMP}/dualblock-lane.json" "${RUNNER_TEMP}/dualblock.xml" \ + "${RUNNER_TEMP}/dualblock-ipc.json" strict=1 role-split-state=1 run-ahead=1 \ + --assumed role-split-state,run-ahead || ipc=$? + if [ "${ipc}" -eq 0 ]; then + echo "defaults-on=true" >> "${GITHUB_OUTPUT}" + elif [ "${ipc}" -eq 3 ]; then + echo "::warning::the role-split state or run-ahead default is not on (dualblock-ipc.json names the entries), so the census reruns with MOBILEGL_IPC_ROLE_SPLIT_STATE=1" + echo "defaults-on=false" >> "${GITHUB_OUTPUT}" + rm -f "${RUNNER_TEMP}/dualblock.xml" + reset_logs + census_run + else + exit "${ipc}" + fi + fi rows=$(awk '!/^[[:space:]]*(#|$)/ { n++ } END { print n+0 }' "${expected}") if [ "${rows}" -eq 0 ] && [ "${rc}" -ne 0 ]; then @@ -1996,8 +2065,11 @@ jobs: # ---- MAGMA'S OWN WIRE LANE: FRAMEBUFFER, TEXTURE AND APPLICATION BUFFERS ------------ # # P5f fm: Magma's framebuffer, copy and mipmap verbs consume server records. - # The label remains separate so both backends' coverage stays explicit. + # The label remains separate so both backends' coverage stays explicit. The dual-block + # census run above covers the label in this same environment; with its defaults proven on + # the disaggregation branches this step would repeat it, so it runs only otherwise. - name: Magma split lane (strict, dual-block) + if: env.MOBILEGL_CI_DISAGGREGATED != 'ON' || steps.dualblock.outputs.defaults-on != 'true' working-directory: ${{ env.SPLIT_BUILD_DIR }} env: MOBILEGL_ITEST_REQUIRE_GPU: "1" diff --git a/docs/ci/ci-cleanup-log.md b/docs/ci/ci-cleanup-log.md index 967b8a39..fbbb3ffc 100644 --- a/docs/ci/ci-cleanup-log.md +++ b/docs/ci/ci-cleanup-log.md @@ -50,6 +50,8 @@ them wrong. | 2026-10-17 | `Transport A/B (monolith vs inproc)` (skip-census lanes `arm-monolith`, `arm-inproc` now optional) | Test / `integration-split` | with `build-linux` reused it repeats the integration job's two legs on the same library (79 paired runs, no one-sided red) | the integration legs; `Transport and default-knob resolution check` now reads 'Split lanes' own run and proves, from every passed entry's client log, strict=0, role-split-state=1 and run-ahead=1 unless pinned (`split_log_paths.py ipc-config`); A/B still runs when a default differs or the build is not reused | | 2026-10-17 | `Unit tests on the split runtime` (skip-census lane `unit-split` now optional) | Test / `integration-split` | with `build-linux` reused the test job runs the same unit lane on the same build (182 paired runs; the one recurring one-sided red was a fixed test race) | the test job, which now carries `MOBILEGL_ITEST_REQUIRE_GPU=1` and `libegl-mesa0` on the three branches; the strict unit step is unchanged | | 2026-10-17 | the integration-gpu entries that pin their own `MOBILEGL_TRANSPORT` (804 inproc, 1446 spawn, 131 monolith) and the 12 `TcpServer` fixture entries only they need, in the inproc-acceptance leg (both passes) | Test / `integration` (inproc-acceptance) | their pin overrides the only variable the two legs differ in, so they ran identically in both (173 paired runs; the one one-sided red was a port bind fixed by cde6512b) | the monolith-control leg, which runs them on every branch; `transport_pinned.py` proves per run that the trimmed selection lost exactly that population (else the leg runs everything) and that the monolith-control leg ran every one; skip census: the monolith-control lanes list their own names and the inproc-acceptance lanes drop the pinned ones | +| 2026-10-17 | `Strict split lane` on the disaggregation branches (dev keeps it) | Test / `integration-split-strict` | with the role-split state defaulting on, its environment equals the dual-block census lane's, whose entries are a superset (31 paired runs after the flip, never red only in the strict lane) | the dual-block step's first run leaves the knob at its default, proves per entry from its client log that strict, the dual block and run-ahead resolved on (`ipc-config`), writes the integration-split part of its JUnit as `strict-split.xml` (every name exactly once, `junit_tally.py --select-from`), and makes the strict lane's checks on it (hard green, two-sided marker census, StrictArming ran); a default that changed reruns the census with the knob set | +| 2026-10-17 | `Magma split lane (strict, dual-block)` on the disaggregation branches when the dual-block step proved its defaults | Test / `integration-split-strict` | the dual-block census run covers the `integration-magma-split` label in the same environment | the dual-block census run | ## Kept after investigation diff --git a/scripts/ci/junit_tally.py b/scripts/ci/junit_tally.py index 1862571d..12e89b27 100755 --- a/scripts/ci/junit_tally.py +++ b/scripts/ci/junit_tally.py @@ -143,6 +143,30 @@ def require_entries_passed(path, names): raise ValueError(f'{name}: required to pass, was {state}') +class SelectionError(ValueError): + """The JUnit does not hold each selected name exactly once.""" + + +def write_selected(path, discovery, out): + """Write the testcases of `path` whose names a ctest --show-only=json-v1 document lists, each + exactly once, as a JUnit of their own: the run of one label carved out of a run of a wider one.""" + wanted = [test['name'] for test in json.loads(Path(discovery).read_text()).get('tests', [])] + if not wanted or len(wanted) != len(set(wanted)): + raise SelectionError(f'{discovery}: the selection is empty or names an entry twice') + by_name = {} + for case in ET.parse(path).getroot().iter('testcase'): + by_name.setdefault(case.get('name'), []).append(case) + missing = [name for name in wanted if len(by_name.get(name, [])) == 0] + twice = [name for name in wanted if len(by_name.get(name, [])) > 1] + if missing or twice: + raise SelectionError(f'{len(missing)} selected name(s) missing from {path} (e.g. {missing[:3]}), ' + f'{len(twice)} reported more than once (e.g. {twice[:3]})') + suite = ET.Element('testsuite', name=Path(out).stem, tests=str(len(wanted))) + for name in wanted: + suite.append(by_name[name][0]) + ET.ElementTree(suite).write(out, encoding='UTF-8', xml_declaration=True) + + def tally(path, prefix=None): passed = failed = skipped = 0 for case in ET.parse(path).getroot().iter('testcase'): @@ -170,7 +194,24 @@ def main(): help='Additionally prove actual run-ahead, without fallback or demotion, on each TCP case') parser.add_argument('--require-entry-passed', action='append', default=[], metavar='NAME', help='This ctest entry must be present and passed; a SKIP fails (repeatable)') + parser.add_argument('--select-from', metavar='JSON', + help='ctest --show-only=json-v1 output; with --write-selected, keep only its entries') + parser.add_argument('--write-selected', metavar='XML', + help='write the selected testcases as their own JUnit and tally that; exit 3 when ' + 'a selected name is missing or reported twice') args = parser.parse_args() + if bool(args.select_from) != bool(args.write_selected): + parser.error('--select-from and --write-selected go together') + if args.write_selected: + try: + write_selected(args.junit, args.select_from, args.write_selected) + except SelectionError as exc: + print(f'junit_tally: {exc}', file=sys.stderr) + return 3 + except Exception as exc: # a malformed file is not "zero of everything" + print(f"junit_tally: cannot prove {args.junit}: {exc}", file=sys.stderr) + return 1 + args.junit = args.write_selected 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') if args.require_run_ahead and args.prefix not in ('DirectGLES.Tcp.', 'DirectGLES.TcpDevice.'): diff --git a/scripts/ci/tcp_lane_tools_test.py b/scripts/ci/tcp_lane_tools_test.py index 6c05050e..7e43e6af 100644 --- a/scripts/ci/tcp_lane_tools_test.py +++ b/scripts/ci/tcp_lane_tools_test.py @@ -136,6 +136,28 @@ class Accounting(unittest.TestCase): tally.require_entries_passed(junit, [name]) self.assertIn(name, str(raised.exception)) + def test_a_selection_is_carved_out_exactly_or_refused(self): + """--select-from/--write-selected turns a run of a wider label into the JUnit of a narrower + one. Every selected name must be there exactly once: missing or doubled is exit 3.""" + tally = module('junit_tally') + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + junit, lane, out = root / 'wide.xml', root / 'lane.json', root / 'narrow.xml' + lane.write_text(json.dumps({'tests': [{'name': 'A'}, {'name': 'B'}]})) + + def run(body): + junit.write_text(f'{body}') + argv = ['junit_tally.py', str(junit), '--select-from', str(lane), '--write-selected', str(out)] + with patch.object(sys, 'argv', argv): + return quiet_main(tally.main) + + status, text = run('') + self.assertEqual((status, text.strip()), (0, '1 0 1')) + self.assertEqual([c.get('name') for c in tally.ET.parse(out).getroot().iter('testcase')], ['A', 'B']) + self.assertEqual(run('')[0], 3, 'a missing name') + self.assertEqual(run('')[0], 3, + 'a name reported twice') + def test_equal_counts_do_not_hide_a_missing_case(self): parity = module('spawn_lane_parity') a, b = 'TriangleScenario.Draw', 'TriangleScenario.Read'