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'