Compare commits

...
20 Commits
Author SHA1 Message Date
swung0x48 d98f183eba [CI] (.github/workflows, scripts/ci, tools/trace_replay): check the verify control's reason in one script
- scripts/ci/retrace_verify_reason.py holds the retrace-verify control's reason check, a plain search for Fatal{PipeVerifyDiffer, "GetRenderStateParameters@ (run_trace_case.cmake prints the library log through message(STATUS), which never re-wraps, so the old prefix stripping and whitespace collapse are gone); the workflow step and test_run_trace_case.py both run it, so the test can no longer pass on a copy
- canned reds carrying a different Fatal (Fatal{UnmigratedPipeInput, "GetPixelStoreParameters@ReadPixels"}) or the comparator's divergence on another field (Fatal{PipeVerifyDiffer, "GetPixelStoreParameters@ReadPixels"}) must fail the check
2026-10-17 17:13:51 -04:00
swung0x48 9eb92f83c9 [CI] (.github/workflows, docs/ci, scripts/ci): require optional census rows on the path that runs them
- skip_census.py check --require LANE: an optional lane the run took the path for is red when absent, and a name the baseline lacks is red too; self-test cases
- skip-census needs build-guard and reads integration-split's new defaults-on output: any verdict but identical requires unit-split, arm-inproc and arm-monolith, and unproven IPC defaults require the two arm rows
2026-10-17 17:13:51 -04:00
swung0x48 43e671f027 [CI] (.github/workflows, docs/ci, scripts/ci, scripts/data): keep the census green on the pinned fallback
- when transport_pinned.py cannot prove the exclusion, the inproc-acceptance leg runs every entry under its own JUnit stems, integration-all and integration-staged-copy-all
- skip_census.py: an optional row with "instead_of" stands in for a trimmed lane; when it ran, that lane may be absent, and it is required otherwise; self-test on a fallback-shaped directory (green, and red on a vanished name, a new skip or neither lane); write from a fallback run keeps the row of the lane the fallback stood in for
- skip census: two optional -all rows for the inproc-acceptance leg, names borrowed from the monolith-control rows and skip lists equal to the old inproc-acceptance lists (285 and 49)
- the integration legs compute the ctest selection only on the disaggregation branches, which are the only ones that read it
2026-10-17 17:13:50 -04:00
swung0x48 8aa92c357b [CI] (.github/workflows, docs/ci, scripts/ci): decide split build reuse in one guard job
- a new build-guard job configures both Linux lines on one runner (configure only, disaggregation branches) and outputs identical, different or unknown with its runner image and compiler; it is continue-on-error at job level, so it never fails the run, and it takes MOBILEGL_CI_DISAGGREGATED from build-linux's branch expression, so a drifted branch list gives different
- build_fingerprint.py guard: one shared FetchContent directory (normalised as @DEPS@) so only the first configure fetches, a failed configure, or one killed with its process group after 10 minutes, retried once, and unknown for a configure that failed twice, any error or too little evidence; different is a warning annotation and unknown a notice, each with its first reasons; the toolchain is recorded, not compared; self-test cases for both sides thin, no libMobileGL.so link, a hung configure, the retry after a failure and after a timeout, the annotations, the shared directory and every verdict
- build-linux-split builds nothing only on identical, and the consumers take build-linux's artifacts only then; different and unknown keep both builds, the full split matrix, A/B and the split unit run, and no consumer can red on a disagreement
- build-linux drops its probe and runs the link ratchet on every disaggregation build as link-ratchet-primary; integration-split reds on it when it takes that build and prints both jobs' image and compiler
- build-linux exports its build type, and the split consumers refuse to reuse a build-linux that is not Release (split_reuse_build_type.sh, self-test in pipe-gates): a debug-logging re-run of failed jobs keeps the guard's Release verdict
- configure_build.sh passes extra arguments through to cmake
2026-10-17 17:13:49 -04:00
swung0x48 91b889888e [Test] (.github/workflows, docs/ci, MG_IntegrationTest, MG_IntegrationTest/Harness, scripts/data): assert each split entry's transport in-test
- the split fixture fails an armed Split/Spawn/Tcp entry whose process did not resolve the transport its registration claims, or whose server is not where the claim says: a thread here for inproc, a process of its own for spawn and tcp (SplitRuntimePeek now names spawn, reads ClientSession::PeerServerPid() and this process's pid, _getpid() on Windows; an unknown own pid leaves only that comparison unjudged)
- SplitLaneHarness.TransportClaimRule, a GL-free unit test, proves the rule red and green on canned inputs; skip census: added to junit-integration-split-strict/unit-strict (test/unit and unit-split borrow that list)
- integration-split drops its post-hoc grep of one entry's log; the step keeps the IPC default proof as 'Default-knob resolution check', on the disaggregation branches only, and a strict default that changed falls back to Transport A/B like the other two
2026-10-17 16:08:42 -04:00
swung0x48 8b76a4e010 [CI] (.github/workflows, docs/ci, tools/trace_replay): drop retrace-split's repeated inproc rows
- with build-linux reused, retrace-split runs its spawn rows plus OpenRA's inproc rows (the wire evidence); the other 75 inproc rows are replayed by the retrace job's inproc-acceptance rows on the same library
- trace_cases.py: github-split-matrix takes --transports and --keep-inproc; the self-test proves the spawn rows unchanged and every dropped inproc row an inproc-acceptance row; only a narrowed matrix that comes out empty raises, so the workflow's named error for an empty full subset still prints
- trace-cases: the spawn-only subset is only a saving, so a failed self-test or an empty result is a warning, the output is not written and retrace-split takes the full subset
- run_trace_case.cmake: MGTRACE_EXPECT_IPC (a harness name) requires the client's IPC line to carry the listed tokens; retrace-split legs prove strict=0 role-split-state=1 run-ahead=1 and a changed default reds every leg with the rows to restore; the acceptance rows prove strict=1 role-split-state=1 run-ahead=1 and gain the refusal census; canned tests in test_run_trace_case.py
2026-10-17 16:08:41 -04:00
swung0x48 5ce4890c10 [CI] (.github/workflows, docs/ci, scripts/ci): fold the strict split lane into the dual-block run
- on the disaggregation branches the dual-block census step's first run leaves MOBILEGL_IPC_ROLE_SPLIT_STATE at its default, which makes its environment the strict split lane's; the step proves per entry that strict, the dual block and run-ahead resolved on, and makes the strict lane's checks on that run
- junit_tally.py --select-from/--write-selected carves the integration-split part out of dualblock.xml as strict-split.xml, every name exactly once (exit 3 otherwise); tests in tcp_lane_tools_test.py
- 'Strict split lane' runs on dev only; 'Magma split lane' is skipped when the dual-block run proved its defaults, since that run covers the label in the same environment; a changed default reruns the census with the knob set
2026-10-17 16:08:40 -04:00
swung0x48 d5dfc9ffff [CI] (.github/workflows, docs/ci, scripts/ci, scripts/data): run transport-pinned entries once
- the inproc-acceptance leg excludes, by exact name, every integration-gpu entry whose own ENVIRONMENT sets MOBILEGL_TRANSPORT (inproc, spawn and monolith pins) and the fixtures only they require; their pin overrides the one variable the legs differ in, so the monolith-control leg's run is the same run
- transport_pinned.py selects them, proves the trimmed ctest selection lost exactly that set (or the leg runs everything, with a warning rather than an error), and proves in the monolith-control leg that every one of them ran; self-test in pipe-gates
- skip census: junit-integration-monolith-control-monolith/integration and integration-staged-copy get their own name lists; the inproc-acceptance lanes drop the pinned names (3628 -> 1450, 888 -> 336); arm-monolith and arm-inproc borrow from monolith-control
2026-10-17 16:08:39 -04:00
swung0x48 eebbb1a213 [CI] (.github/workflows, docs/ci, MG_IntegrationTest/Harness, scripts/data): reuse build-linux for split
- build-linux and build-linux-split each probe the other's configure line with build_fingerprint.py on their own runner (disaggregation branches only); when it matches, build-linux-split skips its compile and the split consumers take build-linux's runtime and library
- the consumers red when build-linux-split skipped but build-linux's guard did not agree, or when the link ratchet, now also run in build-linux, did not pass; a difference falls back to the old two builds, and dev is unchanged apart from the consumers also waiting for build-linux
- the transport resolution check moves after 'Split lanes' and proves the IPC defaults from each passed entry's client log (split_log_paths.py ipc-config, canned tests); with both proven, Transport A/B and the non-strict split unit run are skipped as repeats
- the test job carries REQUIRE_GPU and libegl-mesa0 on the three branches; skip census: arm-monolith, arm-inproc and unit-split become optional rows
2026-10-17 15:59:57 -04:00
swung0x48 72e7d64c2b [CI] (scripts/ci): let skip-census rows be optional
- a baseline row may carry "optional": "<reason>": the lane's absence is a note, and when it runs it is checked like any other lane
- write keeps an optional row (names resolved) when the run did not produce that lane, and keeps the reason on rows it rewrites
- self-test: an absent optional lane is green, a present one with a new skip is red, a plain lane stays required
2026-10-17 15:59:56 -04:00
swung0x48 9f0ad51d93 [CI] (.github/workflows, scripts/ci): configure both Linux builds from one script
- scripts/ci/configure_build.sh holds build-linux's (primary) and build-linux-split's (split) configure lines, flag for flag as before
- scripts/ci/build_fingerprint.py compares two configured trees (cache, compile commands, link statements, CTest files, toolchain) with the build path normalised and the benchmarks left out; its guard configures a probe through the script and never counts thin evidence as equal
- pipe-gates runs its self-test; no job uses the guard yet
2026-10-17 15:59:56 -04:00
swung0x48 f87ec9ec67 [Chore] (.github/workflows, docs/ci, scripts/ci): delete dead CI control code
- Strict split lane: drop the kMGPipeP5eRunAheadReady grep and its red-by-design pre-flip arm; the constant is true, so the arm never ran
- Dual-block census lane: drop the "green with rows outstanding" rule, inert while dualblock-expected-fatals.txt is empty; the empty-census rule and expect-fatal stay
- delete scripts/ci/tcp_log_forwarding_control.py, which nothing calls
2026-10-17 15:59:55 -04:00
swung0x48 93c4220143 [CI] (.github/workflows, docs/ci, MG_IntegrationTest, MG_IntegrationTest/Scenarios, tools/trace_replay): check verify controls by name, not by rerun
- integration-verify: drop control B, whose pair the in-lane PoisonOmitted entries already pin with a stricter check; the arming grep becomes junit_tally --require-entry-passed on the six VerifyArming, VerifyCorrupted and PoisonOmitted entries (a skip fails)
- integration-verify-split: drop the server-side-read rerun and control B; the same check covers the eight VerifySplit control entries, which assert those properties in-test
- controls A and A2 stay: nothing in-lane covers the FATAL=1 abort half or the capability field
- retrace-verify: the corrupted-snapshot control must go red with its own Fatal{PipeVerifyDiffer, "GetRenderStateParameters@"; test_run_trace_case.py proves the check on canned output
- MG_IntegrationTest/CMakeLists.txt: the comments on the VerifySplit control entries say how CI now checks them
- PoisonOmissionScenario.cpp: WithoutOmissionCompletes is described as the ambient half of the pair, and its failure note no longer names a CI step
2026-10-17 15:59:54 -04:00
swung0x48 c5e4ddd330 [CI] (.github/workflows, docs/ci, scripts/ci, scripts/data): tally the split baseline, do not rerun it
- 'Split lanes' keeps its first ctest's exit code beside split-baseline.xml; the controls step tallies that JUnit (DirectGLES.Split. entries) with the same refusals instead of re-running 802 entries, on every branch
- the rerun stays as the fallback when the lane step never ran, the case where the baseline was the only run of those entries
- smoke cases: reuse, fallback, a red exit code under a green JUnit, and a JUnit with no DirectGLES.Split. entry
- skip census: retire lane junit-integration-split/isplit (nothing borrows it; split-baseline covers the names)
2026-10-17 15:59:53 -04:00
swung0x48 1e9f8bb0ff [Refactor] (docs/ci, MG_Impl/Pipe, MG_Remote/Client, MG_Test/Wire, scripts/data): drop the E2 drop knobs
- MOBILEGL_IPC_E2_DROP_DRAW and MOBILEGL_IPC_E2_DROP_CLEAR leave the shipped emitter with their per-draw and per-clear branches, setters, counters and per-frame evidence line; nothing sets them since the dropped-draws control left CI
- the wire-volume assertion on the OpenRA split legs replaces the control (758 DrawVbo records and 758/788 server draws locally, both backends, inproc and spawn)
- retire the unit case RemoteEmitTable.TheE2DropSwitchStartsDisarmed, which only toggled the clear setter; skip census: removed from junit-integration-split-strict/unit-strict (test/unit and unit-split borrow that list)
2026-10-17 15:59:52 -04:00
swung0x48 75edebdab7 [CI] (.github/workflows, docs/ci, scripts/ci, tools/trace_replay): drop the retrace-split reruns
- retrace-split loses its two negative controls, which re-ran every leg (~68 runner-min per run), and its dependency on the monolith-control artifact; the in-test evidence check from the previous commit replaces them
- build-linux-monolith-control keeps its zero-MG_Remote proof and its upload; on the disaggregation branches the new retrace-shipped job replays OpenRA on both backends through that library (elsewhere the retrace job's library has that shape), so the shipped shape is still replayed on every branch
- delete retrace_transport_control.sh, retrace_drop_draw_control.sh, their smoke cases and stub modes, and the uncalled redcheck_control_smoke_test.sh
- control-runtime/build-push-control leaves the eleven metadata loops; remove-artifact-clutter waits for retrace-shipped
2026-10-17 15:59:51 -04:00
swung0x48 bc0111c99e [Test] (.github/workflows, scripts/ci, tools/trace_replay): assert each split leg's transport in-test
- run_trace_case.cmake writes output/split-evidence.txt only after its transport assertions pass
- MGTRACE_WIRE_EVIDENCE (a harness name) turns on the library's counters and requires DrawVbo records on the wire and draws on the server role
- retrace-split's OpenRA legs ask for wire evidence; every split leg, and every inproc retrace row, checks its evidence file with retrace_split_evidence.py
- test_run_trace_case.py runs the real script on canned logs: each assertion goes red on its bad log, and pipe-gates runs it
2026-10-17 14:08:15 -04:00
swung0x48 ca5411cdf6 [CI] (.github/actions, .github/workflows): compress cores so death tests cannot fill the disk
- the first ubuntu-26.04 unit run failed 41 log-reading cases: 239 raw cores from intentional aborts filled /tmp, and the library's log writes then failed silently
- setup-test-runner pipes cores through a zstd handler that keeps honouring each process's RLIMIT_CORE; steps keep their ulimit -c and lose their own core_pattern lines
- the unit job prints free disk space and the core count after its run
2026-10-17 12:37:17 -04:00
swung0x48 fb73bca3e9 [Test] (MG_Test/Program, MG_Util/ShaderTranspiler, MobileGL): probe the prewarm by its log line
- test hooks stay out of production code: ShaderCompiler::BuiltinsPrewarmed() existed only for ShaderPrewarmTest and is gone
- Initialize() logs at INFO whether it prewarmed the built-in tables or skipped them for MOBILEGL_DISABLE_SHADER_PREWARM; the test reads that line from a private log file
- still red with the knob's gate removed and with Destroy's latch reset removed; passes on the split and monolith builds
2026-10-17 11:44:23 -04:00
swung0x48 32772ca4da [CI] (.github/workflows): run every job on ubuntu-26.04
- ubuntu-latest moves to 26.04 job by job from 2026-10-19 to 11-19; a build on one image and its tests on the other would fail on glibc, so all 32 jobs move together now, pinned
- the ccache keys say ubuntu26: the 24.04 compiler's objects would not hit anyway
2026-10-17 11:22:39 -04:00
43 changed files with 5987 additions and 2971 deletions
@@ -44,4 +44,14 @@ runs:
printf 'Acquire::Retries "3";\nAcquire::http::Timeout "30";\nAcquire::https::Timeout "30";\n' |
sudo tee /etc/apt/apt.conf.d/80-mobilegl-ci-network > /dev/null
fi
# Cores go through zstd: a unit run's intentional aborts (death tests) left hundreds
# of raw cores in /tmp and filled the disk. The handler keeps honouring each
# process's RLIMIT_CORE (%c), so only steps that raise it with ulimit -c get cores;
# read one with 'zstd -d core.<exe>.<pid>.zst' first.
if [ ! -f /.dockerenv ] && command -v sudo > /dev/null && command -v zstd > /dev/null; then
printf '#!/bin/sh\n[ "$3" = 0 ] && exec cat > /dev/null\nexec %s -q -1 -o "/tmp/core.$1.$2.zst"\n' \
"$(command -v zstd)" | sudo tee /usr/local/bin/mobilegl-core-handler > /dev/null
sudo chmod 755 /usr/local/bin/mobilegl-core-handler
sudo sysctl -q -w kernel.core_pattern='|/usr/local/bin/mobilegl-core-handler %e %p %c'
fi
fi
+7 -7
View File
@@ -31,7 +31,7 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
permissions:
actions: write
contents: read
@@ -249,7 +249,7 @@ jobs:
trace-cases:
name: trace case matrix
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
env:
MOBILEGL_CI_DISAGGREGATED: ${{ (github.ref_name == 'feat/disaggregated' || github.ref_name == 'codex/fix-github-ci' || github.ref_name == 'cleanup') && 'ON' || 'OFF' }}
outputs:
@@ -272,7 +272,7 @@ jobs:
trace-fixtures:
name: trace fixture (${{ matrix.case }})
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
needs: trace-cases
strategy:
fail-fast: false
@@ -337,7 +337,7 @@ jobs:
android-avd:
name: android avd image
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
env:
AVD_NAME: mobilegl-ci
ANDROID_AVD_HOME: ${{ github.workspace }}/.android/avd
@@ -393,7 +393,7 @@ jobs:
retrace:
name: retrace (${{ matrix.backend.name }} / ${{ matrix.transport || 'monolith' }}, ${{ matrix.case.name }})
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
needs:
- build
- android-avd
@@ -668,7 +668,7 @@ jobs:
retrace-summary:
name: retrace summary
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
needs: retrace
if: ${{ !cancelled() }}
env:
@@ -759,7 +759,7 @@ jobs:
remove-artifact-clutter:
name: remove artifact clutter
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
needs: retrace-summary
if: always()
permissions:
+1 -1
View File
@@ -15,7 +15,7 @@ permissions:
jobs:
text-hygiene:
name: Commit messages and source text
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 15
env:
TEXT_HYGIENE_BASE_BRANCH: feat/disaggregated
+983 -593
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -206,8 +206,12 @@ namespace MobileGL {
// flag here rather than inside PrewarmBuiltins so ShaderCompiler keeps no dependency
// on the async subsystem (ProgramUtilTest compiles that file without it).
// MOBILEGL_DISABLE_SHADER_PREWARM skips it for processes too short-lived to repay it.
if (MG_Util::Async::AsyncShaderCompileEnabled() && !MG_Config::Features.DisableShaderPrewarm) {
MG_Util::ShaderTranspiler::ShaderCompiler::PrewarmBuiltins();
if (MG_Util::Async::AsyncShaderCompileEnabled()) {
if (MG_Config::Features.DisableShaderPrewarm) {
MGLOG_I("glslang built-in table prewarm skipped: MOBILEGL_DISABLE_SHADER_PREWARM is set");
} else {
MG_Util::ShaderTranspiler::ShaderCompiler::PrewarmBuiltins();
}
}
MGLOG_D("glslang initialized");
g_isInitialized = true;
+2 -102
View File
@@ -45,7 +45,6 @@
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <limits>
@@ -69,52 +68,6 @@ namespace MobileGL::MG_Record {
namespace {
Bool g_dropClearEmission = false;
Uint64 g_droppedClearEmissions = 0;
Bool g_dropDrawEmission = false;
Uint64 g_droppedDrawEmissions = 0;
// E2's control has to be armable from OUTSIDE the process that runs the replay, because
// the statement it makes is about a trace lane and not about a unit case: "drop an
// emission and OpenRA's SSIM falls below 0.99". A recompile would make the control
// arm against source text, which is ID-22(a)'s defect.
//
// READ WITH getenv RATHER THAN THROUGH MG_Config, DELIBERATELY AND TEMPORARILY. Config.h
// is c0's and a new MOBILEGL_IPC_* knob goes through the integrator; these are
// NEGATIVE-CONTROL switches no operator may ever set, and they announce themselves at
// warning level every time they arm so they cannot be on by accident. Flagged for
// adoption into IpcTable if the integrator wants them there.
Bool ReadControlKnob(const char* name) {
const char* value = std::getenv(name);
return value != nullptr && value[0] == '1' && value[1] == '\0';
}
// Two negative controls. MOBILEGL_IPC_E2_DROP_DRAW drops every DrawVbo record, so a
// frame that really crosses the wire can show only the clear colour; it is the OpenRA
// retrace's control. MOBILEGL_IPC_E2_DROP_CLEAR cannot be: OpenRA's last frame clears,
// then covers the whole 640x480 surface four times before the snapshot, so dropping the
// clear leaves SSIM at 1.0 (measured). It stays for scenarios whose picture depends on
// the clear, such as ClearThenReadPixelsScenario.
void ArmControlKnobs() {
g_dropClearEmission = ReadControlKnob("MOBILEGL_IPC_E2_DROP_CLEAR");
g_dropDrawEmission = ReadControlKnob("MOBILEGL_IPC_E2_DROP_DRAW");
if (g_dropClearEmission) {
MGLOG_W("MG_Remote client: MOBILEGL_IPC_E2_DROP_CLEAR=1 - a NEGATIVE CONTROL is "
"armed and every glClear will be DROPPED on the wire. It is observable "
"only where the picture depends on the clear: OpenRA overdraws its whole "
"surface every frame, so this knob does NOT redden the E2 retrace "
"(measured, joint-v1.md 3) - MOBILEGL_IPC_E2_DROP_DRAW is the one that "
"does. The dropped count is published on the 'E2 control armed' line");
}
if (g_dropDrawEmission) {
MGLOG_W("MG_Remote client: MOBILEGL_IPC_E2_DROP_DRAW=1 - E2's NEGATIVE CONTROL is "
"armed and every DrawVbo record will be DROPPED on the wire. The surface "
"can then only carry the clear colour, so this arm is expected to fail its "
"SSIM threshold; a lane that stays green with it set is not going through "
"the wire at all");
}
}
// ID-49's two halves, in one place so the emitter and its control read the same
// arithmetic.
//
@@ -376,16 +329,6 @@ namespace MobileGL::MG_Record {
VerbChannel session = ChannelFor("Clear");
BeforeReadOnlyVerb();
if (g_dropClearEmission) {
// A negative control. Everything above still ran, so the only difference
// between this arm and the live one is the record - which is exactly the
// statement "the picture comes from the wire" that E2 exists to prove. Its
// OBSERVABILITY is a property of the workload, not of this branch: see
// ArmControlKnobs for the measurement that took E2's retrace off this knob.
++g_droppedClearEmissions;
return;
}
MG_Pipe::MGPClear record{};
// The DRAW framebuffer is whatever the server's own SyncRenderState resolves from
// gPipeInputs, which the client's MGP_FILL(Clear) at GL_Drawing.cpp:534 has just
@@ -592,8 +535,8 @@ namespace MobileGL::MG_Record {
// All nineteen slots emit one draw_vbo record, the GL call verbatim beside the handles,
// which the server's sink turns into the backend call the monolith makes. Each entry
// point reads the bindings, plans the head and ranges, then calls EmitDrawRecord: the
// pre-verb hooks in EmitDrawArrays' order (push, mark walk, record), the drop-draw
// control, client arrays and indices as owned buffers. Refused by name: +CLIENT_COMMANDS
// pre-verb hooks in EmitDrawArrays' order (push, mark walk, record), then client arrays
// and indices as owned buffers. Refused by name: +CLIENT_COMMANDS
// (no indirect buffer), +UNBOUND_PARAMETER (no parameter buffer; the sink dereferences
// it) and +INDEX_OFFSET (a byte offset that is not whole indices; Start counts indices).
@@ -676,14 +619,6 @@ namespace MobileGL::MG_Record {
}
MarkGpuWritesForDraw();
if (g_dropDrawEmission) {
// E2's negative control covers EVERY draw record, not only DrawArrays: the
// Minecraft traces are DrawElements frames, and a control that dropped only the
// one P5 entry point would leave those lanes green with the wire disarmed.
++g_droppedDrawEmissions;
return;
}
info.NumDraws = numDraws;
MG_Pipe::MGPipeVerbTail tails[2] = {{ranges, static_cast<Uint64>(numDraws) * sizeof(MG_Pipe::MGPDrawRange)},
{nullptr, 0}};
@@ -1582,27 +1517,6 @@ namespace MobileGL::MG_Record {
} // namespace
// THE CONTROL'S OWN EVIDENCE LINE, and it is emitted per frame rather than at teardown
// on purpose: a retrace that is killed by its own timeout, or whose library never runs
// MobileGL::Destroy, would leave a teardown-only line absent and the control would then
// have to accept a bare threshold failure - which is the thing R-16 forbids. One line
// per Present, only while a knob is armed, is bounded by the frame count and present
// whatever happens afterwards.
void LogE2ControlLine(Uint64 frameOrdinal) {
if (!g_dropClearEmission && !g_dropDrawEmission) return;
MGLOG_W("MGPipe: E2 control armed - drop-draw=%d drop-clear=%d, %llu records dropped "
"on the wire (draw=%llu clear=%llu), frame %llu",
g_dropDrawEmission ? 1 : 0, g_dropClearEmission ? 1 : 0,
static_cast<unsigned long long>(g_droppedDrawEmissions + g_droppedClearEmissions),
static_cast<unsigned long long>(g_droppedDrawEmissions),
static_cast<unsigned long long>(g_droppedClearEmissions),
static_cast<unsigned long long>(frameOrdinal));
}
void ArmVerbControlKnobs() {
ArmControlKnobs();
}
#if MOBILEGL_PIPE_VERIFY
Bool MonolithPortApplyingForVerify() {
return t_portApplying;
@@ -1742,20 +1656,6 @@ namespace MobileGL::MG_Record {
return previous;
}
void SetDropClearEmissionForNegativeControl(Bool drop) {
g_dropClearEmission = drop;
}
Uint64 DroppedClearEmissions() {
return g_droppedClearEmissions;
}
void SetDropDrawEmissionForNegativeControl(Bool drop) {
g_dropDrawEmission = drop;
}
Uint64 DroppedDrawEmissions() {
return g_droppedDrawEmissions;
}
Bool ReadbackPackStateIsTightForTest(GLsizei width, Uint64 bytesPerPixel, const PixelStoreParameters& pack) {
return ReadbackPackStateIsTight(width, bytesPerPixel, pack);
}
+1 -17
View File
@@ -53,11 +53,6 @@ namespace MobileGL::MG_Record {
// persistent-map push and the deferred destroys, before its record.
void BeforeReadOnlyVerb();
// Arms the E2 negative controls from their environment knobs (once, at table build).
void ArmVerbControlKnobs();
// The E2 control's per-frame evidence line, emitted only while a control is armed.
void LogE2ControlLine(Uint64 frameOrdinal);
#if MOBILEGL_PIPE_VERIFY
// True while the monolith verb port applies a record on this thread. The verify read hook
// answers a pack-state read inside that window from the applied pack payload, as it does for a
@@ -81,17 +76,6 @@ namespace MobileGL::MG_Record {
// Returns the previous routing.
Bool SetMonolithVerbPortRoutingForTesting(Bool viaPort);
// Negative controls for the retrace lanes. When set, the named emitter skips its record but
// still runs the pre-verb hooks, so a replay that really goes through the wire loses the
// verb while one that fell through to the driver does not. Functions rather than Config.h
// knobs: a running test process sets them, and no operator may. The counts let a control
// assert that it fired. The OpenRA retrace uses the draw drop: OpenRA overdraws every pixel
// it clears, so dropping the clear leaves SSIM at 1.0 (see ArmControlKnobs in VerbPort.cpp).
void SetDropClearEmissionForNegativeControl(Bool drop);
Uint64 DroppedClearEmissions();
void SetDropDrawEmissionForNegativeControl(Bool drop);
Uint64 DroppedDrawEmissions();
// ID-47. The CLIENT refuses a readback whose answer would not fit a reply slot, BEFORE it
// emits the record, and names the read. THE REFUSAL ITSELF IS s1's -
// ClientSession::RequireReadPixelsReplyFits, forwarding to
@@ -177,7 +161,7 @@ namespace MobileGL::MG_Record {
// over a snapshot of the bindings the emitter read from the GL context, so a unit case can
// drive the PRODUCTION derivation with synthetic bindings (R-16) while the integration lane
// proves the bindings are read from the right slots. The emitters compose these and add
// nothing but the hooks, the E2 draw-drop control and the staging of a client index array.
// nothing but the hooks and the staging of a client index array.
// What the emitter reads from the GL context before it plans a draw.
struct RemoteDrawBindings {
+14 -8
View File
@@ -3632,9 +3632,9 @@ if (MOBILEGL_BUILD_DISAGGREGATED AND MOBILEGL_PIPE_VERIFY)
# THE TWO NEGATIVE CONTROLS ON THIS ARM (CONTRACT-P7 §6), the monolith verify block's
# four entries with the split environment underneath and nothing else changed: each is
# green when its control REPORTS, so each goes red the day the comparator or the poison
# stops reaching the split arm. Their CI halves - the same knob exported against the
# ambient VerifySplit. entries, where ctest has to FAIL - live in test.yml's
# integration-verify-split job.
# stops reaching the split arm. test.yml's integration-verify-split job requires each of
# them to pass by name, and control A's CI half - the same knob exported against the
# ambient VerifySplit. entries, where ctest has to FAIL - runs there too.
#
# A (G4): the corruption perturbs the snapshot arm at the CLIENT's entry compare, so the
# report lands in the client role's log, which is the half LibraryLog() reads.
@@ -3667,11 +3667,10 @@ if (MOBILEGL_BUILD_DISAGGREGATED AND MOBILEGL_PIPE_VERIFY)
# ServerPoisonOmission). PoisonOmissionScenario.cpp's kOmittedPairs carries both.
mgl_itest_lane("${mglItestVerifyBackend}.VerifySplitPoisonOmitted." "PoisonOmissionScenario.OmittedFieldAbortsOnThatVerb"
LABELS "integration-verify-split" TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} LOCK "pipe-poison-omit-split-${mglItestVerifyBackend}.log" ENV "${mglItestVerifyArmEnv}\;MOBILEGL_PIPE_POISON_OMIT=ReadPixels:GetPixelStoreParameters\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-poison-omit-split-${mglItestVerifyBackend}.log")
# B's AMBIENT TARGET. The monolith verify lane's control-B step aims its knob at
# `Verify.PoisonOmissionScenario.WithoutOmissionCompletes`, the one integration entry that
# calls glGenerateMipmap at all - and the mechanism filter took that entry out of the
# ambient set above along with the rest of the scenario. Registered back by hand, with no
# knob, so the CI step has an entry on THIS arm to turn red.
# B's AMBIENT HALF. `Verify.PoisonOmissionScenario.WithoutOmissionCompletes` runs the
# scenario with nothing withheld, and the mechanism filter took it out of the ambient set
# above along with the rest of the scenario. Registered back by hand, with no knob, so
# this arm's ambient lane still runs it.
mgl_itest_lane("${mglItestVerifyBackend}.VerifySplit." "PoisonOmissionScenario.WithoutOmissionCompletes"
LABELS "integration-verify-split" TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} TEST_LIST "MGL_VERIFY_SPLIT_TESTS_POISON_${mglItestVerifyBackend}" ENV "${mglItestVerifyArmEnv}")
list(APPEND MGL_VERIFY_SPLIT_TEST_LISTS "MGL_VERIFY_SPLIT_TESTS_POISON_${mglItestVerifyBackend}")
@@ -3698,4 +3697,11 @@ 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")
# The split fixture's transport-claim rule on canned inputs, so the rule itself can go red
# off a GPU: no GL, no library, just the pure function the fixture calls.
add_executable(SplitLaneHarnessTest Harness/SplitLaneHarnessTest.cpp)
target_link_libraries(SplitLaneHarnessTest PRIVATE GTest::gtest_main)
add_test(NAME SplitLaneHarness.TransportClaimRule COMMAND SplitLaneHarnessTest)
set_tests_properties(SplitLaneHarness.TransportClaimRule PROPERTIES LABELS "unit")
endif()
@@ -158,11 +158,21 @@ namespace MGITest {
// entries had no runtime evidence of anything, and their green meant only "the
// same GL workload passed").
const SplitRuntimeState state = PeekSplitRuntime();
// The transport the registration claims must be the one this process resolved,
// served where it says: a thread here for inproc, a process of its own for spawn
// and tcp. A lane that ran some other transport fails here, by name.
const std::string claimMismatch = SplitLane::TransportClaimMismatch(
SplitLane::MarkerValue("MOBILEGL_TRANSPORT"), SplitLane::MarkerValue("MOBILEGL_IPC_CONTROL"),
state.transportName, state.peerServerPid, state.selfPid);
if (!claimMismatch.empty()) {
FAIL() << claimMismatch;
}
m_splitAssertionsArmed = true;
m_emitSeqAtSetUp = state.emitSeq;
RecordProperty("split_transport", state.transportName);
RecordProperty("split_implemented_verbs", static_cast<int>(state.implementedVerbs));
RecordProperty("split_emit_seq_at_setup", static_cast<int>(state.emitSeq));
RecordProperty("split_peer_server_pid", std::to_string(state.peerServerPid));
#if defined(__linux__)
if (std::getenv("MGITEST_TCP_LANE")) {
// Connect must never silently launch a local server. The
@@ -84,6 +84,36 @@ namespace MGITest::SplitLane {
return SplitRuntimeSkipReason();
}
// The transport a registration claims, against what this process resolved and which process
// serves it; empty when they agree. claimedTransport and claimedControl are the entry's own
// MOBILEGL_TRANSPORT and MOBILEGL_IPC_CONTROL; the rest comes from PeekSplitRuntime(). In
// process, the server role is a thread here and states this process's pid; spawn and tcp
// need a server process of their own. A registration that claims no transport is not judged,
// and an unknown own pid (0) leaves only the comparison with it unjudged.
inline std::string TransportClaimMismatch(const std::string& claimedTransport, const std::string& claimedControl,
const std::string& resolvedName, unsigned long long peerPid,
unsigned long long selfPid) {
const bool tcp = claimedControl.rfind("tcp://", 0) == 0;
const bool selfKnown = selfPid != 0;
const std::string pids =
" (server pid " + std::to_string(peerPid) + ", this process " + std::to_string(selfPid) + ")";
if (claimedTransport == "inproc" && !tcp) {
if (resolvedName != "inproc" || peerPid == 0 || (selfKnown && peerPid != selfPid)) {
return "this registration claims MOBILEGL_TRANSPORT=inproc, but the process resolved '" + resolvedName +
"' with its server role in another process or none" + pids;
}
return {};
}
if (claimedTransport == "spawn" || tcp) {
const std::string claim = tcp ? "tcp (MOBILEGL_IPC_CONTROL=" + claimedControl + ")" : "spawn";
if (resolvedName != "spawn" || peerPid == 0 || (selfKnown && peerPid == selfPid)) {
return "this registration claims " + claim + ", but the process resolved '" + resolvedName +
"' without a server process of its own" + pids;
}
}
return {};
}
// "adopted", "emulated", or empty when the lane declared nothing.
inline std::string DeclaredPersistentMapArm() {
return MarkerValue("MGITEST_PERSISTENT_MAP_ARM");
@@ -0,0 +1,64 @@
// MobileGL - MobileGL/MG_IntegrationTest/Harness/SplitLaneHarnessTest.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// SplitLane::TransportClaimMismatch on canned inputs: each claim must be refused when the process
// resolved another transport or the wrong process serves it, and accepted when both agree.
#include "SplitLane.h"
#include <gtest/gtest.h>
namespace {
using MGITest::SplitLane::TransportClaimMismatch;
constexpr unsigned long long kSelf = 4242;
constexpr unsigned long long kServer = 4343;
TEST(SplitLaneHarness, AnInprocClaimNeedsTheServerThreadHere) {
EXPECT_EQ(TransportClaimMismatch("inproc", "", "inproc", kSelf, kSelf), "");
EXPECT_NE(TransportClaimMismatch("inproc", "", "spawn", kServer, kSelf), "");
EXPECT_NE(TransportClaimMismatch("inproc", "", "inproc", kServer, kSelf), "");
EXPECT_NE(TransportClaimMismatch("inproc", "", "inproc", 0, kSelf), "");
EXPECT_NE(TransportClaimMismatch("inproc", "", "monolith", 0, kSelf), "");
}
TEST(SplitLaneHarness, ASpawnClaimNeedsAServerProcessOfItsOwn) {
EXPECT_EQ(TransportClaimMismatch("spawn", "", "spawn", kServer, kSelf), "");
EXPECT_NE(TransportClaimMismatch("spawn", "", "inproc", kSelf, kSelf), "");
EXPECT_NE(TransportClaimMismatch("spawn", "", "spawn", kSelf, kSelf), "");
EXPECT_NE(TransportClaimMismatch("spawn", "", "spawn", 0, kSelf), "");
}
TEST(SplitLaneHarness, ATcpClaimIsASpawnSessionToAnotherProcess) {
EXPECT_EQ(TransportClaimMismatch("spawn", "tcp://127.0.0.1:30613", "spawn", kServer, kSelf), "");
EXPECT_NE(TransportClaimMismatch("spawn", "tcp://127.0.0.1:30613", "spawn", kSelf, kSelf), "");
EXPECT_NE(TransportClaimMismatch("inproc", "tcp://127.0.0.1:30613", "inproc", kSelf, kSelf), "");
}
TEST(SplitLaneHarness, NoClaimIsNotJudged) {
EXPECT_EQ(TransportClaimMismatch("", "", "monolith", 0, kSelf), "");
EXPECT_EQ(TransportClaimMismatch("monolith", "", "monolith", 0, kSelf), "");
}
TEST(SplitLaneHarness, AnUnknownOwnPidStillJudgesTheTransportAndTheServer) {
EXPECT_EQ(TransportClaimMismatch("inproc", "", "inproc", kSelf, 0), "");
EXPECT_NE(TransportClaimMismatch("inproc", "", "spawn", kServer, 0), "");
EXPECT_NE(TransportClaimMismatch("inproc", "", "inproc", 0, 0), "");
EXPECT_EQ(TransportClaimMismatch("spawn", "", "spawn", kServer, 0), "");
EXPECT_NE(TransportClaimMismatch("spawn", "", "inproc", kSelf, 0), "");
EXPECT_NE(TransportClaimMismatch("spawn", "", "spawn", 0, 0), "");
}
TEST(SplitLaneHarness, TheRefusalNamesBothPids) {
const std::string message = TransportClaimMismatch("spawn", "", "spawn", kSelf, kSelf);
EXPECT_NE(message.find("server pid 4242"), std::string::npos) << message;
EXPECT_NE(message.find("this process 4242"), std::string::npos) << message;
}
} // namespace
@@ -25,6 +25,11 @@
#include <chrono>
#include <atomic>
#include <thread>
#if defined(_WIN32)
#include <process.h>
#else
#include <unistd.h>
#endif
#define MGITEST_SPLIT_RUNTIME_PEEK_LIVE 1
#endif
// P13 W5: THE RECORD-ARM HALF OF THE PEEK, which needs no transport. A library built with the
@@ -141,6 +146,9 @@ namespace MGITest {
case TransportMode::InProcess:
state.transportName = "inproc";
break;
case TransportMode::Spawn:
state.transportName = "spawn";
break;
default:
state.transportName = "non-monolith";
break;
@@ -154,11 +162,17 @@ namespace MGITest {
MobileGL::MG_Remote::Client::ClientSession* session = MobileGL::MG_Remote::Client::ClientSession::Active();
state.sessionActive = session != nullptr;
state.implementedVerbs = MobileGL::MG_Remote::Client::ImplementedVerbCount();
#if defined(_WIN32)
state.selfPid = static_cast<unsigned long long>(::_getpid());
#else
state.selfPid = static_cast<unsigned long long>(::getpid());
#endif
if (session != nullptr) {
// Encoder() returns the member; EmitSeq() returns m_emitSeq. Neither is a stub, and
// neither emits anything - this is a read.
const MobileGL::MG_Remote::Wire::PipeWireEncoder& encoder = session->Encoder();
state.emitSeq = encoder.EmitSeq();
state.peerServerPid = session->PeerServerPid();
state.runAheadArmed = session->RunAheadArmed();
// P7 wave 2 package C, OQ-10: read through the SAME accessor the frontend's own
// MGPipeResourceOpsHaveSubDataResident reads (MG_Impl/Pipe/PipeFill.cpp), so a
@@ -74,6 +74,11 @@ namespace MGITest {
unsigned int totalVerbSlots = 0;
// The encoder's highest produced record ordinal, or 0 when there is no session.
unsigned long long emitSeq = 0;
// ClientSession::PeerServerPid(), the pid the server stated in its Welcome (0 without a
// session), and this process's own (0 when unknown), so a lane can tell a server thread
// here from a server process elsewhere.
unsigned long long peerServerPid = 0;
unsigned long long selfPid = 0;
// Real session state/watermarks. Only the client thread reads this snapshot;
// the scheduling observer below reads producerParked separately.
bool runAheadArmed = false;
@@ -306,6 +306,72 @@ def require_green(document, junit_path, required):
print(f"SplitLogPaths require-green: {len(by_name)} PASS, 0 skip, 0 failed, complete discovery")
# The client's one-line IPC configuration (ConfigLoader, every non-monolith transport, INFO) and
# the knobs an entry may pin in its own ENVIRONMENT instead of inheriting the lane's.
IPC_LINE = "Config: IPC "
IPC_PINS = {"strict": "MOBILEGL_IPC_STRICT_ERRORS", "role-split-state": "MOBILEGL_IPC_ROLE_SPLIT_STATE",
"run-ahead": "MOBILEGL_IPC_RUN_AHEAD"}
def entry_environment(test):
env = {}
for prop in test.get("properties", []):
if prop.get("name") == "ENVIRONMENT":
for item in prop.get("value") or []:
key, _, value = item.partition("=")
env[key] = value
return env
def ipc_config(document, junit_path, expected, assumed=()):
"""Prove, from each passed entry's own client log, the IPC knobs the run resolved.
Every entry that passed and has a private log must show `key=value` on its last "Config: IPC"
line, where value is the entry's own ENVIRONMENT pin when it has one and the expected value
otherwise; monolith-pinned entries have no such line and are left out. Returns (code, summary):
0 proven; 3 when the only mismatches are on `assumed` keys of entries that do not pin them (a
default that changed); 1 for anything else, including no evidence at all."""
passed = set()
for case in ET.parse(junit_path).getroot().iter("testcase"):
failed = case.find("failure") is not None or case.find("error") is not None
skipped = case.find("skipped") is not None or case.get("status") in ("notrun", "disabled")
if not failed and not skipped:
passed.add(case.get("name"))
problems, defaults, checked = [], [], []
for test in document.get("tests", []):
name = test["name"]
env = entry_environment(test)
transport = env.get("MOBILEGL_TRANSPORT", "")
base = env.get("MOBILEGL_LOG_FILE_PATH", "")
if name not in passed or transport == "monolith" or not base:
continue
client = Path(role_paths(base)[0])
lines = [line for line in (client.read_text(errors="replace") if client.is_file() else "").splitlines()
if IPC_LINE in line]
if not lines:
if transport == "inproc":
problems.append(f"{name}: passed under inproc but {client} carries no '{IPC_LINE.strip()}' line")
continue
checked.append(name)
for key, value in expected.items():
want = env.get(IPC_PINS.get(key, ""), value)
found = re.search(r"(?:^| )" + re.escape(key) + r"=(\S*)", lines[-1])
got = found.group(1) if found else "<absent>"
if got == want:
continue
row = f"{name}: {key}={got}, expected {want}"
if key in assumed and IPC_PINS.get(key) not in env:
defaults.append(row)
else:
problems.append(row)
if not checked:
problems.append("no passed entry left a 'Config: IPC' line, so nothing proves the configuration")
summary = {"checked": len(checked), "expected": expected, "assumed": sorted(assumed),
"problems": problems, "default_mismatches": defaults}
code = 1 if problems else 3 if defaults else 0
return code, summary
VERIFY_ARMED_LINE = "MGPipe: verify armed"
@@ -616,6 +682,23 @@ def main():
if mode == "require-green":
require_green(json.loads(Path(sys.argv[2]).read_text()), sys.argv[3], sys.argv[4:])
return
if mode == "ipc-config":
# <lane.json> <junit> <summary.json> key=value... [--assumed k1,k2]
args = sys.argv[5:]
assumed = set()
if "--assumed" in args:
at = args.index("--assumed")
assumed = {key for key in args[at + 1].split(",") if key}
args = args[:at] + args[at + 2:]
expected = dict(arg.split("=", 1) for arg in args)
code, summary = ipc_config(json.loads(Path(sys.argv[2]).read_text()), sys.argv[3], expected, assumed)
Path(sys.argv[4]).write_text(json.dumps(summary, indent=1) + "\n")
for row in summary["problems"] + summary["default_mismatches"]:
print(f"SplitLogPaths ipc-config: {row}")
print(f"SplitLogPaths ipc-config: {summary['checked']} entr(ies) checked against "
f"{' '.join(f'{k}={v}' for k, v in expected.items())}: "
f"{['proven', '', '', 'a default differs'][code] if code != 1 else 'FAILED'}")
sys.exit(code)
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
@@ -262,5 +262,79 @@ class VerifySplitArmProofTest(unittest.TestCase):
self.proof()
class IpcConfigProofTest(unittest.TestCase):
"""ipc-config reads each passed entry's own client log for the knobs the run resolved."""
LINE = "Config: IPC ring=8MiB run-ahead={ra} present-credit=2 strict={st} audit=0 role-split-state={rs} surface=x"
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.xml = self.root / "run.xml"
def tearDown(self):
self.temp.cleanup()
def lane(self, rows):
"""rows: (name, status, pins, log line or None)."""
tests, suite = [], ET.Element("testsuite")
for i, (name, status, pins, line) in enumerate(rows):
base = self.root / f"{i}.log"
if line is not None:
(self.root / f"{i}.client.log").write_text("Config: MOBILEGL_TRANSPORT=inproc\n" + line + "\n")
env = ["MOBILEGL_LOG_FILE_PATH=" + str(base)] + pins
tests.append({"name": name, "properties": [{"name": "ENVIRONMENT", "value": env}]})
case = ET.SubElement(suite, "testcase", name=name, status="run")
if status == "skip":
ET.SubElement(case, "skipped")
if status == "fail":
ET.SubElement(case, "failure")
ET.ElementTree(suite).write(self.xml)
return {"tests": tests}
def check(self, rows, expected=None, assumed=("role-split-state", "run-ahead")):
expected = expected or {"strict": "0", "role-split-state": "1", "run-ahead": "1"}
with contextlib.redirect_stdout(io.StringIO()):
return helper.ipc_config(self.lane(rows), self.xml, expected, set(assumed))[0]
def inproc(self, ra="1", st="0", rs="1"):
return ["MOBILEGL_TRANSPORT=inproc"], self.LINE.format(ra=ra, st=st, rs=rs)
def test_the_defaults_are_proven(self):
pins, line = self.inproc()
self.assertEqual(self.check([("A", "pass", pins, line), ("B", "skip", pins, None)]), 0)
def test_a_changed_default_is_exit_three(self):
pins, line = self.inproc(rs="0")
self.assertEqual(self.check([("A", "pass", pins, line)]), 3)
def test_an_unassumed_key_that_differs_is_red(self):
pins, line = self.inproc(st="1")
self.assertEqual(self.check([("A", "pass", pins, line)]), 1)
def test_a_passed_inproc_entry_without_the_line_is_red(self):
pins, line = self.inproc()
self.assertEqual(self.check([("A", "pass", pins, line), ("B", "pass", pins, None)]), 1)
def test_no_evidence_is_red(self):
pins, _ = self.inproc()
self.assertEqual(self.check([("A", "skip", pins, None)]), 1)
def test_an_entry_pin_overrides_the_expectation(self):
pins, line = self.inproc(ra="0")
self.assertEqual(self.check([("A", "pass", pins + ["MOBILEGL_IPC_RUN_AHEAD=0"], line)]), 0)
self.assertEqual(self.check([("A", "pass", pins + ["MOBILEGL_IPC_RUN_AHEAD=1"], line)]), 1,
"a pinned value the run did not honour is red, not a changed default")
def test_the_value_is_matched_whole(self):
pins, line = self.inproc(ra="10")
self.assertEqual(self.check([("A", "pass", pins, line)]), 3)
def test_monolith_pinned_entries_are_left_out(self):
pins, line = self.inproc()
rows = [("A", "pass", pins, line), ("M", "pass", ["MOBILEGL_TRANSPORT=monolith"], None)]
self.assertEqual(self.check(rows), 0)
if __name__ == "__main__":
unittest.main()
@@ -26,7 +26,7 @@
// about WHY: a poison that fired on every verb would look just as red.
//
// The knob is process-wide, so the two cases cannot share a lane: the first runs in the PoisonOmitted.
// entries, the second in the ambient Verify. entries (it skips when the knob IS set).
// entries, the second in the ambient Verify. entries (the first skips when the knob is unset).
//
// WHY THE SEQUENCE RUNS IN A SEPARATE PROCESS, AND WHY THAT PROCESS IS fork()+execve() AND NOT fork()
// ALONE. The poison reports with MGLOG_F and then std::abort(), in the middle of a GL command - so the
@@ -81,7 +81,7 @@ extern char** environ;
namespace MGITest {
namespace {
// What the PoisonOmitted. ctest entries and the CI negative-control steps name. Each pair is
// What the PoisonOmitted. ctest entries name, which CI requires to pass by name. Each pair is
// spelled here so the assertion below is about the exact string the poison contracts to
// print (ARCHITECTURE.md 9.2: Fatal{UnmigratedPipeInput, "<Field>@<Verb>"}).
//
@@ -480,14 +480,12 @@ void main() { o_color = vec4(0.25, 0.5, 0.75, 1.0); }
}
}
// The sibling control, in the ambient Verify. lanes: the same sequence with the knob UNSET
// must run to completion and log no Fatal at all.
// The ambient half of the pair, in the ambient Verify. lanes: the same sequence with the
// knob UNSET must run to completion and log no Fatal at all. The PoisonOmitted. entries are
// the armed half (knob set, the verb must abort), and CI requires those entries by name.
//
// It deliberately does NOT skip when MOBILEGL_PIPE_POISON_OMIT is set. This is the entry
// CI's always-on negative control B exports the knob at: a green entry that the omission
// turns red is the whole proof that the poison is armed, and an entry that politely skipped
// itself would report that green either way. Nothing else in the integration suite calls
// glGenerateMipmap, so this case is also the only possible target for that control.
// It does not skip when MOBILEGL_PIPE_POISON_OMIT is set: run by hand with the knob
// exported, it goes red, and the note in its failure says that is the poison firing.
TEST_F(PoisonOmissionScenario, WithoutOmissionCompletes) {
if (!Ready()) return;
@@ -505,8 +503,7 @@ void main() { o_color = vec4(0.25, 0.5, 0.75, 1.0); }
const std::string note =
omissionArmed
? std::string(" NOTE: MOBILEGL_PIPE_POISON_OMIT is set in this process, so this failure is "
"what CI's negative control B is asking for - the poison IS armed, and this "
"entry going red is the proof.")
"expected: the poison IS armed, and this entry going red is the proof.")
: std::string();
ASSERT_TRUE(WIFEXITED(status))
<< "with no omission armed, a draw followed by glGenerateMipmap must complete; the child "
+1 -7
View File
@@ -62,9 +62,8 @@
namespace MobileGL::MG_Remote::Client {
// P13 W5: the pre-verb hook and the E2 evidence line are the verb port's (VerbPort.h).
// The pre-verb hook is the verb port's (VerbPort.h).
using MG_Record::BeforeReadOnlyVerb;
using MG_Record::LogE2ControlLine;
// The slot arithmetic, asserted rather than commented. GlobalBackendFunctionsTable is
// GLFunctionsTable plus Present plus SetSwapInterval; GLFunctionsTable is 71 function
@@ -80,7 +79,6 @@ namespace MobileGL::MG_Remote::Client {
namespace {
Uint64 g_presentOrdinal = 0;
Uint64 g_publishedMaxRecordBytes = 0;
// ---- the session, demanded rather than assumed --------------------------------
@@ -216,9 +214,6 @@ namespace MobileGL::MG_Remote::Client {
// keyed on pActiveBackendObject.get() (Core.cpp:34) and that pointer never changes
// under split.
session.PumpControlPlane();
++g_presentOrdinal;
LogE2ControlLine(g_presentOrdinal);
}
// =============================================================================
@@ -583,7 +578,6 @@ namespace MobileGL::MG_Remote::Client {
"the three classes no longer partition the 73 slots");
MG_Backend::GlobalBackendFunctionsTable BuildRemoteEmitTable() {
MG_Record::ArmVerbControlKnobs();
MG_Backend::GlobalBackendFunctionsTable table{};
// ---- class C first, so that a slot forgotten below stays Fatal rather than null.
+2 -6
View File
@@ -43,12 +43,10 @@ namespace MobileGL::MG_Remote::Client {
// A reference to a never-destroyed block, like every other MG_Remote singleton (ID-8).
const MG_Backend::GlobalBackendFunctionsTable& RemoteEmitTable();
// P13 W5: the verb emitters, the monolith verb port, the E2 controls and the readback / draw
// planners are MG_Record's (MG_Impl/Pipe/Verb/VerbPort.h) - the record arm runs them in a
// The verb emitters, the monolith verb port and the readback / draw planners are
// MG_Record's (MG_Impl/Pipe/Verb/VerbPort.h) - the record arm runs them in a
// library without a transport. Re-exported here under the names the remote table's users
// and its unit cases already spell.
using MG_Record::DroppedClearEmissions;
using MG_Record::DroppedDrawEmissions;
using MG_Record::ForEachReadbackBand;
using MG_Record::InstallMonolithVerbPort;
using MG_Record::MonolithVerbPortInstalled;
@@ -64,8 +62,6 @@ namespace MobileGL::MG_Remote::Client {
using MG_Record::RemoteIndexSizeFor;
using MG_Record::ScatterReadbackBandIntoPackState;
using MG_Record::ScatterTightReadbackIntoPackState;
using MG_Record::SetDropClearEmissionForNegativeControl;
using MG_Record::SetDropDrawEmissionForNegativeControl;
using MG_Record::TightReadbackByteCount;
using MG_Record::UnmigratedVerbFatal;
+77 -16
View File
@@ -7,14 +7,18 @@
// End of Source File Header
// MOBILEGL_DISABLE_SHADER_PREWARM. Initialize() builds glslang's built-in tables ahead of the
// first compile unless the variable is truthy; the CI test lanes set it, so these cases set or
// clear it themselves. MG_ConfigLoader::Init() rereads the environment on every Initialize(),
// so the variable, not the feature block, is the input. Its own binary: every case calls
// Destroy(), and ShaderCompilePool's stop is a one-way latch (see AsyncTeardownTest).
// first compile unless the variable is truthy, and logs which way it went; these cases read that
// line back. The CI test lanes set the variable, so every case sets or clears it itself:
// MG_ConfigLoader::Init() rereads the environment on every Initialize(). The log file is
// truncated by each Initialize() and closed by Destroy(), so each case reads it after Destroy().
// Its own binary: every case calls Destroy(), and ShaderCompilePool's stop is a one-way latch.
#include <gtest/gtest.h>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include "Config.h"
@@ -22,15 +26,22 @@
#include "Init.h"
#include "MG_Impl/GLImpl/Getter/GL_Getter.h"
#include "MG_Impl/GLImpl/Program/GL_Program.h"
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
#include "MG_Util/Debug/Log.h"
#if defined(_WIN32)
#include <process.h>
#else
#include <unistd.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Impl::GLImpl;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
namespace {
constexpr const char* kPrewarmKnob = "MOBILEGL_DISABLE_SHADER_PREWARM";
constexpr const char* kAsyncKnob = "MOBILEGL_ASYNC_SHADER_COMPILE";
constexpr const char* kPrewarmedLine = "prewarmed the glslang built-in tables";
constexpr const char* kSkippedLine = "built-in table prewarm skipped";
// Sets (or, with nullptr, clears) one environment variable for a scope.
class ScopedEnv {
@@ -62,6 +73,54 @@ namespace {
bool m_had = false;
};
int ProcessId() {
#if defined(_WIN32)
return _getpid();
#else
return static_cast<int>(getpid());
#endif
}
// Points the library's log at a private file for the case; Log() reads what the client role
// wrote, by the library's own naming rule.
class PrivateLog {
public:
PrivateLog()
: m_base((std::filesystem::temp_directory_path() /
("mobilegl-shaderprewarm-test-" + std::to_string(ProcessId()) + ".log"))
.string()),
m_env("MOBILEGL_LOG_FILE_PATH", m_base.c_str()) {}
~PrivateLog() {
std::error_code ignored;
std::filesystem::remove(Path(), ignored);
std::filesystem::remove(m_base, ignored);
}
PrivateLog(const PrivateLog&) = delete;
PrivateLog& operator=(const PrivateLog&) = delete;
std::string Path() const {
return MG_Util::Debug::RoleLogPath(m_base.c_str(), MG_Util::Debug::LogRole::Client);
}
std::string Log() const {
std::ifstream in(Path());
std::stringstream text;
text << in.rdbuf();
return text.str();
}
private:
std::string m_base;
ScopedEnv m_env;
};
int Count(const std::string& text, const char* line) {
int count = 0;
for (auto at = text.find(line); at != std::string::npos; at = text.find(line, at + 1))
++count;
return count;
}
GLint CompileStatusOf(const GLenum type, const char* source) {
const GLuint shader = CreateShader(type);
ShaderSource(shader, 1, &source, nullptr);
@@ -77,35 +136,37 @@ namespace {
} // namespace
TEST(ShaderPrewarm, InitializePrewarmsByDefaultAndAgainAfterDestroy) {
const PrivateLog log;
const ScopedEnv knob(kPrewarmKnob, nullptr);
const ScopedEnv async(kAsyncKnob, "1");
MobileGL::Initialize();
EXPECT_FALSE(MG_Config::Features.DisableShaderPrewarm);
EXPECT_TRUE(ShaderCompiler::BuiltinsPrewarmed());
MobileGL::Destroy();
// Destroy finalizes glslang, which frees the tables the latch stands for.
EXPECT_FALSE(ShaderCompiler::BuiltinsPrewarmed());
EXPECT_EQ(Count(log.Log(), kPrewarmedLine), 1) << log.Log();
// Destroy finalizes glslang, which frees the tables, so the next Initialize warms again.
MobileGL::Initialize();
EXPECT_TRUE(ShaderCompiler::BuiltinsPrewarmed());
MobileGL::Destroy();
EXPECT_EQ(Count(log.Log(), kPrewarmedLine), 1) << log.Log();
}
TEST(ShaderPrewarm, ATruthyKnobSkipsThePrewarmAndCompilesStillWork) {
const PrivateLog log;
const ScopedEnv knob(kPrewarmKnob, "1");
const ScopedEnv async(kAsyncKnob, "1");
MobileGL::Initialize();
EXPECT_TRUE(MG_Config::Features.DisableShaderPrewarm);
EXPECT_FALSE(ShaderCompiler::BuiltinsPrewarmed());
EXPECT_EQ(CompileStatusOf(GL_FRAGMENT_SHADER, kFragment460), GL_TRUE);
EXPECT_FALSE(ShaderCompiler::BuiltinsPrewarmed());
MobileGL::Destroy();
const std::string text = log.Log();
EXPECT_EQ(Count(text, kPrewarmedLine), 0) << text;
EXPECT_EQ(Count(text, kSkippedLine), 1) << text;
}
TEST(ShaderPrewarm, AFalsyKnobKeepsThePrewarm) {
const PrivateLog log;
const ScopedEnv knob(kPrewarmKnob, "0");
const ScopedEnv async(kAsyncKnob, "1");
MobileGL::Initialize();
EXPECT_FALSE(MG_Config::Features.DisableShaderPrewarm);
EXPECT_TRUE(ShaderCompiler::BuiltinsPrewarmed());
MobileGL::Destroy();
const std::string text = log.Log();
EXPECT_EQ(Count(text, kPrewarmedLine), 1) << text;
EXPECT_EQ(Count(text, kSkippedLine), 0) << text;
}
@@ -898,10 +898,6 @@ TEST(CapsMirrorTest, TheConsumerBlockDoesNotCollideWithTheFeatureBits) {
EXPECT_FALSE(CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemSamplers));
}
// =====================================================================================
// E2's emitter-drop control: the switch itself, driven through the real emitter's own counter
// =====================================================================================
// =====================================================================================
// P5b d1: the draw family's record plan (docs/archive/contracts/CONTRACT-P5B.md §2 d1). These drive the
// PRODUCTION derivation the nineteen emitters call - PlanDrawInfo / PlanDrawRange /
@@ -1073,17 +1069,6 @@ TEST(RemoteEmitTable, TheNineteenDrawSlotsAreEmittersDistinctFromEveryStub) {
}
}
TEST(RemoteEmitTable, TheE2DropSwitchStartsDisarmed) {
// The half a unit case can state. E2's statement - "drop one Clear emission and OpenRA's
// SSIM falls below 0.99" - is a TRACE LANE's, because the picture is the thing it is about;
// what belongs here is that the control is off unless someone armed it, so a lane that
// forgot to disarm cannot look like a lane that was never armed.
EXPECT_EQ(DroppedClearEmissions(), 0u);
SetDropClearEmissionForNegativeControl(true);
SetDropClearEmissionForNegativeControl(false);
EXPECT_EQ(DroppedClearEmissions(), 0u) << "arming and disarming the control must not, by itself, drop anything";
}
// =====================================================================================
// ID-47: a readback larger than a reply slot is refused at the CLIENT, by name
// =====================================================================================
@@ -456,10 +456,6 @@ namespace MobileGL {
g_builtinsPrewarmed = false;
}
Bool ShaderCompiler::BuiltinsPrewarmed() {
return g_builtinsPrewarmed;
}
void ShaderCompiler::PrewarmBuiltins() {
if (g_builtinsPrewarmed) return;
g_builtinsPrewarmed = true;
@@ -491,6 +487,7 @@ namespace MobileGL {
// The parses above left this thread's glslang allocator pointing at the last
// TShader's pool, and that TShader is about to be destroyed with it.
glslang::SetThreadPoolAllocator(nullptr);
MGLOG_I("ShaderCompiler: prewarmed the glslang built-in tables for #version 460 and 330 core");
}
namespace {
@@ -533,8 +533,6 @@ namespace MobileGL {
// it, the second eglInitialize of a process comes back up unwarmed and with
// no way left to warm it.
static void ResetPrewarmLatch();
// Whether PrewarmBuiltins has run since the last ResetPrewarmLatch.
static Bool BuiltinsPrewarmed();
// Validation is an explicit immutable option of each compiler operation. The
// program-link task snapshots MOBILEGL_ENABLE_SPIRV_VALIDATION before it can run
+18
View File
@@ -36,6 +36,24 @@ them wrong.
| 2026-10-06 | unit `DirectGLESSlotTable.{ANegativeLookupIsNotCachedAcrossAnotherHoldersAcquire, GetOrCreateToleratesANullStateObject}` | Test / unit | P13 (`# P13` ratchet gate): they pinned the slot table's frontend-keyed half - `HandleOf`'s lifetime-id memo and the minting `GetOrCreate(StatePtr)`'s null-object parking - which minted handles off frontend lifetime ids from inside MG_Backend; that half is deleted (the table only receives client-minted handles now). The other ten `DirectGLESSlotTable` mechanics cases were RETARGETED at the handle entries (`GetOrCreate(handle)` / `FindByHandle` / `ReleaseTwinByHandle`), not removed | the retargeted `DirectGLESSlotTable` cases, `SlotAllocatorTest`, and the link ratchet's P13 exit gate (no server object references the client allocator) |
| 2026-10-06 | unit `RemoteGuards.{SlotTableHandleOfFromTheApplyThreadIsFatalByName, SlotTableMintingGetOrCreateFromTheApplyThreadIsFatalByName, AnAllocatorProbeInsideAnExemptionScopeFromTheApplyThreadIsFatalEvenAtTheOldDebtSites, BarrieredFrontendRegistryMembersRefuseBothLegacyScopes, LegacyRegistryWrapperCannotExposeFrontendKeysOnApply, AnExemptionScopeHeldOnTheGLThreadDoesNotExemptTheApplyThread}` | Test / unit (split) | P13 (`# P13` ratchet gate): they drove the apply-thread refusals hung on Espryt's frontend-keyed registry entries (`HandleOf`, `GetOrCreate(StatePtr)`, `NoteStateForHandle`, `StateForHandle`, `Find(object)`, `begin()`) and the two no-op marker scopes; the entries, the registry refusal and both scopes are deleted, so there is no apply-thread surface left to refuse | `RemoteGuards.AnAllocatorProbeFromTheApplyThreadOutsideEveryScopeIsFatalByName` / `BarrieredLegacyScopesCannotExemptAllocator` (the allocator's own guard, kept) and the link ratchet's P13 exit gate |
| 2026-10-06 | unit `ServerLoopEglTest.FrontendFramebufferDeathDeletesOnTheContextOwner` | Test / unit (split) | P13 (`# P13` ratchet gate): its pin was the refusal of the apply-thread `GetOrCreate(StatePtr)` mint of a framebuffer twin; that overload is deleted | `CtWireScenario`'s object_death cases (framebuffer death by handle on the server) |
| 2026-10-17 | `Negative control (library without a transport)` (with its `Download/Unpack control runtime` steps) | Test / `retrace-split` (every leg) | a full replay rerun on all 154 legs (~60 runner-min) that, since the per-role logs, only ever proved "no client log was written"; a broken control build once reddened every leg at the download and hid every real split replay | in-test: `run_trace_case.cmake`'s transport assertions now leave `output/split-evidence.txt`, which `retrace_split_evidence.py` checks on every leg; `test_run_trace_case.py` (pipe-gates) proves each assertion red on canned logs, including the no-client-log and env-dump-line shapes this control used to rely on |
| 2026-10-17 | `Negative control (dropped draws)` | Test / `retrace-split` (every leg) | a rerun of every leg with `MOBILEGL_IPC_E2_DROP_DRAW`, a production hook; its knob's own early return produced the DirectVulkan `Fatal{RoleViolation, "gPipeInputs"}` reds of 10-02 to 10-05 | in-test on the OpenRA legs (both backends, inproc and spawn): `MGTRACE_WIRE_EVIDENCE` turns on the library's counters and `run_trace_case.cmake` requires DrawVbo records on the wire and draws on the server role (758 each locally); canned reds in `test_run_trace_case.py` |
| 2026-10-17 | `scripts/ci/retrace_transport_control.sh`, `scripts/ci/retrace_drop_draw_control.sh`, the retrace and drop-draw halves of `control_smoke_test.sh` and `stub_ctest.sh`, and `scripts/ci/redcheck_control_smoke_test.sh` | Test / pipe-gates (R-16) | the bodies of the two controls above and their smoke cases; the redcheck script had no caller and mutated the deleted scripts | `test_run_trace_case.py` |
| 2026-10-17 | unit `RemoteEmitTable.TheE2DropSwitchStartsDisarmed`, and the `MOBILEGL_IPC_E2_DROP_DRAW` / `MOBILEGL_IPC_E2_DROP_CLEAR` knobs with their per-draw and per-clear branches in the shipped emitter (`VerbPort.cpp`) | Test / unit (split); product code | the knobs existed only for the retired dropped-draws control (the clear knob was set by nothing); the unit case only toggled the clear setter | the wire-volume assertion in `run_trace_case.cmake` on the OpenRA split legs (DrawVbo records on the wire, draws on the server role) |
| 2026-10-17 | the 802-entry `DirectGLES.Split.` baseline rerun inside `split_negative_controls.sh` (skip-census lane `junit-integration-split/isplit`) | Test / `integration-split`, every branch | a rerun of entries 'Split lanes' had run minutes earlier on the same runner and library (~2.5 min); its logs also overwrote the lane's in the uploaded artifact | the script tallies the lane step's `split-baseline.xml` and its exit code (`split-baseline.rc`) with the same three refusals; it still reruns when that step never ran (the 10-12/10-13 catches); the `split-baseline` census lane covers the names; smoke cases for the reuse, the fallback, a red exit code under a green JUnit and an empty arm |
| 2026-10-17 | `Negative control B (omitted fill point)` | Test / `integration-verify` | it re-ran `PoisonOmissionScenario.WithoutOmissionCompletes` with `MOBILEGL_PIPE_POISON_OMIT=ReadPixels:GetPixelStoreParameters` and accepted any red | the in-lane `DirectGLES/DirectVulkan.PoisonOmitted.PoisonOmissionScenario.OmittedFieldAbortsOnThatVerb` entries: the same pair, and they also require SIGABRT with the named `Fatal{UnmigratedPipeInput}`; now required to pass by name |
| 2026-10-17 | `Verify lane arming check` (grep of `pipe-verify-arming-*.log`) | Test / `integration-verify` | the VerifyArming entries assert the armed line in-test; the grep only added "the entry did not skip" | `Verify lane control entries`: `junit_tally.py --require-entry-passed` on the VerifyArming, VerifyCorrupted and PoisonOmitted entries of both backends (a skip fails) |
| 2026-10-17 | `Negative control A (server-side read)` and `Negative control B (omitted stamp)` | Test / `integration-verify-split` | the first re-ran the two VerifySplitReadCorrupted entries the lane had just run and grepped what they already assert in-test (at least two where=read reports in the server half); the second re-ran with the pair the in-lane VerifySplitPoisonOmitted entries already pin, and accepted any red | `Verify-split control entries`: `--require-entry-passed` on VerifySplitArming, VerifySplitCorrupted, VerifySplitReadCorrupted and VerifySplitPoisonOmitted of both backends |
| 2026-10-17 | the pre-flip arm of `Strict split lane` (red-by-design while `kMGPipeP5eRunAheadReady` was false) and the "GREEN with rows outstanding" rule of `Dual-block census lane` | Test / `integration-split-strict` | dead: the constant is true, and `dualblock-expected-fatals.txt` has no rows, so neither branch could run | the hard-green arm and its two-sided marker census, and the empty-census rule (rows == 0 requires ctest success), both kept |
| 2026-10-17 | `scripts/ci/tcp_log_forwarding_control.py` | none | no caller outside the archived planning notes | none needed |
| 2026-10-17 | the compile of `build-linux-split` on the disaggregation branches (when `build-guard` proves the configurations identical) | Test / `build-linux-split` | it built the library `build-linux` builds there, from a configuration that differs only by the benchmarks (211 paired runs never disagreed); it delayed every split consumer by up to 9 minutes | the `build-guard` job configures both lines on one runner and compares them with `build_fingerprint.py` (cache, compile commands, link statements, CTest files; benchmarks excluded; one shared FetchContent directory, a failed configure or one killed after 10 minutes retried once) and records its image and compiler; only the verdict `identical` lets the consumers take `build-linux`'s artifacts, and only a Release one, while `different` (annotated as a warning) and `unknown` (a configure that failed twice, any error, too little evidence; a notice) keep both builds and the full split lanes; the guard job's own failure never fails the run; `build-linux` runs the link ratchet on every disaggregation build (artifact `link-ratchet-primary`) and integration-split reds on it when it takes that build; dev runs no guard and always builds |
| 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; `Default-knob resolution check` (disaggregation branches) 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, and the skip census then requires the two arm rows |
| 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; without a reused build the skip census requires `unit-split` |
| 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 and that the monolith-control leg ran every one; when it cannot, the leg runs everything under the JUnit stems `integration-all` and `integration-staged-copy-all`; skip census: the monolith-control lanes list their own names, the inproc-acceptance lanes drop the pinned ones, and two optional `-all` rows (the full names, the old skip lists) stand in for them on that fallback |
| 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 |
| 2026-10-17 | the 75 retrace-split inproc rows other than OpenRA's, when `build-linux-split` reuses `build-linux` | Test / `retrace-split` | the retrace job's inproc-acceptance rows replay the same cases on the same library, strict and with the dual block and run-ahead on; no one-sided pair since 10-05 | the inproc-acceptance rows, which now prove per leg from the client log that they run with strict=1, role-split-state=1 and run-ahead=1 (`MGTRACE_EXPECT_IPC`) and keep a refusal census; every remaining retrace-split leg proves its defaults (strict=0, role-split-state=1, run-ahead=1) and a changed default reds every leg with the rows to restore; `trace_cases.py --self-test-split-matrix` (disaggregation branches) proves every dropped row is an acceptance row; OpenRA's inproc rows stay for the wire evidence |
| 2026-10-17 | `Transport resolution check`'s grep of one entry's log for the inproc resolution sentence | Test / `integration-split` | it checked one entry (`DirectGLES.Split.PersistentMapArm.`) after the fact | in-test: the split fixture (`Harness/ScenarioFixture.h`) now fails every armed Split/Spawn/Tcp entry whose process did not resolve the transport its registration claims, or whose server is not where the claim says (a thread here for inproc, a process of its own for spawn and tcp), from the running process (`SplitRuntimePeek`, `ClientSession::PeerServerPid()`); `SplitLaneHarness.TransportClaimRule` (unit) proves the rule red on canned inputs; the step keeps the IPC default proof as `Default-knob resolution check` |
## Kept after investigation
+675
View File
@@ -0,0 +1,675 @@
#!/usr/bin/env python3
# MobileGL - scripts/ci/build_fingerprint.py
# Copyright (c) 2025-2026 MobileGL-Dev
# Licensed under the GNU Lesser General Public License v3.0:
# https://www.gnu.org/licenses/gpl-3.0.txt
# https://www.gnu.org/licenses/lgpl-3.0.txt
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
"""Decide whether the two Linux library configurations are identical apart from the benchmarks.
build-linux and build-linux-split configure the same sources twice. On the disaggregation
branches the only intended difference is MOBILEGL_BUILD_BENCHMARK, which adds MG_Benchmark and the
google benchmark dependency and touches nothing else. When the two configurations are identical,
the split consumers can take build-linux's artifacts and build-linux-split need not compile again.
The fingerprint is read from what configure writes, so it can be taken before the build:
cache MOBILEGL_* entries (except MOBILEGL_BUILD_BENCHMARK), the build type, compilers,
launchers, flags and the link tools from CMakeCache.txt;
compile every compile_commands.json entry: file, output and arguments;
link every link statement of build.ninja: outputs, rule, inputs and variables;
tests the text of every CTestTestfile.cmake and gtest *_include.cmake.
The build directory's path becomes @BUILD@ and a shared FetchContent directory @DEPS@; everything
under MobileGL/MG_Benchmark or the benchmark dependency is left out. Both fingerprints come from
one runner, so its toolchain and image are recorded beside the verdict and never compared.
build_fingerprint.py fingerprint --build-dir D [--deps-dir F] [--out F]
build_fingerprint.py compare A.json B.json
build_fingerprint.py guard --primary-dir P --split-dir S --deps-dir F [--github-output KEY] [--report R]
build_fingerprint.py --self-test
guard configures both lines with scripts/ci/configure_build.sh (configure only) into P and S, with
F as their one FetchContent directory, so only the first configure fetches anything; a configure
that fails, or runs past 10 minutes and is killed, is retried once. It writes
KEY=identical|different|unknown, image= and compiler= to $GITHUB_OUTPUT and always exits 0.
'different' is a proven difference. A configure that failed twice, any error, or a tree with fewer
than 200 compile entries, 3 link statements (libMobileGL.so among them) or 2 CTest files is
'unknown': only 'identical' lets the workflow reuse a build. Any other verdict is annotated with
its first reasons, a warning for 'different' and a notice for 'unknown'.
"""
import argparse
import contextlib
import io
import json
import os
from pathlib import Path
import re
import shlex
import shutil
import signal
import subprocess
import sys
import tempfile
import time
ROOT = Path(__file__).resolve().parents[2]
CONFIGURE = ROOT / 'scripts' / 'ci' / 'configure_build.sh'
# Each configure attempt is killed after CONFIGURE_TIMEOUT seconds. GUARD_STEP_MINUTES must equal the
# guard step's timeout-minutes in test.yml, and leave room for four killed attempts.
CONFIGURE_TIMEOUT = 600
GUARD_STEP_MINUTES = 45
MIN_COMPILE = 200
MIN_LINK = 3
MIN_TESTS = 2
VERDICTS = ('identical', 'different', 'unknown')
CACHE_KEY = re.compile(
r'^(MOBILEGL_\w+'
r'|CMAKE_BUILD_TYPE'
r'|CMAKE_(C|CXX)_COMPILER(_LAUNCHER)?'
r'|CMAKE_(C|CXX)_FLAGS(_\w+)?'
r'|CMAKE_(EXE|SHARED|MODULE|STATIC)_LINKER_FLAGS(_\w+)?'
r'|CMAKE_INTERPROCEDURAL_OPTIMIZATION|CMAKE_POSITION_INDEPENDENT_CODE'
r'|CMAKE_LINKER|CMAKE_AR|CMAKE_RANLIB)$')
CACHE_SKIP = {'MOBILEGL_BUILD_BENCHMARK'}
CACHE_LINE = re.compile(r'^([A-Za-z0-9_.+-]+):([A-Z]+)=(.*)$')
LINK_RULE = re.compile(r'^(C|CXX)_(SHARED_LIBRARY|EXECUTABLE|MODULE|STATIC_LIBRARY)_LINKER__')
BENCHMARK_PATH = re.compile(r'(^|/)(MobileGL/MG_Benchmark|(_deps|@DEPS@)/benchmark-[a-z]+)(/|$)')
BENCHMARK_SUBDIRS = re.compile(
r'^\s*subdirs\("[^"]*(MobileGL/MG_Benchmark|(_deps|@DEPS@)/benchmark-build)"\)\s*$')
PACKAGES = ('clang-20', 'lld-20', 'libc++-20-dev', 'libc++abi-20-dev', 'libvulkan-dev',
'libegl1-mesa-dev', 'libgles2-mesa-dev')
def normaliser(build_dir, deps_dir=None):
roots = [(str(Path(build_dir)), '@BUILD@'), (str(Path(build_dir).resolve()), '@BUILD@')]
if deps_dir:
roots += [(str(Path(deps_dir)), '@DEPS@'), (str(Path(deps_dir).resolve()), '@DEPS@')]
roots = sorted(set(roots), key=lambda pair: len(pair[0]), reverse=True)
def norm(text):
for root, token in roots:
text = text.replace(root, token)
return text
return norm
def is_benchmark(path):
return bool(BENCHMARK_PATH.search(path.replace('\\', '/')))
def read_cache(build, norm):
cache = {}
path = build / 'CMakeCache.txt'
if not path.is_file():
return cache
for line in path.read_text(errors='replace').splitlines():
m = CACHE_LINE.match(line)
if m and CACHE_KEY.match(m.group(1)) and m.group(1) not in CACHE_SKIP:
cache[m.group(1)] = norm(m.group(3))
return cache
def read_compile(build, norm):
path = build / 'compile_commands.json'
rows = []
if not path.is_file():
return rows
for entry in json.loads(path.read_text(errors='replace')):
directory = entry.get('directory', '')
source = entry.get('file', '')
if 'arguments' in entry:
argv = list(entry['arguments'])
else:
argv = shlex.split(entry.get('command', ''))
output = entry.get('output', '')
rel_out = norm(os.path.join(directory, output)) if output else ''
if is_benchmark(norm(source)) or is_benchmark(rel_out) or is_benchmark(norm(directory)):
continue
rows.append([norm(source), rel_out] + [norm(arg) for arg in argv])
return sorted(rows)
def ninja_lines(text):
"""build.ninja's logical lines: a line ending in an unescaped $ continues on the next one."""
out, pending = [], None
for raw in text.splitlines():
line = raw if pending is None else pending + raw.lstrip()
trailing = len(line) - len(line.rstrip('$'))
if trailing % 2 == 1:
pending = line[:-1]
continue
pending = None
out.append(line)
if pending is not None:
out.append(pending)
return out
def split_unescaped(text):
"""Ninja tokens: split on spaces that are not escaped as '$ '."""
tokens, current, i = [], '', 0
while i < len(text):
c = text[i]
if c == '$' and i + 1 < len(text):
current += text[i:i + 2]
i += 2
continue
if c == ' ':
if current:
tokens.append(current)
current = ''
else:
current += c
i += 1
if current:
tokens.append(current)
return tokens
def read_links(build, norm):
links = {}
if not (build / 'build.ninja').is_file():
return links
lines = ninja_lines((build / 'build.ninja').read_text(errors='replace'))
i = 0
while i < len(lines):
line = lines[i]
i += 1
if not line.startswith('build '):
continue
head = line[len('build '):]
colon = re.search(r'(?<!\$):', head)
if colon is None:
continue
outputs = split_unescaped(head[:colon.start()])
rest = split_unescaped(head[colon.end():])
if not rest or not LINK_RULE.match(rest[0]):
continue
rule, inputs = rest[0], []
for token in rest[1:]:
if token in ('|', '||', '|@'):
break
inputs.append(norm(token))
variables = {}
while i < len(lines) and lines[i].startswith((' ', '\t')):
key, _, value = lines[i].strip().partition(' = ')
variables[key] = norm(value)
i += 1
names = [norm(o) for o in outputs if o != '|']
if any(is_benchmark(name) for name in names):
continue
links[' '.join(names)] = {'rule': rule, 'inputs': sorted(inputs), 'vars': variables}
return links
def read_tests(build, norm):
tests = {}
for path in sorted(build.rglob('*.cmake')):
rel = path.relative_to(build).as_posix()
if path.name != 'CTestTestfile.cmake' and not path.name.endswith('_include.cmake'):
continue
if is_benchmark(rel) or '/CMakeFiles/' in '/' + rel:
continue
lines = [norm(line) for line in path.read_text(errors='replace').splitlines()]
kept = [line for line in lines if not BENCHMARK_SUBDIRS.match(line) and not line.startswith('# ')]
tests[rel] = '\n'.join(kept)
return tests
def run_version(argv):
try:
out = subprocess.run(argv, capture_output=True, text=True, timeout=60)
return (out.stdout + out.stderr).strip()
except (OSError, subprocess.SubprocessError) as error:
return f'unavailable: {error.__class__.__name__}'
def read_toolchain():
tools = {}
for name in ('MOBILEGL_CI_CC', 'MOBILEGL_CI_CXX', 'MOBILEGL_CI_LLD'):
exe = os.environ.get(name)
tools[name] = run_version([exe, '--version']) if exe else 'unset'
tools['cmake'] = run_version(['cmake', '--version'])
tools['ninja'] = run_version(['ninja', '--version'])
tools['packages'] = run_version(['dpkg-query', '-W', '-f=${Package} ${Version}\\n', *PACKAGES])
return tools
def read_image():
return {key: os.environ.get(key, '') for key in ('ImageOS', 'ImageVersion', 'RUNNER_NAME')}
def fingerprint(build_dir, deps_dir=None):
build = Path(build_dir)
norm = normaliser(build, deps_dir)
result = {
'cache': read_cache(build, norm),
'compile': read_compile(build, norm),
'link': read_links(build, norm),
'tests': read_tests(build, norm),
}
result['counts'] = {'compile': len(result['compile']), 'link': len(result['link']),
'tests': len(result['tests'])}
return result
def unproven(fp):
counts = fp['counts']
problems = []
if counts['compile'] < MIN_COMPILE:
problems.append(f"{counts['compile']} compile entries (< {MIN_COMPILE})")
if counts['link'] < MIN_LINK:
problems.append(f"{counts['link']} link statements (< {MIN_LINK})")
if not any(name.split()[0].endswith('libMobileGL.so') for name in fp['link']):
problems.append('no link statement for libMobileGL.so')
if counts['tests'] < MIN_TESTS:
problems.append(f"{counts['tests']} CTest files (< {MIN_TESTS})")
return problems
def compare(a, b, limit=8):
"""Per-field differences between two fingerprints; empty means equal."""
diffs = {}
keys = sorted(set(a['cache']) | set(b['cache']))
cache = [f"{k}: {a['cache'].get(k, '<absent>')!r} vs {b['cache'].get(k, '<absent>')!r}"
for k in keys if a['cache'].get(k) != b['cache'].get(k)]
if cache:
diffs['cache'] = cache
ca = {tuple(row) for row in a['compile']}
cb = {tuple(row) for row in b['compile']}
if ca != cb:
only_a = sorted(ca - cb)
only_b = sorted(cb - ca)
diffs['compile'] = ([f'only in first: {row[0]} -> {row[1]}' for row in only_a[:limit]]
+ [f'only in second: {row[0]} -> {row[1]}' for row in only_b[:limit]]
+ [f'{len(only_a)} vs {len(only_b)} differing entries'])
link = []
for name in sorted(set(a['link']) | set(b['link'])):
la, lb = a['link'].get(name), b['link'].get(name)
if la == lb:
continue
if la is None or lb is None:
link.append(f"{name}: only in {'second' if la is None else 'first'}")
continue
for field in ('rule', 'inputs'):
if la[field] != lb[field]:
link.append(f'{name}: {field} differs')
for var in sorted(set(la['vars']) | set(lb['vars'])):
if la['vars'].get(var) != lb['vars'].get(var):
link.append(f'{name}: {var} differs')
if link:
diffs['link'] = link
tests = [f"{name}: {'differs' if name in a['tests'] and name in b['tests'] else 'only in one'}"
for name in sorted(set(a['tests']) | set(b['tests']))
if a['tests'].get(name) != b['tests'].get(name)]
if tests:
diffs['tests'] = tests
return {field: rows[:limit] + ([f'... {len(rows) - limit} more'] if len(rows) > limit else [])
for field, rows in diffs.items()}
def verdict(a, b):
"""(verdict, reasons). Too little evidence on either side is 'unknown', never 'identical'."""
thin = [f'first: {p}' for p in unproven(a)] + [f'second: {p}' for p in unproven(b)]
if thin:
return 'unknown', thin
reasons = [f'{field}: {row}' for field, rows in compare(a, b).items() for row in rows]
return ('different' if reasons else 'identical'), reasons
def run_attempt(argv, label, timeout=CONFIGURE_TIMEOUT):
"""One configure attempt; True on success. One that runs past the timeout (a stalled fetch) is
killed with its whole process group, cmake and its git clones included, and counts as failed."""
proc = subprocess.Popen(argv, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
start_new_session=True)
try:
output, _ = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
try:
output, _ = proc.communicate(timeout=60)
except subprocess.TimeoutExpired:
output = ''
print(output[-6000:])
print(f'build_fingerprint: {label} ran past {timeout} s and was killed')
return False
if proc.returncode != 0:
print(output[-6000:])
print(f'build_fingerprint: {label} failed with exit {proc.returncode}')
return proc.returncode == 0
def run_configure(variant, build_dir, deps_dir, timeout=None):
"""configure_build.sh, configure only, with the shared FetchContent directory; True on success."""
return run_attempt(['bash', str(CONFIGURE), variant, str(build_dir), f'-DFETCHCONTENT_BASE_DIR={deps_dir}'],
f'configure_build.sh {variant}', CONFIGURE_TIMEOUT if timeout is None else timeout)
def configure_twice(configure, variant, build_dir, deps_dir):
"""One retry: a transient fetch failure must not decide anything. Returns the attempts used."""
for attempt in (1, 2):
if build_dir.exists():
shutil.rmtree(build_dir)
if configure(variant, build_dir, deps_dir):
return attempt
return 0
def decide(primary_dir, split_dir, deps_dir, configure=run_configure):
"""(verdict, reasons, counts). The primary line runs first: it alone fetches the benchmark."""
primary_dir, split_dir, deps_dir = Path(primary_dir), Path(split_dir), Path(deps_dir)
counts = {}
for variant, build_dir in (('primary', primary_dir), ('split', split_dir)):
attempts = configure_twice(configure, variant, build_dir, deps_dir)
if not attempts:
return 'unknown', [f'the {variant} configure failed twice'], counts
counts[f'{variant}_attempts'] = attempts
primary = fingerprint(primary_dir, deps_dir)
split = fingerprint(split_dir, deps_dir)
counts.update(primary=primary['counts'], split=split['counts'])
result, reasons = verdict(primary, split)
return result, reasons, counts
def one_line(text, limit=200):
return ' '.join(str(text).split())[:limit]
def annotation(result, reasons):
"""The workflow annotation for a verdict other than 'identical': a 'different' that persists is
configuration drift that brings the second build back on every run, so it is a warning."""
if result == 'identical':
return None
level = 'warning' if result == 'different' else 'notice'
text = one_line('; '.join(reasons[:3]) or 'no reason recorded', 600).replace('%', '%25')
return f'::{level}::build-guard verdict {result}, so build-linux-split builds: {text}'
def write_outputs(key, result, image, compiler):
lines = [f'{key}={result}', f'image={one_line(image)}', f'compiler={one_line(compiler)}']
for line in lines:
print(line)
path = os.environ.get('GITHUB_OUTPUT')
if path:
with open(path, 'a') as out:
out.write(''.join(line + '\n' for line in lines))
def guard(args, configure=run_configure, toolchain_reader=read_toolchain):
result, reasons, counts = 'unknown', [], {}
image = read_image()
toolchain = {}
try:
toolchain = toolchain_reader()
result, reasons, counts = decide(args.primary_dir, args.split_dir, args.deps_dir, configure)
except Exception as error: # a guard that cannot decide says so, and nothing is reused
result, reasons = 'unknown', [f'the guard could not compare the configurations: {error!r}']
compiler = (toolchain.get('MOBILEGL_CI_CXX') or 'unset').splitlines()[0] if toolchain else 'unread'
image_text = f"{image['ImageOS']} {image['ImageVersion']}".strip() or 'unknown image'
print(f'build_fingerprint: {counts}')
print(f'build_fingerprint: this runner: {image_text}, {compiler}')
for reason in reasons:
print(f'build_fingerprint: {reason}')
note = annotation(result, reasons)
if note:
print(note)
if args.report:
Path(args.report).write_text(json.dumps({'verdict': result, 'reasons': reasons, 'counts': counts,
'toolchain': toolchain, 'image': image}, indent=1) + '\n')
write_outputs(args.github_output, result, image_text, compiler)
return 0
# ------------------------------------------------------------------------------------- self-test
def canned_tree(root, *, name='build', mobilegl_flag='ON', build_type='Release', define='-DMG=1',
link_flag='-Wl,--as-needed', test_env='VK_ICD_FILENAMES=/icd.json', benchmark=False,
empty=False, compile_count=MIN_COMPILE + 5, deps=None, library='libMobileGL.so'):
build = root / name
build.mkdir(parents=True)
if empty:
return build
abs_build = str(build)
deps = str(deps) if deps else abs_build + '/_deps'
(build / 'CMakeCache.txt').write_text(
f'CMAKE_BUILD_TYPE:STRING={build_type}\n'
'CMAKE_CXX_COMPILER:FILEPATH=/usr/bin/clang++-20\n'
'CMAKE_CXX_COMPILER_LAUNCHER:STRING=ccache\n'
'CMAKE_CXX_FLAGS:STRING=\n'
f'MOBILEGL_BUILD_DISAGGREGATED:BOOL={mobilegl_flag}\n'
f"MOBILEGL_BUILD_BENCHMARK:BOOL={'ON' if benchmark else 'OFF'}\n"
f'FETCHCONTENT_BASE_DIR:PATH={deps}\n'
f'CMAKE_CACHEFILE_DIR:INTERNAL={abs_build}\n')
entries = [{'directory': abs_build, 'file': f'/src/MobileGL/f{i}.cpp',
'output': f'CMakeFiles/MobileGL.dir/f{i}.cpp.o',
'command': f'ccache clang++ {define if i == 7 else "-DMG=1"} -I{abs_build}/gen '
f'-I{deps}/googletest-src/googletest/include '
f'-o CMakeFiles/MobileGL.dir/f{i}.cpp.o -c /src/MobileGL/f{i}.cpp'}
for i in range(compile_count)]
entries.append({'directory': deps + '/googletest-build/googletest',
'file': deps + '/googletest-src/googletest/src/gtest-all.cc',
'output': 'CMakeFiles/gtest.dir/src/gtest-all.cc.o', 'command': 'clang++ -c gtest-all.cc'})
if benchmark:
entries.append({'directory': abs_build + '/MobileGL/MG_Benchmark', 'file': '/src/MobileGL/MG_Benchmark/b.cpp',
'output': 'CMakeFiles/SanityBench.dir/b.cpp.o', 'command': 'clang++ -c b.cpp'})
entries.append({'directory': deps + '/benchmark-build/src', 'file': deps + '/benchmark-src/src/benchmark.cc',
'output': 'CMakeFiles/benchmark.dir/benchmark.cc.o', 'command': 'clang++ -c benchmark.cc'})
(build / 'compile_commands.json').write_text(json.dumps(entries))
links = [
(library, 'CXX_SHARED_LIBRARY_LINKER__MobileGL_Release', 'CMakeFiles/MobileGL.dir/f1.cpp.o'),
('libMobileGL_s.a', 'CXX_STATIC_LIBRARY_LINKER__MobileGL_s_Release', 'CMakeFiles/MobileGL.dir/f2.cpp.o'),
('MobileGL/MG_Test/SanityTest', 'CXX_EXECUTABLE_LINKER__SanityTest_Release', 'libMobileGL_s.a'),
]
if benchmark:
links.append(('MobileGL/MG_Benchmark/SanityBench', 'CXX_EXECUTABLE_LINKER__SanityBench_Release',
'libMobileGL_s.a'))
links.append((deps + '/benchmark-build/src/libbenchmark.a', 'CXX_STATIC_LIBRARY_LINKER__benchmark_Release',
deps + '/benchmark-build/src/CMakeFiles/benchmark.dir/benchmark.cc.o'))
ninja = ['ninja_required_version = 1.5', '']
for out, rule, inp in links:
ninja += [f'build {out}: {rule} {inp} | /usr/lib/libc.so || cmake_object_order_depends_target_x',
f' LINK_FLAGS = {link_flag}', ' LINK_LIBRARIES = -lEGL $', ' -lvulkan',
f' POST_BUILD = cd {abs_build}/MobileGL && :', '']
ninja.append('build CMakeFiles/x.o: CXX_COMPILER__MobileGL_Release /src/x.cpp')
(build / 'build.ninja').write_text('\n'.join(ninja) + '\n')
subdirs = (f'subdirs("MobileGL/MG_Test")\nsubdirs("{deps}/googletest-build")\n'
+ (f'subdirs("MobileGL/MG_Benchmark")\nsubdirs("{deps}/benchmark-build")\n' if benchmark else ''))
(build / 'CTestTestfile.cmake').write_text(f'# CMake generated Testfile for {abs_build}\n{subdirs}')
(build / 'MobileGL' / 'MG_Test').mkdir(parents=True)
(build / 'MobileGL' / 'MG_Test' / 'CTestTestfile.cmake').write_text(
f'add_test([=[T]=] "{abs_build}/MobileGL/MG_Test/SanityTest")\n'
f'set_tests_properties([=[T]=] PROPERTIES ENVIRONMENT "{test_env}")\n')
if benchmark:
(build / 'MobileGL' / 'MG_Benchmark').mkdir(parents=True)
(build / 'MobileGL' / 'MG_Benchmark' / 'CTestTestfile.cmake').write_text(
'add_test([=[SanityBench]=] "SanityBench")\n')
return build
def self_test():
cases = [
('none', {}, 'identical'),
('a MOBILEGL_* cache line', {'mobilegl_flag': 'OFF'}, 'different'),
("one TU's -D", {'define': '-DMG=2'}, 'different'),
('the build type', {'build_type': 'Debug'}, 'different'),
('a link flag', {'link_flag': '-Wl,--no-as-needed'}, 'different'),
('a test ENVIRONMENT', {'test_env': 'VK_ICD_FILENAMES=/other.json'}, 'different'),
('only the benchmark TUs, link lines, subdirs lines and dependency', {'benchmark': True}, 'identical'),
('only the build directory name', {'name': 'build-other'}, 'identical'),
('only where the shared FetchContent directory is', {'deps_name': 'deps-elsewhere'}, 'identical'),
('an empty directory', {'empty': True}, 'unknown'),
]
failures = 0
def report(ok, label, got):
nonlocal failures
failures += not ok
print(f"{'ok' if ok else 'NOT OK':7} {label}: {got}")
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
base_deps = tmp / 'deps'
base = fingerprint(canned_tree(tmp / 'base', deps=base_deps), base_deps)
assert not unproven(base), unproven(base)
for label, change, expected in cases:
change = dict(change)
deps = tmp / change.pop('deps_name', 'deps')
root = tmp / re.sub(r'\W+', '_', label)
other = fingerprint(canned_tree(root, deps=deps, **change), deps)
got, reasons = verdict(base, other)
report(got == expected, label, got + ('' if got == expected or not reasons else f' ({reasons[:2]})'))
# Two trees that agree on everything but are too thin to prove anything are never identical.
thin_a = fingerprint(canned_tree(tmp / 'thin-a', compile_count=10, deps=base_deps), base_deps)
thin_b = fingerprint(canned_tree(tmp / 'thin-b', compile_count=10, deps=base_deps), base_deps)
report(verdict(thin_a, thin_b)[0] == 'unknown', 'both sides thin', verdict(thin_a, thin_b)[0])
empty = verdict(fingerprint(canned_tree(tmp / 'empty-a', empty=True)),
fingerprint(canned_tree(tmp / 'empty-b', empty=True)))[0]
report(empty == 'unknown', 'both sides empty', empty)
# Full-sized trees that agree but link no libMobileGL.so prove nothing about the library.
no_lib = [fingerprint(canned_tree(tmp / f'no-lib-{side}', deps=base_deps, library='libMobileGLServer.so'),
base_deps) for side in 'ab']
report(verdict(*no_lib)[0] == 'unknown', 'both sides without a libMobileGL.so link', verdict(*no_lib)[0])
# A configure attempt that hangs is killed with its children, so it cannot hold the step.
started = time.monotonic()
with contextlib.redirect_stdout(io.StringIO()):
hung = run_attempt(['bash', '-c', 'sleep 60 & wait'], 'a hung configure', timeout=1)
elapsed = time.monotonic() - started
report(not hung and elapsed < 30, 'a hung configure is killed and fails', f'{hung} after {elapsed:.1f} s')
# The production path carries the timeout too, and four killed attempts fit the step.
stub = tmp / 'hanging-configure.sh'
stub.write_text('sleep 60 & wait\n')
real_configure = CONFIGURE
globals()['CONFIGURE'] = stub
try:
started = time.monotonic()
with contextlib.redirect_stdout(io.StringIO()):
hung = run_configure('primary', tmp / 'hung-build', tmp / 'hung-deps', timeout=1)
elapsed = time.monotonic() - started
finally:
globals()['CONFIGURE'] = real_configure
report(not hung and elapsed < 30, 'run_configure passes its timeout on', f'{hung} after {elapsed:.1f} s')
report(isinstance(CONFIGURE_TIMEOUT, int) and CONFIGURE_TIMEOUT > 0
and 4 * (CONFIGURE_TIMEOUT + 60) <= GUARD_STEP_MINUTES * 60,
'four killed configure attempts fit the guard step', CONFIGURE_TIMEOUT)
escaped = annotation('different', ['100% of the flags'])
report(escaped is not None and escaped.endswith('100%25 of the flags'), "an annotation escapes '%'", escaped)
# The guard itself, with a stand-in for configure_build.sh.
def run_guard(configure, label):
output = tmp / f'github_output-{label}'
old = os.environ.get('GITHUB_OUTPUT')
os.environ['GITHUB_OUTPUT'] = str(output)
try:
args = argparse.Namespace(primary_dir=str(tmp / f'{label}-primary'),
split_dir=str(tmp / f'{label}-split'),
deps_dir=str(tmp / f'{label}-deps'), github_output='verdict', report=None)
printed = io.StringIO()
with contextlib.redirect_stdout(printed):
guard(args, configure, lambda: {'MOBILEGL_CI_CXX': 'clang version 20.1.8'})
finally:
if old is None:
os.environ.pop('GITHUB_OUTPUT', None)
else:
os.environ['GITHUB_OUTPUT'] = old
outputs = dict(line.split('=', 1) for line in output.read_text().splitlines())
outputs['annotations'] = [line for line in printed.getvalue().splitlines() if line.startswith('::')]
return outputs
calls = []
def flaky(variant, build_dir, deps_dir):
calls.append(variant)
if variant == 'primary' and calls.count('primary') == 1:
return False
canned_tree(build_dir.parent, name=build_dir.name, benchmark=variant == 'primary', deps=deps_dir)
return True
out = run_guard(flaky, 'flaky')
report(out.get('verdict') == 'identical' and calls == ['primary', 'primary', 'split'],
'a configure that fails once is retried', f"{out.get('verdict')} after {calls}")
report(set(out) == {'verdict', 'image', 'compiler', 'annotations'},
'the guard writes verdict, image and compiler', sorted(out))
report(out['annotations'] == [], "'identical' is not annotated", out['annotations'])
hangs = []
def hangs_once(variant, build_dir, deps_dir):
hangs.append(variant)
if len(hangs) == 1:
return run_attempt(['bash', '-c', 'sleep 60 & wait'], 'a hung configure', timeout=1)
canned_tree(build_dir.parent, name=build_dir.name, benchmark=variant == 'primary', deps=deps_dir)
return True
out = run_guard(hangs_once, 'hangs')
report(out.get('verdict') == 'identical' and hangs == ['primary', 'primary', 'split'],
'a configure killed on its timeout is retried', f"{out.get('verdict')} after {hangs}")
def broken(variant, build_dir, deps_dir):
return variant != 'split'
out = run_guard(broken, 'broken')
report(out.get('verdict') == 'unknown', 'a configure that fails twice', out.get('verdict'))
report(len(out['annotations']) == 1 and out['annotations'][0].startswith('::notice::')
and 'failed twice' in out['annotations'][0], "'unknown' is a notice with its reason",
out['annotations'])
def debug_split(variant, build_dir, deps_dir):
canned_tree(build_dir.parent, name=build_dir.name, deps=deps_dir,
build_type='Debug' if variant == 'split' else 'Release')
return True
out = run_guard(debug_split, 'differ')
report(out.get('verdict') == 'different', 'a proven difference', out.get('verdict'))
report(len(out['annotations']) == 1 and out['annotations'][0].startswith('::warning::')
and 'CMAKE_BUILD_TYPE' in out['annotations'][0], "'different' is a warning with its reason",
out['annotations'])
def raising(variant, build_dir, deps_dir):
raise OSError('no bash')
out = run_guard(raising, 'raising')
report(out.get('verdict') == 'unknown', 'an error inside the guard', out.get('verdict'))
print(f'build_fingerprint self-test: {"FAILED" if failures else "ok"}')
return 1 if failures else 0
def main(argv=None):
if argv is None:
argv = sys.argv[1:]
if argv == ['--self-test']:
return self_test()
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest='command', required=True)
p = sub.add_parser('fingerprint')
p.add_argument('--build-dir', required=True)
p.add_argument('--deps-dir')
p.add_argument('--out')
p = sub.add_parser('compare')
p.add_argument('first')
p.add_argument('second')
p = sub.add_parser('guard')
p.add_argument('--primary-dir', required=True)
p.add_argument('--split-dir', required=True)
p.add_argument('--deps-dir', required=True)
p.add_argument('--github-output', default='verdict')
p.add_argument('--report')
args = parser.parse_args(argv)
if args.command == 'fingerprint':
fp = fingerprint(args.build_dir, args.deps_dir)
text = json.dumps(fp, indent=1, sort_keys=True) + '\n'
if args.out:
Path(args.out).write_text(text)
print(f"build_fingerprint: {args.build_dir}: {fp['counts']}; unproven: {unproven(fp) or 'no'}")
return 0
if args.command == 'compare':
a = json.loads(Path(args.first).read_text())
b = json.loads(Path(args.second).read_text())
result, reasons = verdict(a, b)
for reason in reasons:
print(reason)
print(f'verdict={result}')
return 0 if result == 'identical' else 1
return guard(args)
if __name__ == '__main__':
sys.exit(main())
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
# MobileGL - scripts/ci/configure_build.sh
# Copyright (c) 2025-2026 MobileGL-Dev
# Licensed under the GNU Lesser General Public License v3.0:
# https://www.gnu.org/licenses/gpl-3.0.txt
# https://www.gnu.org/licenses/lgpl-3.0.txt
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
# The configure lines of the two Linux library builds, in one place, so build-linux and
# build-linux-split run exactly these and build_fingerprint.py can configure either one as a probe.
# primary build-linux's: CI_BUILD_TYPE (default Release), MOBILEGL_CI_DISAGGREGATED (ON or OFF,
# required) for the transport, the benchmarks, compile_commands.json.
# split build-linux-split's: Release, the transport and its in-process arm always on.
# Both default to Release at the INFO log level, like the shipped build. INFO is load-bearing: the
# library logs the resolved transport at INFO, and run_trace_case.cmake and the split lanes read
# that line back as proof the transport resolved in the process under test.
#
# usage: configure_build.sh <primary|split> <build dir> [extra cmake arguments...]
# with MOBILEGL_CI_CC, MOBILEGL_CI_CXX and VK_ICD_FILENAMES from setup-test-runner. The builds pass
# no extra arguments; build_fingerprint.py's guard passes its shared FetchContent directory.
set -euo pipefail
variant="${1:?usage: configure_build.sh <primary|split> <build dir>}"
build_dir="${2:?usage: configure_build.sh <primary|split> <build dir>}"
shift 2
common=(
-S . -B "${build_dir}" -G Ninja
-DCMAKE_C_COMPILER="${MOBILEGL_CI_CC}"
-DCMAKE_CXX_COMPILER="${MOBILEGL_CI_CXX}"
-DCMAKE_C_COMPILER_LAUNCHER=ccache
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache
)
case "${variant}" in
primary)
disaggregated="${MOBILEGL_CI_DISAGGREGATED:?the primary build needs MOBILEGL_CI_DISAGGREGATED (ON or OFF)}"
flags=(
-DCMAKE_BUILD_TYPE="${CI_BUILD_TYPE:-Release}"
-DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO
-DMOBILEGL_BUILD_TEST=ON
-DMOBILEGL_BUILD_BENCHMARK=ON
-DMOBILEGL_BUILD_INTEGRATION_TEST=ON
-DMOBILEGL_ITEST_VK_ICD="${VK_ICD_FILENAMES}"
-DMOBILEGL_BUILD_TRACE_REPLAY=OFF
-DMOBILEGL_BUILD_DISAGGREGATED="${disaggregated}"
-DMOBILEGL_BUILD_DISAGGREGATED_INPROC="${disaggregated}"
-DMOBILEGL_PIPE_VERIFY=OFF
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON
-DBENCHMARK_DOWNLOAD_DEPENDENCIES=ON
-DBENCHMARK_ENABLE_TESTING=OFF
-DCMAKE_POLICY_VERSION_MINIMUM=3.5
)
;;
split)
# _INPROC implies _DISAGGREGATED; both are passed, so neither depends on remembering that.
flags=(
-DCMAKE_BUILD_TYPE=Release
-DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO
-DMOBILEGL_BUILD_TEST=ON
-DMOBILEGL_BUILD_BENCHMARK=OFF
-DMOBILEGL_BUILD_INTEGRATION_TEST=ON
-DMOBILEGL_ITEST_VK_ICD="${VK_ICD_FILENAMES}"
-DMOBILEGL_BUILD_TRACE_REPLAY=OFF
-DMOBILEGL_BUILD_DISAGGREGATED=ON
-DMOBILEGL_BUILD_DISAGGREGATED_INPROC=ON
-DCMAKE_POLICY_VERSION_MINIMUM=3.5
)
;;
*)
echo "configure_build.sh: unknown variant '${variant}' (primary or split)" >&2
exit 2
;;
esac
cmake "${common[@]}" "${flags[@]}" "$@"
+60 -87
View File
@@ -7,21 +7,15 @@
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
# R-16 FOR THE CI NEGATIVE CONTROLS THEMSELVES: a control-run smoke test.
# A smoke test for the CI negative controls themselves.
#
# BRIEF-P5 13 (R-16) says a negative control must assert its own failure reason and that every gate
# carries a line saying "I made it red once, by doing X". The two controls this file exercises ARE
# gates, and until ID-46 finding 8 nobody could make either of them red, because a workflow `run:`
# block only executes on a runner. The wave-1 verification agent had to hand-copy the blocks into
# throwaway harnesses to show they were broken (wave1-codex-verify.md 8). This file is that
# experiment, kept: it runs the REAL control scripts - the same files .github/workflows/test.yml
# invokes, not copies of them - against a stubbed ctest, and checks that each one passes exactly
# when it should.
# A negative control is a gate, and a workflow `run:` block only executes on a runner, so this
# file runs the real control script - the same file .github/workflows/test.yml invokes, not a copy
# of it - against a stubbed ctest, and checks that it passes exactly when it should.
#
# The case that matters is the first one. A stubbed ctest reports a NON-EMPTY selection and then
# The case that matters is the first one. A stubbed ctest reports a non-empty selection and then
# fails with UNRELATED_CONTROL_FAILURE: a reason that has nothing to do with the knob the control
# turns. Before ID-48's fix both controls printed their success message and the step exited 0. They
# must now report FAILED.
# turns. The control must report FAILED on it, and PASSED only on its scenarios' own diagnostic.
#
# usage: control_smoke_test.sh
set -u
@@ -55,33 +49,62 @@ expect() {
fi
}
run_split() { # $1 = STUB_MODE
# The lane step's own baseline, as the workflow leaves it: the stub's JUnit for the whole label and
# its exit code beside it, in <base>.xml and <base>.rc.
lane_baseline() { # $1 = STUB_MODE, $2 = base path
env -i PATH="${STUB_DIR}:/usr/bin:/bin" STUB_MODE="$1" CONTROL_TMPDIR="${WORK}/tmp-$1" \
ctest -L integration-split --no-tests=error --output-junit "$2.xml" > /dev/null 2>&1
echo "$?" > "$2.rc"
}
# run_split <STUB_MODE> [baseline path]: the controls, tallying the lane step's baseline. A path
# with no files behind it is the job whose lane step never ran.
run_split() {
local base="${2:-${WORK}/base-$1}"
[ -n "${2:-}" ] || lane_baseline "$1" "${base}"
env -i PATH="${STUB_DIR}:/usr/bin:/bin" STUB_MODE="$1" \
CTEST=ctest CONTROL_TMPDIR="${WORK}/tmp-$1" \
CTEST=ctest CONTROL_TMPDIR="${WORK}/tmp-$1" SPLIT_BASELINE_JUNIT="${base}.xml" \
bash "${HERE}/split_negative_controls.sh"
}
run_retrace() { # $1 = STUB_MODE
cd "${WORK}" || return 127
mkdir -p "${WORK}/OpenRA"
local rc=0
env -i PATH="${STUB_DIR}:/usr/bin:/bin" STUB_MODE="$1" \
CTEST=ctest CONTROL_TMPDIR="${WORK}/tmp-$1" \
CONTROL_LIBRARY="${WORK}/pull.so" FROZEN_LIBRARY="${WORK}/frozen.so" \
bash "${HERE}/retrace_transport_control.sh" OpenRA DirectGLES || rc=$?
cmp -s "${WORK}/frozen.so" "${WORK}/split.so" || {
echo 'F6 FAILED: pull control did not restore the split library'; return 1;
}
# run_split_saying <sentence> <args for run_split...>: run_split, and its output must carry the
# sentence that says which baseline it used.
run_split_saying() {
local sentence="$1"
shift
local out
out=$(run_split "$@" 2>&1)
local rc=$?
printf '%s\n' "${out}"
printf '%s' "${out}" | grep -qF "${sentence}" || { echo "missing: ${sentence}"; return 1; }
return "${rc}"
}
run_drop_draw() { # $1 = STUB_MODE
cd "${WORK}" || return 127
mkdir -p "${WORK}/OpenRA"
env -i PATH="${STUB_DIR}:/usr/bin:/bin" STUB_MODE="$1" \
CTEST=ctest CONTROL_TMPDIR="${WORK}/tmp-$1" \
FROZEN_LIBRARY="${WORK}/frozen.so" LIBRARY_LOG="${WORK}/tmp-$1/mobilegl.log" \
bash "${HERE}/retrace_drop_draw_control.sh" OpenRA DirectGLES
# The lane step's JUnit says green but its exit code says red: ctest's "Not Run" is a skip in
# JUnit, so the code is part of the baseline and must be honoured.
run_split_red_code() {
lane_baseline evidence "${WORK}/redcode"
echo 8 > "${WORK}/redcode.rc"
run_split evidence "${WORK}/redcode"
}
# The lane step never ran: the controls rerun the baseline, and the rerun's JUnit stays out of
# CONTROL_TMPDIR/*.xml, which the job uploads for the skip census.
run_split_rerun() {
rm -f "${WORK}/tmp-evidence/isplit.xml"
run_split_saying "reran the DirectGLES.Split. baseline" evidence "${WORK}/absent" || return 1
if [ -e "${WORK}/tmp-evidence/isplit.xml" ]; then
echo "the rerun's JUnit landed in CONTROL_TMPDIR/*.xml"
return 1
fi
}
# The lane step ran nothing of the DirectGLES.Split. arm (only other entries of the label).
run_split_no_arm() {
printf '%s\n' '<testsuite><testcase name="SplitLogPaths.PrivateAndDistinct" status="run"/></testsuite>' \
> "${WORK}/noarm.xml"
echo 0 > "${WORK}/noarm.rc"
run_split evidence "${WORK}/noarm"
}
echo "=== the split lane's E1 / E3(a) controls (scripts/ci/split_negative_controls.sh)"
@@ -101,61 +124,11 @@ expect FAILED "E1 restored boundary remains red" -- run_split e1-res
expect FAILED "E1 record never reaches the peer" -- run_split e1-no-peer
expect FAILED "E1 observed no real emission" -- run_split e1-no-emit
expect FAILED "E1 incorrectly waits every record" -- run_split e1-waitall
echo
echo "=== the retrace lane's pull-library control (scripts/ci/retrace_transport_control.sh)"
# A pull-shaped library the nm identity check accepts: a real ELF .so defining no MG_Remote symbol.
if command -v cc > /dev/null 2>&1; then
printf '%s\n' 'int mobilegl_pull_only(void) { return 1; }' > "${WORK}/pull.c"
cc -shared -fPIC -o "${WORK}/pull.so" "${WORK}/pull.c" || { echo "cannot build the stand-in library"; exit 1; }
else
echo "no cc available; the retrace half of this smoke test needs one" >&2
exit 1
fi
printf '%s\n' 'int MG_Remote_stub(void) { return 1; }' > "${WORK}/split.c"
cc -shared -fPIC -o "${WORK}/frozen.so" "${WORK}/split.c" || exit 1
cp "${WORK}/frozen.so" "${WORK}/split.so"
# THE FINDING, part (b): a regex matching no tests. --no-tests=error exits non-zero and the old
# control read that as "the pull library turned it red".
expect FAILED "empty selection (--no-tests=error exit)" -- run_retrace retrace-noselect
# A red that never names the transport: a fixture failure, a loader failure, a timeout.
expect FAILED "red without the transport-resolution message" -- run_retrace retrace-unrelated
# The real thing - and note the stub emits it CMake-wrapped across two lines, which a line-oriented
# grep for the literal sentence would miss.
expect PASSED "run_trace_case.cmake's own sentence, wrapped" -- run_retrace retrace-evidence
# The sentence a pull library ACTUALLY gets since P6's log rename: it wrote the unsuffixed
# mobilegl.log, the runner reads mobilegl.client.log, and the no-log check fires before the marker
# search. The control accepted only the marker sentence until B3's fix round 2, and was red on
# every real pull-library run (measured on the B3 package tree).
expect PASSED "the no-client-log sentence a pull library gets" -- run_retrace retrace-evidence-nolog
# Both sentences again as `ctest -V` really prints them, with "1: " on every line: folded
# naively that is "never 1: reported resolving it", and the control used to red on it.
expect PASSED "the marker sentence under ctest -V's N: prefix" -- run_retrace retrace-evidence-prefixed
expect PASSED "the no-log sentence under ctest -V's N: prefix" -- run_retrace retrace-evidence-nolog-prefixed
# The pull library replaying green is the failure this control exists to catch.
expect FAILED "a pull library passed the split retrace" -- run_retrace retrace-green
echo
echo "=== the retrace lane's draw-drop control (scripts/ci/retrace_drop_draw_control.sh)"
# Exit gate E2's picture control. The joint gate's finding is the reason it exists at all: the
# CLEAR-drop knob was armed, was read, dropped all 29 of OpenRA's clears - and the retrace still
# scored ssim 1.000000, because OpenRA overdraws every pixel it clears. So this control's three
# guards are each a different way for "the picture went red" to be someone else's red.
expect FAILED "empty selection (--no-tests=error exit)" -- run_drop_draw dropdraw-noselect
# The failure the gate exists to catch: the golden survives the loss of every draw.
expect FAILED "the retrace passed with every draw dropped" -- run_drop_draw dropdraw-green
# A red with no comparator output at all - a loader failure, a missing fixture, a timeout.
expect FAILED "red with no ssim summary in the output" -- run_drop_draw dropdraw-nossim
# A red whose SSIM is FINE: something else (a Fatal{, a transport assertion) reddened the case.
expect FAILED "red but the ssim is above the threshold" -- run_drop_draw dropdraw-ssimhigh
# A red picture with no evidence the knob was ever read by the process that produced it.
expect FAILED "no 'E2 control armed' line in the library log" -- run_drop_draw dropdraw-nolog
# The R-16 case the dropped-record COUNT exists for: the knob armed and dropped nothing, so the
# wrong picture came from somewhere else.
expect FAILED "the knob armed but dropped zero records" -- run_drop_draw dropdraw-zero
# The real thing: a red picture, a fallen SSIM, and N > 0 records the library says it dropped.
expect PASSED "ssim below threshold and N records dropped" -- run_drop_draw dropdraw-evidence
# The baseline is the lane step's own run; the controls rerun it only when that step never ran.
expect PASSED "the lane step's baseline is tallied, not rerun" -- run_split_saying "no rerun" evidence
expect PASSED "no lane JUnit: the controls rerun the baseline" -- run_split_rerun
expect FAILED "the lane step's exit code is red, its JUnit green" -- run_split_red_code
expect FAILED "the lane JUnit has no DirectGLES.Split. entry" -- run_split_no_arm
echo
echo "smoke test: ${passes} passed, ${failures} failed"
+41
View File
@@ -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.'):
-174
View File
@@ -1,174 +0,0 @@
#!/bin/bash
# MobileGL - scripts/ci/redcheck_control_smoke_test.sh
# Copyright (c) 2025-2026 MobileGL-Dev
# Licensed under the GNU Lesser General Public License v3.0:
# https://www.gnu.org/licenses/gpl-3.0.txt
# https://www.gnu.org/licenses/lgpl-3.0.txt
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
# R-16's "I made it red once, by doing X" for scripts/ci/control_smoke_test.sh, mechanised so the
# claim can be re-checked rather than believed.
#
# X = revert the message check in each control, i.e. put the controls back in the state ID-46
# finding 8 found them in: a non-zero ctest exit is accepted whatever the failure was.
#
# The smoke test must then FAIL on the cases that exist for this defect -
# "unrelated failure with a non-empty selection" and "red without the transport-resolution
# message", plus the missing private-file Fatal - and not merely somewhere. A smoke test that goes
# red for any other reason when the evidence check is removed would not be pinning the evidence check.
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
WORK="$(mktemp -d)" || exit 1
HELPER="${HERE}/../../MobileGL/MG_IntegrationTest/Harness/split_log_paths.py"
WAIT_HELPER="${HERE}/wait_boundary_negative_control.py"
trap 'cp "${WORK}/split.orig" "${HERE}/split_negative_controls.sh"; cp "${WORK}/retrace.orig" "${HERE}/retrace_transport_control.sh"; cp "${WORK}/dropdraw.orig" "${HERE}/retrace_drop_draw_control.sh"; cp "${WORK}/helper.orig" "${HELPER}"; cp "${WORK}/wait.orig" "${WAIT_HELPER}"; rm -rf "${WORK}"' EXIT
cp "${HERE}/split_negative_controls.sh" "${WORK}/split.orig"
cp "${HERE}/retrace_transport_control.sh" "${WORK}/retrace.orig"
cp "${HERE}/retrace_drop_draw_control.sh" "${WORK}/dropdraw.orig"
cp "${HELPER}" "${WORK}/helper.orig"
cp "${WAIT_HELPER}" "${WORK}/wait.orig"
echo "=== baseline: the smoke test must be GREEN before anything is perturbed"
if ! bash "${HERE}/control_smoke_test.sh" > "${WORK}/before.log" 2>&1; then
echo "the smoke test is ALREADY RED; the red-check below would prove nothing"
cat "${WORK}/before.log"
exit 1
fi
tail -1 "${WORK}/before.log"
echo
echo "=== perturbation: remove the evidence check from both controls"
python3 - "${HERE}/split_negative_controls.sh" "${HERE}/retrace_transport_control.sh" \
"${HERE}/retrace_drop_draw_control.sh" "${WAIT_HELPER}" <<'PY' || exit 1
import sys
split, retrace, dropdraw, wait = sys.argv[1:]
# (file, [(needle, the prefix the line must start with)]). The draw-drop control has THREE
# evidence checks rather than one, because "the picture went red" has three different ways of
# being somebody else's red: the SSIM never fell, the library never said the knob armed, and the
# knob armed and dropped nothing. All three are reverted together, and the smoke cases that pin
# each of them must go red below.
#
# THE PREFIXES ARE A TUPLE, AND THAT IS A FIX, not a generalisation for its own sake. The first
# version matched only lines starting with `if ! `, and the split control's evidence check has
# been `elif ! tr -s ... | grep -qE "${evidence}"` since it was written - so this red-check found
# ZERO checks in it, the SystemExit below was not checked by the caller, and the run went on to
# smoke-test the UNPERTURBED controls and report "RED-CHECK FAILED: the controls accept an
# unrelated failure again and the smoke test still passed". It failed safe rather than green, so
# nothing was ever silently proved - but the one control it was most about was never perturbed.
# Reproduced on p5/joint@e61d0012 before this line changed.
#
# AND THE SPLIT CONTROL HAS THREE EVIDENCE CHECKS, NOT ONE. E1 checks each boundary
# probe's own observation and assertion; E3(a) checks its assertion and private log.
# All three are reverted: perturbing only one leaves the
# other two catching the smoke test's "unrelated failure" case, and the case never flips - which
# is what this script measured the first time the perturbation actually applied.
rules = [
(split, [('LINE', '"${log_helper}" assertion', ('if ! ', 'elif ! ')),
('SUBST', '"${private_evidence}" "${name}" || exit 1',
'"${private_evidence}" "${name}" || true')]),
(wait, [('SUBST', 'if not own_negative(text, observation, diagnostic):',
'if False: # E1 evidence check removed by red-check')]),
(retrace, [('LINE', 'grep -qF "${EVIDENCE}"', ('if ! ',))]),
(dropdraw, [('LINE', 'awk -v a="${ssim}"', ('if ! ',)),
('LINE', '[ -z "${armed_line}" ]', ('if ',)),
('LINE', '[ "${dropped}" -lt 1 ]', ('if ',))]),
]
for path, checks in rules:
text = open(path).read()
for rule in checks:
if rule[0] == 'SUBST':
_, old, new = rule
if text.count(old) != 1:
raise SystemExit(f"expected exactly one {old!r} in {path}, found {text.count(old)}")
text = text.replace(old, new)
continue
_, needle, prefixes = rule
out, hit = [], 0
for line in text.splitlines(keepends=True):
if needle in line and line.lstrip().startswith(prefixes):
indent = line[:len(line) - len(line.lstrip())]
# KEEP THE KEYWORD. Turning an `elif` into an `if` splits the chain in two and
# leaves the second half's `fi` dangling - a syntax error, which the smoke test
# then reports as rc=2 on every case and which looks nothing like "the control
# accepted an unrelated failure".
keyword = 'elif' if line.lstrip().startswith('elif') else 'if'
out.append(f"{indent}{keyword} false; then\n")
hit += 1
else:
out.append(line)
if hit != 1:
raise SystemExit(f"expected exactly one {needle!r} check in {path}, found {hit}")
text = ''.join(out)
open(path, 'w').write(text)
print("all seven evidence checks reverted to 'any non-zero ctest exit is accepted'")
PY
# AND THE PERTURBATION'S OWN FAILURE IS FATAL. Without this, a SystemExit above left the controls
# UNTOUCHED and the run continued to smoke-test them, printing "RED-CHECK FAILED: the controls
# accept an unrelated failure again and the smoke test still passed" - a true statement about a
# perturbation that never happened, and the wrong diagnosis to hand whoever reads it.
if [ $? -ne 0 ]; then
echo "RED-CHECK ABORTED: the perturbation did not apply, so nothing below would be measuring it."
exit 1
fi
echo
echo "=== the smoke test on the reverted controls (it MUST be red on the pinned cases)"
bash "${HERE}/control_smoke_test.sh" > "${WORK}/after.log" 2>&1
rc=$?
cat "${WORK}/after.log"
if [ "${rc}" -eq 0 ]; then
echo
echo "RED-CHECK FAILED: the controls accept an unrelated failure again and the smoke test still passed."
exit 1
fi
missed=0
grep -q "NOT OK unrelated failure with a non-empty selection" "${WORK}/after.log" || missed=1
grep -q "NOT OK red without the transport-resolution message" "${WORK}/after.log" || missed=1
grep -qF "NOT OK missing-fatal: control must report FAILED for its own reason" "${WORK}/after.log" || missed=1
for case in \
"red but the ssim is above the threshold" \
"no 'E2 control armed' line in the library log" \
"the knob armed but dropped zero records"; do
grep -q "NOT OK ${case}" "${WORK}/after.log" || { echo "still green: ${case}"; missed=1; }
done
if [ "${missed}" -ne 0 ]; then
echo
echo "RED-CHECK FAILED: the smoke test did not red every named split, retrace, draw-drop and private-log evidence case."
exit 1
fi
echo
cp "${WORK}/split.orig" "${HERE}/split_negative_controls.sh"
cp "${WORK}/retrace.orig" "${HERE}/retrace_transport_control.sh"
cp "${WORK}/dropdraw.orig" "${HERE}/retrace_drop_draw_control.sh"
cp "${WORK}/wait.orig" "${WAIT_HELPER}"
echo "=== ID-62 perturbation: remove only the skip check"
python3 - "${HELPER}" "${WAIT_HELPER}" <<'PY' || exit 1
from pathlib import Path
import sys
path = Path(sys.argv[1])
text = path.read_text()
needle = ' if skipped:\n'
if text.count(needle) != 1:
raise SystemExit('expected exactly one skip check')
path.write_text(text.replace(needle, ' if False: # skip check removed by red-check\n'))
path = Path(sys.argv[2])
text = path.read_text()
needle = ' if case.find("skipped") is not None or case.get("status") in {"notrun", "disabled"}:\n'
if text.count(needle) != 1:
raise SystemExit('expected exactly one E1 skip check')
path.write_text(text.replace(needle, ' if False: # E1 skip check removed by red-check\n'))
PY
bash "${HERE}/control_smoke_test.sh" > "${WORK}/skips.log" 2>&1
rc=$?
cat "${WORK}/skips.log"
if [ "${rc}" -eq 0 ] || ! grep -qFx 'NOT OK skipped-selection: control must report FAILED for its own reason' "${WORK}/skips.log"; then
echo 'RED-CHECK FAILED: removing the skip check must red the skipped-selection message assertion'
exit 1
fi
echo 'ID-62 red-once: removing only the skip check made skipped-selection red (smoke rc=1)'
echo "P5_T1_CONTROL_SMOKE_REDCHECK_OK - evidence and skipped-selection checks each made every named smoke case red"
-159
View File
@@ -1,159 +0,0 @@
#!/bin/bash
# MobileGL - scripts/ci/retrace_drop_draw_control.sh
# Copyright (c) 2025-2026 MobileGL-Dev
# Licensed under the GNU Lesser General Public License v3.0:
# https://www.gnu.org/licenses/gpl-3.0.txt
# https://www.gnu.org/licenses/lgpl-3.0.txt
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
# EXIT GATE E2's PICTURE CONTROL: dropping the draws on the wire must redden this retrace, and
# it must be THIS control's red.
#
# WHY THERE IS A SECOND CONTROL ON THIS LANE, and why it is the draws and not the clears.
#
# The retrace-split job already carries scripts/ci/retrace_transport_control.sh, which swaps a
# PULL library in and requires run_trace_case.cmake's transport-resolution assertion to catch it.
# That control proves the lane is running a SPLIT library. It does not prove that the PICTURE came
# through the wire, and it cannot: OpenRA scores ssim 1.000000 against a monolith library too, so
# the transport assertion is what reds it and the comparator is never consulted. BRIEF-P5 7 E2
# names a second, sharper statement - "patch an emitter to drop a record and the SSIM must fall
# below the threshold" - and until now nothing executed it.
#
# c1 shipped MOBILEGL_IPC_E2_DROP_CLEAR for exactly that, and the joint gate ran it and it DID NOT
# WORK: with the knob armed and its WARN in the library's own log, the OpenRA retrace under inproc
# still scored ssim=1.000000 with mismatchPixels=0 (joint-v1.md 3, "E2 clear-drop control - NOT
# VERIFIED"). That is not a broken knob. `apitrace dump` over the 31249 replayed calls counts 30
# glClear, 30 glXSwapBuffers and 788 glDrawArrays, and the final frame issues its clear at call
# 30197 and then covers the surface four times over with a terrain layer before the snapshot at
# 31249. A frame that overdraws every pixel it clears has a picture that does not depend on the
# clear. Measured here with the dropped-record count published: 29 Clear records dropped, ssim
# still 1.000000. The knob worked; the observable was invisible.
#
# So this control drops every DrawVbo record instead (MOBILEGL_IPC_E2_DROP_DRAW=1): the surface
# can then only carry the clear colour, and the golden is made of the geometry. Measured on the
# same head: 758 DrawVbo records dropped, ssim=0.000036, mismatchPixels=295296.
#
# WHAT THE CONTROL ASSERTS, and none of the three is the process exit code alone (R-16):
# 1. a NON-EMPTY selection, counted before the run - `--no-tests=error` turns an empty selection
# into a non-zero exit, which is how ID-46 finding 8(b) got a control to congratulate itself;
# 2. the SSIM ACTUALLY FELL: the numbers are parsed out of the retrace's own summary and
# compared, rather than "ctest was non-zero". A loader failure, a missing fixture, a timeout
# and a Fatal{ all exit non-zero and none of them is this control's red;
# 3. the LIBRARY SAID IT DROPPED SOMETHING: its own "E2 control armed ... N records dropped on
# the wire" line, with N > 0, from the log file the replay wrote. Without this, a knob that
# was never read - a stale library, a variable that did not reach the process, an emit table
# that fell through to the driver - would redden the picture for some other reason and pass.
#
# Usage: retrace_drop_draw_control.sh <case> <backend>
# CTEST ctest binary (default: ctest)
# CONTROL_TMPDIR scratch dir (default: ${RUNNER_TEMP:-/tmp})
# LIBRARY_LOG the replay's CLIENT library log (default: <case>/<backend>/output/mobilegl.client.log)
set -u
CASE="${1:?usage: retrace_drop_draw_control.sh <case> <backend>}"
BACKEND="${2:?usage: retrace_drop_draw_control.sh <case> <backend>}"
CTEST="${CTEST:-ctest}"
CONTROL_TMPDIR="${CONTROL_TMPDIR:-${RUNNER_TEMP:-/tmp}}"
# P6: BOTH ROLES HAVE THEIR OWN LOG and neither keeps the old name, so an un-updated default
# here would be a file that does not exist rather than half a session read as a whole one. This
# control's marker (`E2 control armed`) is a CLIENT-side line.
LIBRARY_LOG="${LIBRARY_LOG:-${CASE}/${BACKEND}/output/mobilegl.client.log}"
mkdir -p "${CONTROL_TMPDIR}"
FROZEN_LIBRARY="${FROZEN_LIBRARY:?FROZEN_LIBRARY must name the split library the replay loads}"
symbols=$(nm --defined-only "${FROZEN_LIBRARY}") || exit 1
remote_count=$(printf '%s\n' "${symbols}" | grep -ic MG_Remote || true)
echo "draw-drop control library: ${FROZEN_LIBRARY}: MG_Remote=${remote_count}"
if [ "${remote_count}" -lt 1 ]; then
echo '::error::draw-drop control requires a split library: MG_Remote=0'
exit 1
fi
selector="^MobileGLTraceReplay\.${CASE}\.${BACKEND}$"
# The rerun replays into the same case directory, so the good run's images are put aside and
# restored whichever way the control goes; "Upload actual image" runs `if: always()` and would
# otherwise ship the deliberately-wrong run's output under the good run's name.
GOOD_OUTPUT="${CONTROL_TMPDIR}/drop-draw-verified-output"
rm -rf "${GOOD_OUTPUT}"
if [ -d "${CASE}" ]; then cp -a "${CASE}" "${GOOD_OUTPUT}"; fi
restore_good_output() {
if [ -d "${GOOD_OUTPUT}" ]; then
rm -rf "${CASE}"; mv "${GOOD_OUTPUT}" "${CASE}"
echo "restored the verified run's output over the control's"
fi
}
trap restore_good_output EXIT
trap 'exit 130' INT TERM
matched=$("${CTEST}" -N -R "${selector}" | grep -cE '^ *Test *#[0-9]+:')
if [ "${matched}" -lt 1 ]; then
restore_good_output
echo "::error::the control selected ${matched} tests with -R '${selector}', so there is nothing for the dropped draws to redden. --no-tests=error would have exited non-zero on the empty selection and a control without this guard reads that as success (ID-46 finding 8b)."
exit 1
fi
# A PREVIOUS RUN'S LINE MUST NEVER ARM THIS ONE. The CLIENT truncates this log once at open and
# then appends (P6 made the sink O_APPEND, because under spawn a second process writes the same
# file and two truncating handles overwrite each other's bytes) - but it only truncates if the
# replay gets that far, and a run that died in the loader would leave the baseline's log in place
# with a perfectly good "control armed" line in it. Removing it first is the same rule
# split_negative_controls.sh's `reset` step follows.
rm -f "${LIBRARY_LOG}"
out="${CONTROL_TMPDIR}/retrace-drop-draw-output.txt"
# THE TRANSPORT COMES FROM THE JOB, not from this file. retrace-split is a matrix over
# {backend, case, transport} as of P6, and a control that hard-coded `inproc` would have gone on
# proving something about the OTHER arm while the spawn arm ran unguarded. Default inproc so a
# caller that sets nothing behaves exactly as before.
transport="${SPLIT_TRANSPORT:-inproc}"
export MOBILEGL_TRANSPORT="${transport}"
export MOBILEGL_IPC_E2_DROP_DRAW=1
"${CTEST}" -V --no-tests=error --timeout 10800 -R "${selector}" > "${out}" 2>&1
control_rc=$?
unset MOBILEGL_IPC_E2_DROP_DRAW
cat "${out}"
# Read the library's evidence BEFORE the good output is restored over it.
armed_line=""
dropped=0
if [ -f "${LIBRARY_LOG}" ]; then
armed_line=$(grep 'MGPipe: E2 control armed' "${LIBRARY_LOG}" | tail -1)
dropped=$(printf '%s' "${armed_line}" | sed -n 's/.*armed[^,]*, \([0-9][0-9]*\) records dropped.*/\1/p')
dropped="${dropped:-0}"
fi
cp -f "${LIBRARY_LOG}" "${CONTROL_TMPDIR}/drop-draw-library.log" 2>/dev/null
restore_good_output
if [ "${control_rc}" -eq 0 ]; then
echo "::error::the split retrace PASSED with every DrawVbo record dropped on the wire. The golden is made of that geometry, so a green here means the picture did not come from the wire: the emit table fell through to the driver, the library under test is not the one the lane thinks it is, or MOBILEGL_IPC_E2_DROP_DRAW never reached the process. Exit gate E2 is exactly this statement and nothing weaker - MOBILEGL_IPC_E2_DROP_CLEAR is NOT a substitute (measured: 29 clears dropped, ssim still 1.000000, because OpenRA overdraws every pixel it clears)."
exit 1
fi
# 2. THE SSIM ACTUALLY FELL. Parsed, not inferred from the exit code.
ssim_line=$(grep -o 'ssim=[0-9.]*, ssimThreshold=[0-9.]*' "${out}" | tail -1)
if [ -z "${ssim_line}" ]; then
echo "::error::the split retrace went red (ctest exit ${control_rc}) with the draws dropped, but its output carries no 'ssim=..., ssimThreshold=...' summary at all, so the comparator never ran. A loader failure, a missing fixture, a timeout or a Fatal{ all land here and none of them is this control's red."
exit 1
fi
ssim=${ssim_line#ssim=}; ssim=${ssim%%,*}
threshold=${ssim_line##*ssimThreshold=}
if ! awk -v a="${ssim}" -v b="${threshold}" 'BEGIN { exit !(a + 0 < b + 0) }'; then
echo "::error::the split retrace went red (ctest exit ${control_rc}) but its ${ssim_line} is NOT below the threshold, so the picture is not what reddened it. This control's whole claim is that the golden is made of the dropped geometry."
exit 1
fi
# 3. THE LIBRARY SAID IT DROPPED SOMETHING.
if [ -z "${armed_line}" ]; then
echo "::error::the split retrace went red with ssim ${ssim} < ${threshold}, but ${LIBRARY_LOG} carries no 'MGPipe: E2 control armed' line, so there is no evidence the knob was ever read by the process that produced the picture. A library that is not the one under test, an emit table that fell through to the driver, or an environment that did not reach the replay all produce a wrong picture for a reason that has nothing to do with this control."
exit 1
fi
if [ "${dropped}" -lt 1 ]; then
echo "::error::the split retrace went red with ssim ${ssim} < ${threshold} and the knob announced itself - '${armed_line}' - but it reports ZERO records dropped. The emitter was never reached, so whatever changed the picture was not this control. This is the R-16 case the dropped-record COUNT exists for: the arming message alone proves only that the knob was read."
exit 1
fi
echo "the dropped draws turned the split retrace red for their own reason (ctest exit ${control_rc}): ${matched} selected case(s), ssim ${ssim} < ${threshold}, and the library dropped ${dropped} record(s) on the wire"
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
# MobileGL - scripts/ci/retrace_split_evidence.py
# Copyright (c) 2025-2026 MobileGL-Dev
# Licensed under the GNU Lesser General Public License v3.0:
# https://www.gnu.org/licenses/gpl-3.0.txt
# https://www.gnu.org/licenses/lgpl-3.0.txt
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
"""Check the evidence file a split retrace leg leaves behind.
tools/trace_replay/run_trace_case.cmake writes output/split-evidence.txt only after every
assertion of its MOBILEGL_TRANSPORT block passed: the client log carried the transport marker
(and, under spawn, the separate server pid), a server log existed, no Fatal{ and no MGWIRE-FLOOR
line, and, on the legs that asked for it, draw records on the wire and draws on the server. The
leg's ctest exit status alone cannot tell "the assertions passed" from "the assertions never ran",
so the workflow runs this after ctest: a missing file means the transport block was skipped.
Usage: retrace_split_evidence.py FILE --transport T [--case C] [--backend B] [--require-wire]
"""
import argparse
from pathlib import Path
import sys
def parse(text):
values = {}
for line in text.splitlines():
if '=' in line:
key, value = line.split('=', 1)
values[key.strip()] = value.strip()
return values
def count(values, key):
try:
return int(values.get(key, ''))
except ValueError:
return None
def problems_in(values, transport, case=None, backend=None, require_wire=False):
problems = []
if values.get('transport') != transport:
problems.append(f"transport is '{values.get('transport')}', expected '{transport}'")
if case is not None and values.get('case') != case:
problems.append(f"case is '{values.get('case')}', expected '{case}'")
if backend is not None and values.get('backend') != backend:
problems.append(f"backend is '{values.get('backend')}', expected '{backend}'")
if not values.get('marker'):
problems.append('no transport marker recorded')
if values.get('server_log') != '1':
problems.append('no server log recorded')
for key in ('fatal_lines', 'floor_lines'):
if count(values, key) != 0:
problems.append(f"{key} is '{values.get(key)}', expected 0")
tcp = values.get('control', '-').startswith('tcp://')
if transport == 'spawn' and values.get('second', '-') == '-':
problems.append('spawn leg recorded no server-process sentence')
wire = values.get('wire') == 'ON'
if require_wire and not wire:
problems.append('wire evidence was required and the leg did not collect it')
if wire:
records = count(values, 'wire_draw_records')
if records is None or records < 1:
problems.append(f"wire_draw_records is '{values.get('wire_draw_records')}', expected at least 1")
draws = values.get('server_draws')
if not (tcp and draws == 'n/a'):
if count(values, 'server_draws') is None or count(values, 'server_draws') < 1:
problems.append(f"server_draws is '{draws}', expected at least 1")
return problems
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('evidence', type=Path)
parser.add_argument('--transport', required=True)
parser.add_argument('--case')
parser.add_argument('--backend')
parser.add_argument('--require-wire', action='store_true')
args = parser.parse_args(argv)
if not args.evidence.is_file():
print(f'::error::{args.evidence} does not exist: run_trace_case.cmake writes it only after its '
f'MOBILEGL_TRANSPORT assertions passed, so on this leg they did not run or did not finish')
return 1
values = parse(args.evidence.read_text(errors='replace'))
problems = problems_in(values, args.transport, args.case, args.backend, args.require_wire)
if problems:
print(f'::error::{args.evidence}: ' + '; '.join(problems))
return 1
print(f"split evidence: {values.get('case')} {values.get('backend')} transport={values.get('transport')} "
f"second='{values.get('second')}' wire={values.get('wire')} "
f"wire_draw_records={values.get('wire_draw_records')} server_draws={values.get('server_draws')}")
return 0
if __name__ == '__main__':
sys.exit(main())
-179
View File
@@ -1,179 +0,0 @@
#!/bin/bash
# MobileGL - scripts/ci/retrace_transport_control.sh
# Copyright (c) 2025-2026 MobileGL-Dev
# Licensed under the GNU Lesser General Public License v3.0:
# https://www.gnu.org/licenses/gpl-3.0.txt
# https://www.gnu.org/licenses/lgpl-3.0.txt
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
# THE RETRACE-SPLIT LANE'S NEGATIVE CONTROL: a library WITHOUT A TRANSPORT must red this split retrace.
#
# P13 W2: the library is the push monolith without MG_Remote (the shape FCL embeds). Until W2 it was
# the pull build, and this file was retrace_pull_library_control.sh; both libraries were run side by
# side for one wave and both went red here for the same sentence. "pull" below is history: what the
# control needs is a library with no MG_Remote, and the push monolith is one.
#
# This file is the body of .github/workflows/test.yml's "Negative control (library without a transport)" step in
# the retrace-split job, extracted for the reason given at the top of
# scripts/ci/split_negative_controls.sh: a `run:` block is unreviewable and untestable off a
# runner, and scripts/ci/control_smoke_test.sh now runs THIS file rather than a hand-made copy.
#
# WHAT THE CONTROL IS FOR. The retrace-split job replays a trace against the SPLIT runtime under
# MOBILEGL_TRANSPORT=inproc. OpenRA scores ssim 1.000000 against a MONOLITH library too - measured -
# so the picture is not and cannot be this lane's gate. What stands between the job and a green that
# ran monolith end to end is run_trace_case.cmake's transport-resolution assertion
# (run_trace_case.cmake:265-289): the library must have logged
# "MOBILEGL_TRANSPORT=inproc - the MGPipe record stream", which exists only in
# ConfigLoader::InitTransport's InProcess arm, which exists only under MOBILEGL_BUILD_DISAGGREGATED.
# This control swaps the pull library over the frozen path and requires the same replay to fail FOR
# THAT REASON.
#
# WHAT THE REVIEW FOUND (ID-46 finding 8 part (b), CONFIRMED by execution against the REAL ctest in
# a REAL build tree; ID-48 assigns it here). Two holes, both of which let the control pass while
# asserting nothing:
#
# 1. NO SELECTION GUARD AT ALL - unlike the split lane's run_control, which has had one since
# review finding M-4. With a case/backend regex matching no tests, `--no-tests=error` exits 8,
# and the old `if [ "${control_rc}" -eq 0 ]` accepted 8 as "the pull library turned it red".
# The verifier measured exactly that: "real ctest exit for a regex matching NO tests: 8",
# HARNESS_EXIT=0. An empty selection was the one thing this control could not tell apart from
# a working transport-identity assertion.
# 2. ONLY "non-zero ctest" WAS CHECKED after the nm identity check. The nm check establishes that
# the library IS a pull build; it says nothing about why the replay failed. A loader failure, a
# missing fixture or a timeout all passed the control.
#
# Both are closed below: the selection is counted before the run, and the red must carry
# run_trace_case.cmake's own words.
#
# Usage: retrace_transport_control.sh <case> <backend>
# CTEST ctest binary (default: ctest)
# CONTROL_TMPDIR scratch dir (default: ${RUNNER_TEMP:-/tmp})
# CONTROL_LIBRARY the libMobileGL.so without MG_Remote to swap in
# FROZEN_LIBRARY the path every case has baked in, which CONTROL_LIBRARY is copied over
set -u
CASE="${1:?usage: retrace_transport_control.sh <case> <backend>}"
BACKEND="${2:?usage: retrace_transport_control.sh <case> <backend>}"
CTEST="${CTEST:-ctest}"
CONTROL_TMPDIR="${CONTROL_TMPDIR:-${RUNNER_TEMP:-/tmp}}"
CONTROL_LIBRARY="${CONTROL_LIBRARY:?CONTROL_LIBRARY must name a libMobileGL.so built without MG_Remote}"
FROZEN_LIBRARY="${FROZEN_LIBRARY:?FROZEN_LIBRARY must name the path the cases have baked in}"
mkdir -p "${CONTROL_TMPDIR}"
# run_trace_case.cmake's own sentences for "this library never resolved the transport", one per
# line (grep -F takes its patterns newline-separated). Anchored on the distinctive clause rather
# than on the whole paragraph, which carries substituted paths.
#
# TWO SENTENCES SINCE P6's LOG RENAME, and the second is the one a pull library actually gets.
# A pull build has one log role and writes MOBILEGL_LOG_FILE_PATH unchanged - output/mobilegl.log
# (MG_Util/Debug/Log.h, the pull arm of RoleLogPath) - while the disaggregated build's readers,
# run_trace_case.cmake among them, read output/mobilegl.client.log. So the pull library never
# reaches the marker search that says "never reported resolving it": the runner stops one check
# earlier, at "the run wrote no <client log>, so there is no evidence the transport ever
# resolved". Both are the transport-identity assertion refusing the same library for the same
# reason (a pull build has no split log for the marker to be in); neither is a loader failure, a
# missing fixture, a timeout or an SSIM drop, which are the reds hole 2 below exists to refuse.
# MEASURED on the B3 package tree with the real ctest and the real pull library: with only the
# first sentence here, the control exited 1 through hole 2 on every pull-library run - the step
# had been red for no product reason since the rename. scripts/ci/testdata/stub_ctest.sh's
# retrace-evidence-nolog mode reproduces the shape.
EVIDENCE='never reported resolving it
no evidence the transport ever resolved'
# EVERY LINE OF EVIDENCE IS A PATTERN, SO AN EMPTY LINE IS A PATTERN THAT MATCHES EVERYTHING. A
# trailing newline, or a blank line between the two sentences, would turn hole 2's check below
# into "any red will do". The smoke test's "red without the transport-resolution message" case
# would catch that (22/23), but only once somebody ran it; this refuses it here, before the
# selection is counted or the library is swapped.
case "${EVIDENCE}" in
*$'\n\n'*|*$'\n'|$'\n'*)
echo "::error::EVIDENCE carries an empty grep -F pattern (a blank line or a leading/trailing newline), which would match every failure"
exit 1;;
esac
selector="^MobileGLTraceReplay\.${CASE}\.${BACKEND}$"
# The rerun replays into the same case directory, so the good run's images are put aside and
# restored whichever way the control goes; "Upload actual image" runs `if: always()` and would
# otherwise ship the deliberately-wrong run's output under the good run's name.
GOOD_OUTPUT="${CONTROL_TMPDIR}/split-verified-output"
rm -rf "${GOOD_OUTPUT}"
if [ -d "${CASE}" ]; then cp -a "${CASE}" "${GOOD_OUTPUT}"; fi
restore_good_output() {
if [ -d "${GOOD_OUTPUT}" ]; then
rm -rf "${CASE}"; mv "${GOOD_OUTPUT}" "${CASE}"
echo "restored the verified run's output over the control's"
fi
}
# Restore the exact split library on success, failure, and interruption. The following
# draw-drop control uses this same frozen path.
saved_library=$(mktemp "${CONTROL_TMPDIR}/split-library.XXXXXX") || exit 1
cp -p "${FROZEN_LIBRARY}" "${saved_library}" || exit 1
restore_control() {
cp -p "${saved_library}" "${FROZEN_LIBRARY}"
rm -f "${saved_library}"
restore_good_output
}
trap restore_control EXIT
trap 'exit 130' INT TERM
# HOLE 1: COUNT THE SELECTION FIRST. `--no-tests=error` turns an empty selection into a non-zero
# exit, which is indistinguishable from a working control unless the selection is counted.
matched=$("${CTEST}" -N -R "${selector}" | grep -cE '^ *Test *#[0-9]+:')
if [ "${matched}" -lt 1 ]; then
restore_good_output
echo "::error::the control selected ${matched} tests with -R '${selector}', so there is nothing for the transport-less library to red. --no-tests=error would have exited non-zero on the empty selection and this control used to read that as success (ID-46 finding 8b, measured: ctest exit 8, step green)."
exit 1
fi
# The pull library, unpacked from build-linux's artifact, over the frozen path every case has baked
# in. It defines no MG_Remote symbol, so ConfigLoader has no transport parser and
# MOBILEGL_TRANSPORT=inproc is accepted and ignored - the exact shape of "the split lane ran
# monolith".
cp "${CONTROL_LIBRARY}" "${FROZEN_LIBRARY}" || exit 1
symbols=$(nm --defined-only "${FROZEN_LIBRARY}") || exit 1
remote_count=$(printf '%s\n' "${symbols}" | grep -ic MG_Remote || true)
echo "transport control library: ${FROZEN_LIBRARY}: MG_Remote=${remote_count}"
if [ "${remote_count}" -ne 0 ]; then
restore_good_output
echo "::error::the control's own library defines MG_Remote symbols, so it has a transport and this control would prove nothing"
exit 1
fi
out="${CONTROL_TMPDIR}/retrace-control-output.txt"
# THE TRANSPORT COMES FROM THE JOB, not from this file. retrace-split is a matrix over
# {backend, case, transport} as of P6, and a control that hard-coded `inproc` would have gone on
# proving something about the OTHER arm while the spawn arm ran unguarded. Default inproc so a
# caller that sets nothing behaves exactly as before.
transport="${SPLIT_TRANSPORT:-inproc}"
export MOBILEGL_TRANSPORT="${transport}"
"${CTEST}" -V --no-tests=error --timeout 10800 -R "${selector}" > "${out}" 2>&1
control_rc=$?
cat "${out}"
restore_good_output
if [ "${control_rc}" -eq 0 ]; then
echo "::error::a library WITHOUT A TRANSPORT passed the split retrace. OpenRA scores ssim 1.000000 under a monolith library too (measured), so the picture is not and cannot be this lane's gate - run_trace_case.cmake's transport-resolution assertion is, and it has stopped working. Every green in this job is then a monolith run under a name that says split."
exit 1
fi
# HOLE 2: THE RED MUST BE THIS CONTROL'S RED.
#
# Whitespace is normalised across the WHOLE file before the match, newlines included, because the
# sentence is emitted by CMake's message(FATAL_ERROR ...) and CMake re-wraps that text to its own
# width: "never reported resolving it" arrives split over two lines with a two-space continuation
# indent, and a line-oriented grep for the literal finds nothing. That is not hypothetical - it is
# the shape the stub reproduces in scripts/ci/testdata/stub_ctest.sh.
# `ctest -V` ALSO PREFIXES EVERY OUTPUT LINE WITH "<test number>: ", continuation lines included,
# so without stripping it first the folded text reads "never 1: reported resolving it" and the
# control reds on the very sentence it is looking for (the *-prefixed stub modes).
if ! sed -E 's/^[0-9]+: //' "${out}" | tr -s '[:space:]' ' ' | grep -qF "${EVIDENCE}"; then
echo "::error::the split retrace went red (ctest exit ${control_rc}) with the transport-less library in place, but the failure never says the library did not resolve the transport - neither of run_trace_case.cmake's sentences (\"${EVIDENCE//$'\n'/\" / \"}\") is in the output. A loader failure, a missing fixture, a timeout or an SSIM drop all land here, and none of them establishes that the transport-identity assertion is what caught the pull library. Only 'non-zero ctest' used to be checked (ID-46 finding 8b)."
exit 1
fi
echo "the transport-less library turned the split retrace red for its own reason (ctest exit ${control_rc}): ${matched} selected case(s) named the transport, not the picture"
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
# MobileGL - scripts/ci/retrace_verify_reason.py
# Copyright (c) 2025-2026 MobileGL-Dev
# Licensed under the GNU Lesser General Public License v3.0:
# https://www.gnu.org/licenses/gpl-3.0.txt
# https://www.gnu.org/licenses/lgpl-3.0.txt
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
"""Whether retrace-verify's corrupted-snapshot control went red for its own reason.
The control replays OpenRA with MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters and must go
red with the comparator's divergence on that field: a red without it (another Fatal, a divergence
on another field) proves nothing about the comparator. The check requires that divergence in the
transcript; it does not rule out other failures beside it. run_trace_case.cmake prints the library
log through message(STATUS), which never re-wraps, so each log line stays one transcript line.
Usage: retrace_verify_reason.py TRANSCRIPT (the control run's ctest -V output)
Exit 0 when the divergence is there, 1 when it is not, 2 when the transcript cannot be read.
"""
import sys
REASON = 'Fatal{PipeVerifyDiffer, "GetRenderStateParameters@'
def carries_reason(text):
return REASON in text
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
if len(argv) != 1:
print(__doc__)
return 2
try:
text = open(argv[0], encoding='utf-8', errors='replace').read()
except OSError as error:
print(f'::error::retrace_verify_reason: cannot read {argv[0]}: {error}')
return 2
if carries_reason(text):
print(f'retrace_verify_reason: the red carries {REASON}...')
return 0
print(f'::error::the corrupted-snapshot retrace went red without {REASON}...: '
'something other than the comparator failed it')
return 1
if __name__ == '__main__':
sys.exit(main())
+122 -5
View File
@@ -21,13 +21,20 @@ into the tree (scripts/data/skip_census_baseline.json):
the lane is missing entirely, which is reported on its own line;
* a skip that turned into a pass is reported, never red - rewrite the baseline in the commit
that earns it so the improvement is locked in;
* new lanes and new names are reported, never red.
* new lanes and new names are reported, never red;
* a lane whose row carries "optional": "<reason>" runs only on some paths (for example only when
a build-equality guard falls back); its absence is a note, and when it runs it is checked
like any other lane;
* an optional row that also carries "instead_of": "<lane>" is that lane's whole-run fallback:
when it ran, the lane it stands in for may be absent (a note), and is required otherwise;
* `check --require LANE` names an optional lane that the run took the path for, so its
absence is red in that run.
A deliberate retirement edits the baseline in the same commit and names the cases in the commit
message (PLAN-P13 §4.2).
Usage:
skip_census.py check <junit-dir> --baseline <json> [--report <json>]
skip_census.py check <junit-dir> --baseline <json> [--report <json>] [--require LANE]...
skip_census.py write <junit-dir> --baseline <json>
skip_census.py --self-test
<junit-dir> holds one sub-directory per lane (actions/download-artifact with a pattern lays
@@ -110,14 +117,54 @@ def resolve_baseline(baseline):
names = row['names'] if 'names' in row else baseline[row['names_from']]['names']
resolved[lane] = {'names': sorted({normalize_name(n) for n in names}),
'skipped': sorted({normalize_name(n) for n in row['skipped']})}
for key in ('optional', 'instead_of'):
if row.get(key):
resolved[lane][key] = row[key]
return resolved
def compare(lanes, baseline):
def keep_optional_rows(written, previous):
"""`write` from a run that did not run an optional lane keeps that lane's row, with its names
resolved, and every optional row keeps its reason. A run where an `instead_of` row stood in
did not run the lane it replaces, so that lane keeps its previous row too."""
resolved = resolve_baseline(previous)
def keep(lane):
written[lane] = {'skipped': resolved[lane]['skipped'], 'names': resolved[lane]['names']}
for lane, row in previous.items():
if not row.get('optional'):
continue
if lane not in written:
keep(lane)
for key in ('optional', 'instead_of'):
if row.get(key):
written[lane][key] = row[key]
for lane, row in list(written.items()):
stood_in = row.get('instead_of')
if stood_in and stood_in not in written and stood_in in previous:
keep(stood_in)
return dict(sorted(written.items()))
def compare(lanes, baseline, required=()):
red, info = [], []
for lane in sorted(set(required) - set(baseline)):
red.append(f'lane {lane}: required by this run, but the baseline has no such row')
stood_in = {row['instead_of']: lane for lane, row in baseline.items()
if row.get('optional') and row.get('instead_of') and lane in lanes}
for lane, known in sorted(baseline.items()):
current = lanes.get(lane)
if current is None:
if lane in stood_in:
info.append(f'lane {lane}: not run ({stood_in[lane]} ran instead)')
continue
if known.get('optional') and lane not in required:
info.append(f"lane {lane}: not run (optional: {known['optional']})")
continue
if known.get('optional'):
red.append(f"lane {lane}: no JUnit in this run, which took the path that runs it "
f"({known['optional']})")
continue
red.append(f'lane {lane}: no JUnit at all in this run (the job did not run or did not upload)')
continue
allowed = set(known['skipped'])
@@ -149,6 +196,8 @@ def run(argv):
parser.add_argument('junit_dir')
parser.add_argument('--baseline', required=True, type=pathlib.Path)
parser.add_argument('--report', type=pathlib.Path)
parser.add_argument('--require', action='append', default=[], metavar='LANE',
help='check: an optional lane this run took the path for, so it must be present')
args = parser.parse_args(argv)
lanes = collect(args.junit_dir)
if not lanes:
@@ -158,14 +207,17 @@ def run(argv):
if args.report:
args.report.write_text(json.dumps(to_baseline(lanes), indent=1) + '\n')
if args.command == 'write':
written = to_baseline(lanes)
if args.baseline.exists():
written = keep_optional_rows(written, json.loads(args.baseline.read_text()))
args.baseline.parent.mkdir(parents=True, exist_ok=True)
args.baseline.write_text(json.dumps(to_baseline(lanes), indent=1) + '\n')
args.baseline.write_text(json.dumps(written, indent=1) + '\n')
print(f'wrote {args.baseline}')
return 0
if not args.baseline.exists():
print(f'::error::skip census: no baseline at {args.baseline}; write one from a full run')
return 1
red, info = compare(lanes, resolve_baseline(json.loads(args.baseline.read_text())))
red, info = compare(lanes, resolve_baseline(json.loads(args.baseline.read_text())), args.require)
for line in info:
print(f'note: {line}')
for line in red:
@@ -215,6 +267,71 @@ def self_test():
junit(lost / 'junit-other' / 'other.xml', [('A', 'pass')])
assert quiet(['check', str(lost), '--baseline', str(base)]) == 1, 'a lane that vanished must be red'
assert quiet(['check', str(tmp / 'empty'), '--baseline', str(base)]) == 1, 'no JUnit must be red'
# An optional lane: absent is a note, present is checked, and a plain lane stays required.
optional = tmp / 'optional.json'
rows = json.loads(base.read_text())
rows['junit-other/other'] = {'skipped': [], 'names': ['A'], 'optional': 'runs on the fallback only'}
optional.write_text(json.dumps(rows))
assert quiet(['check', str(good), '--baseline', str(optional)]) == 0, 'an absent optional lane is a note'
both = tmp / 'both'
junit(both / 'junit-unit' / 'unit.xml', [('A', 'pass'), ('B', 'skip'), ('C', 'pass')])
junit(both / 'junit-other' / 'other.xml', [('A', 'skip')])
assert quiet(['check', str(both), '--baseline', str(optional)]) == 1, 'a present optional lane is checked'
assert quiet(['check', str(lost), '--baseline', str(optional)]) == 1, 'a plain lane is still required'
assert quiet(['check', str(good), '--baseline', str(optional), '--require', 'junit-other/other']) == 1, \
'an optional lane the run took the path for is required'
present = tmp / 'present'
junit(present / 'junit-unit' / 'unit.xml', [('A', 'pass'), ('B', 'skip'), ('C', 'pass')])
junit(present / 'junit-other' / 'other.xml', [('A', 'pass')])
assert quiet(['check', str(present), '--baseline', str(optional), '--require', 'junit-other/other']) == 0, \
'a required optional lane that ran is checked like any other'
assert quiet(['check', str(good), '--baseline', str(optional), '--require', 'junit-no/such']) == 1, \
'a required lane the baseline does not know is red'
kept = keep_optional_rows(to_baseline(collect(good)), rows)
assert kept['junit-other/other'] == rows['junit-other/other'], 'write keeps an absent optional row'
# A whole-run fallback lane stands in for a trimmed one: the leg ran everything under its
# own stem, so the trimmed lane is absent and the full one is held to the full names.
trimmed = tmp / 'trimmed.json'
rows = json.loads(base.read_text())
rows['junit-leg/full'] = {'skipped': ['B'], 'names_from': 'junit-unit/unit'}
rows['junit-leg/trimmed'] = {'skipped': [], 'names': ['A']}
rows['junit-leg/trimmed-all'] = {'skipped': ['B'], 'names_from': 'junit-unit/unit',
'optional': 'the fallback', 'instead_of': 'junit-leg/trimmed'}
trimmed.write_text(json.dumps(rows))
def leg(name, trimmed_cases=None, all_cases=None):
d = tmp / name
junit(d / 'junit-unit' / 'unit.xml', [('A', 'pass'), ('B', 'skip'), ('C', 'pass')])
junit(d / 'junit-leg' / 'full.xml', [('A', 'pass'), ('B', 'skip'), ('C', 'pass')])
if trimmed_cases is not None:
junit(d / 'junit-leg' / 'trimmed.xml', trimmed_cases)
if all_cases is not None:
junit(d / 'junit-leg' / 'trimmed-all.xml', all_cases)
return str(d)
full_run = [('A', 'pass'), ('B', 'skip'), ('C', 'pass')]
assert quiet(['check', leg('trim-ok', [('A', 'pass')]), '--baseline', str(trimmed)]) == 0, \
'the trimmed lane alone is green'
assert quiet(['check', leg('fallback-ok', None, full_run), '--baseline', str(trimmed)]) == 0, \
'the fallback lane in place of the trimmed one is green'
assert quiet(['check', leg('fallback-lost', None, full_run[:2]), '--baseline', str(trimmed)]) == 1, \
'a name that disappeared from the fallback lane is red'
assert quiet(['check', leg('fallback-skip', None, [('A', 'skip')] + full_run[1:]),
'--baseline', str(trimmed)]) == 1, 'a new skip in the fallback lane is red'
assert quiet(['check', leg('neither'), '--baseline', str(trimmed)]) == 1, \
'with neither lane the trimmed one is still required'
assert keep_optional_rows({}, rows)['junit-leg/trimmed-all']['instead_of'] == 'junit-leg/trimmed', \
'write keeps instead_of'
rewritten = tmp / 'rewritten.json'
rewritten.write_text(trimmed.read_text())
assert quiet(['write', leg('rewrite-fallback', None, full_run), '--baseline', str(rewritten)]) == 0
kept = json.loads(rewritten.read_text())
assert kept.get('junit-leg/trimmed') == {'skipped': [], 'names': ['A']}, \
'write from a fallback run keeps the row of the lane the fallback stood in for'
assert kept['junit-leg/trimmed-all']['instead_of'] == 'junit-leg/trimmed'
assert quiet(['check', leg('rewrite-trim-ok', [('A', 'pass')]), '--baseline', str(rewritten)]) == 0, \
'the rewritten baseline still passes a normal run'
assert quiet(['check', leg('rewrite-trim-lost', [('B', 'skip')]), '--baseline', str(rewritten)]) == 1, \
'the rewritten baseline still reds a name gone from the trimmed lane'
assert quiet(['check', str(good), '--baseline', str(tmp / 'none.json')]) == 1, 'no baseline must be red'
assert normalize_name('S/T.Case/Leaf # GetParam() = 64-byte object <46-CA 08-EB>') == 'S/T.Case/Leaf'
assert normalize_name('T.Plain') == 'T.Plain'
+32 -16
View File
@@ -9,14 +9,9 @@
# EXIT GATE E1's NEGATIVE CONTROL and EXIT GATE E3(a)'s.
#
# This file is the body of .github/workflows/test.yml's "Negative controls (barrier, map push)"
# step, which requires the verb barrier and the persistent-map push to be load-bearing. It lives in
# the repository rather than inline in the workflow for one reason: a workflow `run:` block cannot
# be executed anywhere except on a runner, so the logic below was unreviewable and untestable until
# it ran in CI - and when the wave-1 cross-family review claimed it was broken, confirming the
# claim needed a hand-made copy of these lines with their inputs stubbed (wave1-codex-verify.md 8).
# A copy is not the thing. The smoke test at scripts/ci/control_smoke_test.sh now runs THIS file,
# so the lines CI executes and the lines the smoke test proves are the same lines.
# The body of test.yml's "Negative controls (barrier, map push)" step, which requires the verb
# barrier and the persistent-map push to be load-bearing. It is a file, not an inline `run:` block,
# so scripts/ci/control_smoke_test.sh runs these same lines off a runner against a stubbed ctest.
#
# WHAT THE REVIEW FOUND (ID-46 finding 8, CONFIRMED by execution; ID-48 assigns it here).
# The previous version accepted ANY non-zero ctest exit as "the knob is load-bearing". A timeout, a
@@ -37,7 +32,7 @@
# carries none of them.
#
# WHY THE EVIDENCE IS THE SCENARIO'S ASSERTION TEXT AND NOT THE KNOB'S OWN LOG LINE.
# ConfigLoader logs a named line for both knobs (ConfigLoader.cpp:385-393, "is the R-1 NEGATIVE
# ConfigLoader logs a named line for both knobs ("is the R-1 NEGATIVE
# CONTROL", "is the E3(a) NEGATIVE CONTROL"), and it is tempting to grep for that. It is not
# evidence: it is written at config load, by every process in the run, whatever happens next. A
# setup abort would carry it too. It proves the knob was READ, never that the knob caused the red.
@@ -47,8 +42,11 @@
# overlap Fatal cannot fire with the default BATCH_WAITS=1. Pixel failures are not E1 evidence.
#
# Usage: split_negative_controls.sh [--self-test]
# CTEST ctest binary (default: ctest)
# CONTROL_TMPDIR scratch dir for the junit + output (default: ${RUNNER_TEMP:-/tmp})
# CTEST ctest binary (default: ctest)
# CONTROL_TMPDIR scratch dir for the junit + output (default: ${RUNNER_TEMP:-/tmp})
# SPLIT_BASELINE_JUNIT the JUnit an earlier `ctest -L integration-split` wrote; its exit code is
# read from the file of the same name ending in .rc. When both exist the
# baseline is tallied from them instead of re-run.
set -u
if [ "${1:-}" = "--self-test" ]; then
@@ -60,8 +58,6 @@ CTEST="${CTEST:-ctest}"
CONTROL_TMPDIR="${CONTROL_TMPDIR:-${RUNNER_TEMP:-/tmp}}"
mkdir -p "${CONTROL_TMPDIR}"
junit="${CONTROL_TMPDIR}/isplit.xml"
rm -f "${junit}"
log_helper="$(dirname "$0")/../../MobileGL/MG_IntegrationTest/Harness/split_log_paths.py"
# Check ownership even while the runtime lane is disarmed and will skip.
@@ -76,15 +72,35 @@ python3 "${log_helper}" check "${CTEST}" "$PWD" || exit 1
# ImplementedVerbCount(), read by Harness/SplitRuntimePeek), so the only honest way to ask it from a
# shell is to look at what the entries DID.
# The metadata ownership test shares the label but cannot arm runtime controls.
"${CTEST}" -L integration-split -R '^DirectGLES\.Split\.' -j 4 --no-tests=error --output-junit "${junit}"
baseline_rc=$?
#
# The lane step before this one already ran every integration-split entry on this runner and
# library, and left its JUnit and exit code. Both are read when they exist; only the
# DirectGLES.Split. entries count, as in the rerun. The rerun stays for a job whose lane step
# never ran (an earlier gate failed), where this baseline is the only run of those entries.
baseline_rc_file="${SPLIT_BASELINE_JUNIT:-}"
baseline_rc_file="${baseline_rc_file%.xml}.rc"
if [ -n "${SPLIT_BASELINE_JUNIT:-}" ] && [ -f "${SPLIT_BASELINE_JUNIT}" ] && [ -f "${baseline_rc_file}" ]; then
junit="${SPLIT_BASELINE_JUNIT}"
baseline_rc=$(tr -dc '0-9' < "${baseline_rc_file}")
baseline_rc="${baseline_rc:-1}"
echo "baseline tallied from ${junit} (the lane step's own run, ctest exit ${baseline_rc}); no rerun"
else
# A directory of its own: the job uploads CONTROL_TMPDIR/*.xml for the skip census, which has
# no row for this rerun.
mkdir -p "${CONTROL_TMPDIR}/split-controls"
junit="${CONTROL_TMPDIR}/split-controls/isplit.xml"
rm -f "${junit}"
echo "::notice::no baseline JUnit and exit code from the lane step (SPLIT_BASELINE_JUNIT='${SPLIT_BASELINE_JUNIT:-}'), so the controls reran the DirectGLES.Split. baseline"
"${CTEST}" -L integration-split -R '^DirectGLES\.Split\.' -j 4 --no-tests=error --output-junit "${junit}"
baseline_rc=$?
fi
if [ ! -f "${junit}" ]; then
echo "::error::the baseline run wrote no ${junit} (ctest exit ${baseline_rc}), so nothing below can tell an armed lane from a broken one"
exit 1
fi
tally=$(python3 "$(dirname "$0")/junit_tally.py" "${junit}")
tally=$(python3 "$(dirname "$0")/junit_tally.py" "${junit}" --require-split-ran)
if [ -z "${tally}" ]; then
echo "::error::could not tally ${junit}; a run whose result cannot be read is not an arming signal"
exit 1
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# MobileGL - scripts/ci/split_reuse_build_type.sh
# Copyright (c) 2025-2026 MobileGL-Dev
# Licensed under the GNU Lesser General Public License v3.0:
# https://www.gnu.org/licenses/gpl-3.0.txt
# https://www.gnu.org/licenses/lgpl-3.0.txt
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
# The split consumers' build-type check. A consumer that reuses build-linux's build (SPLIT_REUSE is
# true: build-guard found the two configurations identical) takes it only when build-linux built
# Release, the only type build-guard can find identical to the split line. A re-run of failed jobs
# with debug logging rebuilds build-linux as Debug but keeps build-guard's verdict, while
# build-linux-split built nothing, so the consumer stops here and says so.
#
# usage: split_reuse_build_type.sh (SPLIT_REUSE and PRIMARY_BUILD_TYPE from the job)
# split_reuse_build_type.sh --self-test
set -euo pipefail
if [ "${1:-}" = --self-test ]; then
failures=0
expect() {
local want="$1" reuse="$2" type="$3" got=0
SPLIT_REUSE="${reuse}" PRIMARY_BUILD_TYPE="${type}" bash "$0" > /dev/null || got=$?
if [ "${got}" -ne "${want}" ]; then
echo "NOT OK reuse=${reuse} build type '${type}': exit ${got}, expected ${want}"
failures=$((failures + 1))
fi
}
expect 0 true Release
expect 1 true Debug
expect 1 true ''
expect 0 false Debug
expect 0 '' ''
if [ "${failures}" -ne 0 ]; then
echo "split_reuse_build_type self-test: FAILED"
exit 1
fi
echo "split_reuse_build_type self-test: ok"
exit 0
fi
if [ "${SPLIT_REUSE:-false}" = true ] && [ "${PRIMARY_BUILD_TYPE:-}" != Release ]; then
echo "::error::build-guard found the two configurations identical as Release builds, but build-linux built '${PRIMARY_BUILD_TYPE:-an unknown type}' (a re-run of failed jobs with debug logging keeps the guard's verdict) and build-linux-split built nothing; re-run all jobs"
exit 1
fi
+22
View File
@@ -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'<testsuite>{body}</testsuite>')
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('<testcase name="A"/><testcase name="B"><skipped/></testcase><testcase name="C"/>')
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('<testcase name="A"/><testcase name="C"/>')[0], 3, 'a missing name')
self.assertEqual(run('<testcase name="A"/><testcase name="A"/><testcase name="B"/>')[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'
-158
View File
@@ -1,158 +0,0 @@
#!/usr/bin/env python3
# MobileGL - scripts/ci/tcp_log_forwarding_control.py
# Copyright (c) 2025-2026 MobileGL-Dev
# Licensed under the GNU Lesser General Public License v3.0:
# https://www.gnu.org/licenses/gpl-3.0.txt
# https://www.gnu.org/licenses/lgpl-3.0.txt
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
"""Run the four real arming scenarios with server log forwarding enabled and disabled."""
import argparse
import errno
import json
import os
from pathlib import Path
import re
import signal
import socket
import subprocess
import time
import xml.etree.ElementTree as ET
CASES = {
'unlocated': ('DirectGLES', 'UnlocatedIoBlockScenario.TheEmulationIsActuallyArmedWhenTheEnvironmentPinsItOn',
{'MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS': '1'}, 'WITHOUT their layout(location) qualifier'),
'primitives': ('DirectVulkan', 'PrimitivesGeneratedNoXfbScenario.TheRerouteIsActuallyArmedWhenTheEnvironmentPinsItOn',
{'MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE': '1'}, 'PRIMITIVES_GENERATED reroute engaged'),
'point-size': ('DirectGLES', 'PointSizeDemotionScenario.TheDemotionIsActuallyArmedWhenTheEnvironmentPinsItOn',
{'MOBILEGL_POINT_SIZE_DEMOTION': '1'}, 'demoted to an ordinary varying'),
'verify': ('DirectGLES', 'PipeVerifyArmingScenario.Armed',
{'MOBILEGL_PIPE_VERIFY': '1', 'MGITEST_PIPE_ARMING_LANE': '1'}, 'MGPipe: verify armed'),
}
def read(path):
return path.read_text(errors='replace') if path.exists() else ''
def run_arm(build, out, case, forward):
backend, test, knobs, marker = CASES[case]
out.mkdir(parents=True, exist_ok=True)
with socket.socket() as reserve:
reserve.bind(('127.0.0.1', 0))
port = reserve.getsockname()[1]
endpoint = f'tcp://127.0.0.1:{port}'
env = dict(os.environ, MOBILEGL_TRANSPORT='spawn', MOBILEGL_BACKEND_TYPE=backend,
MOBILEGL_IPC_REQUIRE_SAME_BUILD='1', MOBILEGL_IPC_TOKEN='', EGL_PLATFORM='surfaceless',
LIBGL_ALWAYS_SOFTWARE='1', MOBILEGL_ITEST_REQUIRE_GPU='1', **knobs)
for name in ('MOBILEGL_PIPE_VERIFY_CORRUPT', 'MOBILEGL_IPC_CONTROL', 'MOBILEGL_IPC_DIAL'):
env.pop(name, None)
if Path('/usr/share/glvnd/egl_vendor.d/50_mesa.json').exists():
env['__EGL_VENDOR_LIBRARY_FILENAMES'] = '/usr/share/glvnd/egl_vendor.d/50_mesa.json'
if Path('/usr/share/vulkan/icd.d/lvp_icd.json').exists():
env.setdefault('VK_ICD_FILENAMES', '/usr/share/vulkan/icd.d/lvp_icd.json')
peer_env = dict(env, MOBILEGL_IPC_ROLE='server', MOBILEGL_IPC_DIAL='no',
MOBILEGL_IPC_LOG_FORWARD=str(int(forward)), MOBILEGL_LOG_FILE_PATH=str(out / 'peer.log'))
for name in ('MOBILEGL_TRANSPORT', 'MOBILEGL_IPC_SERVER_PATH', 'MOBILEGL_IPC_RING_MB', 'MOBILEGL_IPC_STAGE_MB'):
peer_env.pop(name, None)
with (out / 'supervisor.log').open('wb') as output:
server = subprocess.Popen([str(build / 'libMobileGLServer.so'), endpoint, '--serve'],
env=peer_env, stdout=output, stderr=output, start_new_session=True)
try:
for _ in range(100):
if server.poll() is not None:
raise RuntimeError('supervisor exited before listening: ' + read(out / 'supervisor.log'))
with socket.socket() as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind(('127.0.0.1', port))
except OSError as error:
if error.errno == errno.EADDRINUSE:
break
raise
time.sleep(.05)
else:
raise RuntimeError('supervisor did not listen')
client_env = dict(env, MOBILEGL_IPC_CONTROL=endpoint, MOBILEGL_IPC_DATA='stream',
MOBILEGL_IPC_LOG_FORWARD='1', MOBILEGL_LOG_FILE_PATH=str(out / 'client.log'),
MGITEST_SPLIT_LANE='1', MGITEST_TCP_LANE='1')
result = subprocess.run([str(build / 'MobileGL/MG_IntegrationTest/MobileGLIntegrationTest'),
'--gtest_filter=' + test, '--gtest_output=xml:' + str(out / 'result.xml')],
env=client_env, capture_output=True, text=True, timeout=120)
(out / 'stdout.log').write_text(result.stdout + result.stderr)
finally:
try:
os.killpg(server.pid, signal.SIGTERM)
except ProcessLookupError:
pass
server.wait(timeout=10)
passed = failed = skipped = 0
marker_assertion_failed = False
if (out / 'result.xml').exists():
for entry in ET.parse(out / 'result.xml').getroot().iter('testcase'):
if entry.find('skipped') is not None or entry.get('status') == 'notrun':
skipped += 1
elif entry.find('failure') is not None:
failed += 1
marker_assertion_failed = any(marker in (failure.get('message', '') + (failure.text or ''))
for failure in entry.findall('failure'))
else:
passed += 1
client_log = read(out / 'client.client.log')
return {'case': test, 'returncode': result.returncode, 'passed': passed, 'failed': failed, 'skipped': skipped,
'tcp_armed': bool(re.search(r'control=tcp data=stream server=\S+ pid=[1-9][0-9]*', client_log)),
'marker_in_client': marker in client_log,
'marker_in_server': marker in read(out / 'peer.server.log'),
'marker_forwarded': marker in read(out / 'client.server.log'),
'marker_assertion_failed': marker_assertion_failed,
'output': str(out)}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--build-dir', type=Path, required=True)
parser.add_argument('--verify-build-dir', type=Path)
parser.add_argument('--out', type=Path, required=True)
parser.add_argument('--case', action='append', choices=CASES,
help='Explicit subset; the default runs all four and requires a verify build')
args = parser.parse_args()
selected = args.case or list(CASES)
if 'verify' in selected and not args.verify_build_dir:
parser.error('the verify scenario requires --verify-build-dir configured with MOBILEGL_PIPE_VERIFY=ON')
args.out.mkdir(parents=True, exist_ok=True)
results = {}
for case in selected:
build = args.verify_build_dir if case == 'verify' else args.build_dir
arms = {}
for forward in (True, False):
name = 'forward-on' if forward else 'forward-off'
try:
arms[name] = run_arm(build, args.out / case / name, case, forward)
except Exception as error:
arms[name] = {'error': str(error)}
on, off = arms['forward-on'], arms['forward-off']
if case == 'verify':
# PipeFill owns this marker in the client. Disabling peer forwarding
# must leave it visible; requiring red here would test a false design.
arms['expectation'] = 'client-owned marker remains green with peer forwarding disabled'
arms['proved'] = all(arm.get('passed') == 1 and arm.get('returncode') == 0
and arm.get('tcp_armed') and arm.get('marker_in_client')
for arm in (on, off))
else:
arms['expectation'] = 'server-owned marker passes with forwarding and fails without it'
arms['proved'] = bool(on.get('passed') == 1 and on.get('returncode') == 0
and on.get('tcp_armed') and on.get('marker_in_server') and on.get('marker_forwarded')
and off.get('failed') == 1 and not off.get('skipped') and off.get('tcp_armed')
and off.get('marker_in_server') and not off.get('marker_forwarded')
and off.get('marker_assertion_failed')
and not off.get('marker_in_client'))
results[case] = arms
print(case + ': ' + ('PASS' if arms['proved'] else 'NOT PROVEN'), flush=True)
(args.out / 'summary.json').write_text(json.dumps(results, indent=2))
return 0 if all(result['proved'] for result in results.values()) else 1
if __name__ == '__main__':
raise SystemExit(main())
-106
View File
@@ -33,28 +33,6 @@
# green baseline green; the control's own run PASSES (the knob is not load-bearing)
# red-baseline the baseline itself has a failed entry
# all-skipped the baseline is entirely skipped (lost implementation, a hard failure)
# retrace-noselect `ctest -N` matches nothing; the run exits 8 the way --no-tests=error does
# retrace-unrelated one match; the run fails without naming the transport
# retrace-evidence one match; the run fails with run_trace_case.cmake's own sentence
# retrace-evidence-nolog
# one match; the run fails one check earlier, with the runner's "wrote no
# <client log> ... no evidence the transport ever resolved" - the shape a
# PULL library produces since P6's per-role log rename
# retrace-evidence-prefixed, retrace-evidence-nolog-prefixed
# the two above with `ctest -V`'s "1: " prefix on every line
# retrace-green one match; the run PASSES
#
# EXIT GATE E2's DRAW-DROP CONTROL (scripts/ci/retrace_drop_draw_control.sh). The library's own
# log is modelled as a separate sink from ctest stdout, exactly as it is for the split lane: the
# console sink is compiled out of the configurations these lanes run, so the dropped-record count
# can only ever arrive through ${LIBRARY_LOG}.
# dropdraw-noselect `ctest -N` matches nothing
# dropdraw-green the retrace PASSES with the draws dropped
# dropdraw-nossim red, but no ssim summary at all (loader failure / timeout shape)
# dropdraw-ssimhigh red, and the ssim is ABOVE the threshold: something else reddened it
# dropdraw-nolog red with a low ssim, but the library never said the knob armed
# dropdraw-zero red with a low ssim, the knob armed, and it dropped NOTHING
# dropdraw-evidence red with a low ssim and N > 0 records dropped: the real thing
set -u
mode="${STUB_MODE:?STUB_MODE must be set}"
@@ -142,10 +120,6 @@ fi
emit_listing() {
echo "Test project /stub"
if [ "${mode}" = "retrace-noselect" ] || [ "${mode}" = "dropdraw-noselect" ]; then
echo "Total Tests: 0"
return
fi
echo " Test #1: DirectGLES.Split.ClearThenReadPixelsScenario.ClearWithNoDrawIsVisibleToDefaultFramebufferReadPixels"
if [ "${mode}" = partial-fatal ]; then
echo " Test #2: DirectGLES.Split.TriangleScenario.SecondEntry"
@@ -288,86 +262,6 @@ case "${mode}" in
echo "100% tests passed, 0 tests failed out of 1"
exit 0
;;
retrace-noselect)
echo "No tests were found!!!"
exit 8
;;
retrace-unrelated)
echo "1/1 Test #1: MobileGLTraceReplay.OpenRA.DirectGLES ...***Failed"
echo "CMake Error: the fixture could not be unpacked"
exit 8
;;
retrace-evidence)
echo "1/1 Test #1: MobileGLTraceReplay.OpenRA.DirectGLES ...***Failed"
echo "CMake Error at run_trace_case.cmake:279 (message):"
echo " MOBILEGL_TRANSPORT=inproc is set for OpenRA DirectGLES and the library never"
echo " reported resolving it: mobilegl.log carries no"
echo ' "MOBILEGL_TRANSPORT=inproc - the MGPipe record stream".'
exit 8
;;
retrace-evidence-nolog)
# What a pull library gets since P6: it wrote output/mobilegl.log (one role, no suffix), the
# runner reads output/mobilegl.client.log and stops at the no-log check BEFORE the marker
# search. CMake-wrapped like the mode above, with the clause split across lines.
echo "1/1 Test #1: MobileGLTraceReplay.OpenRA.DirectGLES ...***Failed"
echo "CMake Error at run_trace_case.cmake:270 (message):"
echo " MOBILEGL_TRANSPORT=inproc is set for OpenRA DirectGLES but the run wrote no"
echo " OpenRA/DirectGLES/output/mobilegl.client.log, so there is no evidence the"
echo " transport ever resolved. A split retrace with no library log cannot be"
echo " counted as a split retrace."
exit 8
;;
retrace-evidence-prefixed|retrace-evidence-nolog-prefixed)
# The two modes above as `ctest -V` really prints them: EVERY line of a test's output carries
# a "<test number>: " prefix, continuation lines included, so after whitespace folding the
# wrapped sentence reads "never 1: reported resolving it" / "no evidence the 1: transport ever
# resolved". A short case directory breaks the sentence at exactly those words; CI's long
# paths happened not to (retrace-split, run 35671704873: 5 false reds from this shape).
STUB_MODE="${mode%-prefixed}" bash "$0" "$@" | sed 's/^/1: /'
exit "${PIPESTATUS[0]}"
;;
retrace-green)
echo "100% tests passed, 0 tests failed out of 1"
exit 0
;;
dropdraw-*)
# The library's own log, written by the replay the way the real one is. The control removes
# it before the run, so anything here is this run's.
armed=""
case "${mode}" in
dropdraw-zero)
armed='MGPipe: E2 control armed - drop-draw=1 drop-clear=0, 0 records dropped on the wire (draw=0 clear=0), frame 29' ;;
dropdraw-nolog) armed="" ;;
*)
armed='MGPipe: E2 control armed - drop-draw=1 drop-clear=0, 758 records dropped on the wire (draw=758 clear=0), frame 29' ;;
esac
if [ -n "${LIBRARY_LOG:-}" ] && [ -n "${armed}" ]; then
mkdir -p "$(dirname "${LIBRARY_LOG}")"
printf '%s\n' "[10:38:30] [Linux mobilegl_trace_/WARN]: ${armed}" > "${LIBRARY_LOG}"
fi
case "${mode}" in
dropdraw-green)
echo "100% tests passed, 0 tests failed out of 1"
exit 0
;;
dropdraw-nossim)
echo "1/1 Test #1: MobileGLTraceReplay.OpenRA.DirectGLES ...***Failed"
echo "CMake Error: the replay could not load the library"
exit 8
;;
dropdraw-ssimhigh)
echo "1/1 Test #1: MobileGLTraceReplay.OpenRA.DirectGLES ...***Failed"
echo "-- retrace completed; ssim=1.000000, ssimThreshold=0.990000, mismatchPixels=0"
echo "CMake Error at run_trace_case.cmake:301 (message): 3 MGPipe Fatal(s)"
exit 8
;;
*)
echo "1/1 Test #1: MobileGLTraceReplay.OpenRA.DirectGLES ...***Failed"
echo "-- retrace completed; ssim=0.000036, ssimThreshold=0.990000, mismatchPixels=295296"
exit 8
;;
esac
;;
*)
echo "stub_ctest: unknown STUB_MODE '${mode}'" >&2
exit 127
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
# MobileGL - scripts/ci/transport_pinned.py
# Copyright (c) 2025-2026 MobileGL-Dev
# Licensed under the GNU Lesser General Public License v3.0:
# https://www.gnu.org/licenses/gpl-3.0.txt
# https://www.gnu.org/licenses/lgpl-3.0.txt
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
"""The integration-gpu entries that pin their own transport, which the two integration legs share.
The integration job runs integration-gpu once per leg, and the legs differ only in the
MOBILEGL_TRANSPORT the job exports. An entry whose own ctest ENVIRONMENT sets MOBILEGL_TRANSPORT
(the Split/Spawn/Tcp families and a few monolith-pinned ones) overrides that one difference, so it
runs identically in both legs. The inproc leg leaves those entries, and the fixture entries only
they require, to the monolith-control leg, which every branch runs.
transport_pinned.py select ALL.json --out PINNED.txt
transport_pinned.py assert-exclusion ALL.json TRIMMED.json [--out SUMMARY.json]
transport_pinned.py require-ran ALL.json JUNIT.xml
transport_pinned.py --self-test
ALL.json and TRIMMED.json are `ctest --show-only=json-v1` without and with `--exclude-from-file`.
select writes the exact names (P, the pinned entries, plus F, their own fixtures) one per line.
assert-exclusion proves the trimmed selection lost exactly P and F, with P non-empty and no pin
left; it prints the counts on every run. require-ran proves the monolith leg ran every one of them.
select and assert-exclusion report a problem as a warning, because their caller then runs every
entry and stays green; require-ran's are errors.
"""
import argparse
import contextlib
import io
import json
from pathlib import Path
import re
import sys
import tempfile
import xml.etree.ElementTree as ET
ARM_NAME = re.compile(r'^(DirectGLES|DirectVulkan)\.(Split|Spawn|Tcp|TcpDevice)\.')
def props(test):
return {p['name']: p.get('value') for p in test.get('properties', [])}
def as_list(value):
if value is None:
return []
if isinstance(value, list):
return [str(v) for v in value]
return [v for v in str(value).split(';') if v]
def pinned_transport(test):
for item in as_list(props(test).get('ENVIRONMENT')):
if item.startswith('MOBILEGL_TRANSPORT='):
return item.split('=', 1)[1]
return None
def partition(document, strict=True):
"""(pinned {name: transport}, fixtures {name}) for a json-v1 document. strict raises when an arm
name and its pin disagree, so the selection is exactly the population the census lists by name."""
tests = document.get('tests', [])
pinned = {t['name']: pinned_transport(t) for t in tests if pinned_transport(t) is not None}
problems = []
for test in tests if strict else []:
name, transport = test['name'], pinned.get(test['name'])
split_pin = transport is not None and transport != 'monolith'
if bool(ARM_NAME.match(name)) != split_pin:
problems.append(f'{name}: name and pin disagree (pin {transport!r})')
if problems:
raise ValueError('transport pins and arm names disagree: ' + '; '.join(problems[:10]))
requirers = {}
for test in tests:
for fixture in as_list(props(test).get('FIXTURES_REQUIRED')):
requirers.setdefault(fixture, set()).add(test['name'])
fixtures = set()
for test in tests:
p = props(test)
provided = as_list(p.get('FIXTURES_SETUP')) + as_list(p.get('FIXTURES_CLEANUP'))
if not provided:
continue
users = set().union(*(requirers.get(f, set()) for f in provided))
if users and users <= set(pinned):
fixtures.add(test['name'])
return pinned, fixtures
def names(document):
return [t['name'] for t in document.get('tests', [])]
def select(document, strict=True):
pinned, fixtures = partition(document, strict)
return sorted(set(pinned) | fixtures)
def assert_exclusion(full, trimmed):
pinned, fixtures = partition(full)
expected = set(pinned) | fixtures
lost = set(names(full)) - set(names(trimmed))
added = set(names(trimmed)) - set(names(full))
problems = []
if not pinned:
problems.append('no entry pins its transport, so there is nothing to leave to the other leg')
if not names(trimmed):
problems.append('the trimmed selection is empty')
if added:
problems.append(f'{len(added)} name(s) appeared only in the trimmed selection, e.g. {sorted(added)[0]}')
if lost != expected:
extra, kept = sorted(lost - expected), sorted(expected - lost)
problems.append(f'excluded {len(lost)} name(s), expected {len(expected)}: '
f'{len(extra)} unexpected (e.g. {extra[:3]}), {len(kept)} still selected (e.g. {kept[:3]})')
left = [t['name'] for t in trimmed.get('tests', []) if pinned_transport(t) is not None]
if left:
problems.append(f'{len(left)} pinned entr(ies) left in the trimmed selection, e.g. {left[0]}')
by_pin = {}
for transport in pinned.values():
by_pin[transport] = by_pin.get(transport, 0) + 1
summary = {'pinned': len(pinned), 'by_transport': by_pin, 'fixtures': sorted(fixtures),
'remaining': len(names(trimmed)), 'problems': problems}
return summary
def require_ran(document, junit_path):
expected = set(select(document, strict=False))
seen = {case.get('name') for case in ET.parse(junit_path).getroot().iter('testcase')}
return sorted(expected - seen), len(expected)
def self_test():
def test(name, transport=None, setup=None, cleanup=None, requires=None):
properties = []
if transport:
properties.append({'name': 'ENVIRONMENT', 'value': ['A=1', f'MOBILEGL_TRANSPORT={transport}']})
if setup:
properties.append({'name': 'FIXTURES_SETUP', 'value': [setup]})
if cleanup:
properties.append({'name': 'FIXTURES_CLEANUP', 'value': [cleanup]})
if requires:
properties.append({'name': 'FIXTURES_REQUIRED', 'value': [requires]})
return {'name': name, 'properties': properties}
full = {'tests': [
test('DirectGLES.Plain.A'), test('DirectGLES.Split.A', 'inproc'),
test('DirectVulkan.Spawn.A', 'spawn'), test('DirectGLES.Tcp.A', 'spawn', requires='tcp'),
test('DirectGLES.HandleRecycle.A', 'monolith'),
test('TcpServer.Start', setup='tcp'), test('TcpServer.Stop', cleanup='tcp'),
test('Shared.Start', setup='shared'), test('DirectGLES.Plain.B', requires='shared'),
]}
expected = ['DirectGLES.HandleRecycle.A', 'DirectGLES.Split.A', 'DirectGLES.Tcp.A',
'DirectVulkan.Spawn.A', 'TcpServer.Start', 'TcpServer.Stop']
trimmed = {'tests': [t for t in full['tests'] if t['name'] not in expected]}
checks = [
('select is the pinned entries and their own fixtures', select(full) == expected),
('the exact exclusion passes', not assert_exclusion(full, trimmed)['problems']),
('a pinned entry left behind fails',
bool(assert_exclusion(full, {'tests': trimmed['tests'] + [full['tests'][1]]})['problems'])),
('an unpinned entry excluded too fails',
bool(assert_exclusion(full, {'tests': trimmed['tests'][1:]})['problems'])),
('an empty trimmed selection fails', bool(assert_exclusion(full, {'tests': []})['problems'])),
('no pinned entry at all fails', bool(assert_exclusion(trimmed, trimmed)['problems'])),
]
try:
partition({'tests': [test('DirectGLES.Split.B')]})
checks.append(('an arm name without its pin is refused', False))
except ValueError:
checks.append(('an arm name without its pin is refused', True))
try:
partition({'tests': [test('DirectGLES.Plain.C', 'inproc')]})
checks.append(('an inproc pin outside the arm names is refused', False))
except ValueError:
checks.append(('an inproc pin outside the arm names is refused', True))
with tempfile.TemporaryDirectory() as tmp:
junit = Path(tmp) / 'run.xml'
junit.write_text('<testsuite>' + ''.join(f'<testcase name="{n}"/>' for n in names(full)) + '</testsuite>')
checks.append(('require-ran passes when the leg ran them all', require_ran(full, junit)[0] == []))
junit.write_text('<testsuite>' + ''.join(f'<testcase name="{n}"/>' for n in names(trimmed)) + '</testsuite>')
checks.append(('require-ran fails when the leg lost them', require_ran(full, junit)[0] == expected))
# The annotation level: the inproc leg falls back and stays green on an exclusion problem.
(Path(tmp) / 'all.json').write_text(json.dumps(full))
(Path(tmp) / 'bad.json').write_text(json.dumps({'tests': []}))
def annotations(argv):
out = io.StringIO()
with contextlib.redirect_stdout(out):
rc = main([str(Path(tmp) / a) if a.endswith(('.json', '.xml')) else a for a in argv])
return rc, out.getvalue()
rc, text = annotations(['assert-exclusion', 'all.json', 'bad.json'])
checks.append(('an exclusion problem is a warning', rc == 1 and '::warning::' in text and '::error::' not in text))
rc, text = annotations(['require-ran', 'all.json', 'run.xml'])
checks.append(('an entry the monolith leg did not run is an error', rc == 1 and '::error::' in text))
rc, text = annotations(['require-ran', 'all.json', 'missing.xml'])
checks.append(('a monolith-leg JUnit that cannot be read is an error', rc == 1 and '::error::' in text))
failed = 0
for label, ok in checks:
failed += not ok
print(f"{'ok' if ok else 'NOT OK':7} {label}")
print(f"transport_pinned self-test: {'FAILED' if failed else 'ok'}")
return 1 if failed else 0
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
if argv == ['--self-test']:
return self_test()
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest='command', required=True)
p = sub.add_parser('select')
p.add_argument('all')
p.add_argument('--out', required=True)
p = sub.add_parser('assert-exclusion')
p.add_argument('all')
p.add_argument('trimmed')
p.add_argument('--out')
p = sub.add_parser('require-ran')
p.add_argument('all')
p.add_argument('junit')
args = parser.parse_args(argv)
level = 'error' if args.command == 'require-ran' else 'warning'
try:
full = json.loads(Path(args.all).read_text())
if args.command == 'select':
chosen = select(full)
Path(args.out).write_text(''.join(name + '\n' for name in chosen))
print(f'transport_pinned: {len(chosen)} name(s) left to the monolith-control leg')
return 0
if args.command == 'assert-exclusion':
summary = assert_exclusion(full, json.loads(Path(args.trimmed).read_text()))
if args.out:
Path(args.out).write_text(json.dumps(summary, indent=1) + '\n')
print(f"transport_pinned: excluded {summary['pinned']} pinned entr(ies) {summary['by_transport']} "
f"and {len(summary['fixtures'])} fixture entr(ies); {summary['remaining']} remain")
for problem in summary['problems']:
print(f'::{level}::transport_pinned: {problem}')
return 1 if summary['problems'] else 0
missing, total = require_ran(full, args.junit)
if missing:
print(f'::error::transport_pinned: the monolith-control leg ran {total - len(missing)} of the '
f'{total} entries the inproc leg leaves to it; missing e.g. {missing[:5]}')
return 1
print(f'transport_pinned: the monolith-control leg ran all {total} entries the inproc leg leaves to it')
return 0
except (OSError, ValueError, ET.ParseError) as error:
print(f'::{level}::transport_pinned: {error}')
return 1
if __name__ == '__main__':
sys.exit(main())
File diff suppressed because it is too large Load Diff
+138
View File
@@ -67,6 +67,34 @@ if(DEFINED TRACE_IPC_CONTROL AND NOT "${TRACE_IPC_CONTROL}" STREQUAL "")
set(ENV{MOBILEGL_IPC_LOG_FORWARD} 1)
endif()
# Wire-volume evidence, only on the legs that ask for it. MGTRACE_WIRE_EVIDENCE is a harness name
# the library never reads. On a split transport it turns on the library's own per-frame counters,
# so the checks after the replay can count the draw records the client put on the wire and the
# draws the server role's backend ran. Both knobs are set outright: a longer summary period from
# the caller would leave the server's counter line missing on a short trace. It stays off unless
# asked for, because the counters are part of the configuration under test.
set(trace_wire_evidence OFF)
if(NOT "$ENV{MGTRACE_WIRE_EVIDENCE}" STREQUAL "" AND NOT "$ENV{MGTRACE_WIRE_EVIDENCE}" STREQUAL "0")
if("$ENV{MOBILEGL_TRANSPORT}" STREQUAL "" OR "$ENV{MOBILEGL_TRANSPORT}" STREQUAL "monolith")
message(FATAL_ERROR
"MGTRACE_WIRE_EVIDENCE is set for ${TRACE_CASE_NAME} ${TRACE_BACKEND} but "
"MOBILEGL_TRANSPORT is '$ENV{MOBILEGL_TRANSPORT}': a run with no wire has no wire "
"volume to count, so the request cannot be met.")
endif()
set(trace_wire_evidence ON)
set(ENV{MOBILEGL_PIPE_STATS} 1)
set(ENV{MOBILEGL_PIPE_STATS_PERIOD} 1)
endif()
# MGTRACE_EXPECT_IPC, another harness name, lists key=value tokens the client's one-line IPC
# configuration must carry (checked in the transport block below); a leg that leaves knobs at their
# defaults uses it to prove what those defaults resolved to. A monolith run has no such line.
if(NOT "$ENV{MGTRACE_EXPECT_IPC}" STREQUAL ""
AND ("$ENV{MOBILEGL_TRANSPORT}" STREQUAL "" OR "$ENV{MOBILEGL_TRANSPORT}" STREQUAL "monolith"))
message(FATAL_ERROR
"MGTRACE_EXPECT_IPC is set for ${TRACE_CASE_NAME} ${TRACE_BACKEND} but MOBILEGL_TRANSPORT is "
"'$ENV{MOBILEGL_TRANSPORT}': a monolith run logs no IPC configuration to check.")
endif()
if(EXISTS "${TRACE_OUTPUT_DIR}")
file(REMOVE_RECURSE "${TRACE_OUTPUT_DIR}")
endif()
@@ -478,5 +506,115 @@ if(DEFINED ENV{MOBILEGL_TRANSPORT} AND NOT "$ENV{MOBILEGL_TRANSPORT}" STREQUAL "
"(magma-b3.md §2). The clamp in VulkanRenderer::OnSubmitsCompletedUpTo is what makes "
"this unreachable; a line here means it was removed or bypassed.")
endif()
# The IPC configuration the client resolved, against MGTRACE_EXPECT_IPC's tokens. Each token
# must appear whole on the last "Config: IPC" line, so run-ahead=1 never matches
# run-ahead=10. MGTRACE_EXPECT_IPC_REASON, when set, says why the leg relies on them.
set(split_ipc_expected "-")
if(NOT "$ENV{MGTRACE_EXPECT_IPC}" STREQUAL "")
set(split_ipc_expected "$ENV{MGTRACE_EXPECT_IPC}")
file(STRINGS "${mobilegl_log}" split_ipc_lines REGEX "Config: IPC ")
if(NOT split_ipc_lines)
message(FATAL_ERROR "${split_case}: ${mobilegl_log} carries no 'Config: IPC' line, so the "
"configuration MGTRACE_EXPECT_IPC names cannot be checked.")
endif()
list(GET split_ipc_lines -1 split_ipc_line)
string(REPLACE " " ";" split_ipc_tokens "$ENV{MGTRACE_EXPECT_IPC}")
set(split_ipc_missing "")
foreach(token IN LISTS split_ipc_tokens)
if(NOT "${token}" STREQUAL "")
string(FIND " ${split_ipc_line} " " ${token} " split_ipc_at)
if(split_ipc_at EQUAL -1)
list(APPEND split_ipc_missing "${token}")
endif()
endif()
endforeach()
if(split_ipc_missing)
string(REPLACE ";" " " split_ipc_missing "${split_ipc_missing}")
message(FATAL_ERROR
"${split_case}: the client resolved '${split_ipc_line}', which lacks "
"${split_ipc_missing} (MGTRACE_EXPECT_IPC='$ENV{MGTRACE_EXPECT_IPC}'). "
"$ENV{MGTRACE_EXPECT_IPC_REASON}")
endif()
message(STATUS "MGPipe split: ${split_case} IPC expectation met ($ENV{MGTRACE_EXPECT_IPC})")
endif()
# The wire volume, on the legs that asked for it (MGTRACE_WIRE_EVIDENCE above). The client
# counts each record at the codec commit and prints one frame-sent line per Present, keyed
# by raw opcode; the server role's backend counts its draws and prints one stats line per
# Present. Under inproc and spawn the client has no backend of its own, so draws on both
# sides mean the picture was drawn from records that crossed the wire. A tcp server is
# launched outside this script with an environment that is not ours, so its count is
# not required.
set(split_draw_opcode "-")
set(split_wire_records "-")
set(split_server_draws "-")
if(trace_wire_evidence)
set(split_wire_table "${CMAKE_CURRENT_LIST_DIR}/../../MobileGL/MG_Pipe/generated/PipeWire.inc")
set(split_draw_op_line "")
if(EXISTS "${split_wire_table}")
file(STRINGS "${split_wire_table}" split_draw_op_line REGEX "^[ \t]*DrawVbo = [0-9]+,")
endif()
if(NOT split_draw_op_line MATCHES "DrawVbo = ([0-9]+),")
message(FATAL_ERROR "${split_case}: cannot find the DrawVbo opcode in ${split_wire_table}, "
"so the wire's draw records cannot be counted.")
endif()
set(split_draw_opcode "${CMAKE_MATCH_1}")
set(split_wire_records 0)
file(STRINGS "${mobilegl_log}" split_sent_lines REGEX "P65LinkMetrics kind=frame-sent ")
foreach(line IN LISTS split_sent_lines)
if(line MATCHES "by_op=([0-9=,]+)")
if(",${CMAKE_MATCH_1}" MATCHES ",${split_draw_opcode}=([0-9]+)")
math(EXPR split_wire_records "${split_wire_records} + ${CMAKE_MATCH_1}")
endif()
endif()
endforeach()
if(split_wire_records EQUAL 0)
message(FATAL_ERROR
"${split_case}: MOBILEGL_TRANSPORT=$ENV{MOBILEGL_TRANSPORT} committed no DrawVbo "
"record to the wire: ${mobilegl_log} carries no frame-sent line counting opcode "
"${split_draw_opcode}. Either the transport resolved and the emit table did not put "
"a draw on it, or MOBILEGL_PIPE_STATS did not reach the client.")
endif()
if("$ENV{MOBILEGL_IPC_CONTROL}" MATCHES "^tcp://")
set(split_server_draws "n/a")
else()
set(split_server_draws 0)
file(STRINGS "${mobilegl_server_log}" split_stats_lines REGEX "MGPipe stats:.* draws=[0-9]+ ")
foreach(line IN LISTS split_stats_lines)
if(line MATCHES " draws=([0-9]+) ")
math(EXPR split_server_draws "${split_server_draws} + ${CMAKE_MATCH_1}")
endif()
endforeach()
if(split_server_draws EQUAL 0)
message(FATAL_ERROR
"${split_case}: MOBILEGL_TRANSPORT=$ENV{MOBILEGL_TRANSPORT}: the server role's "
"backend executed no draw - ${mobilegl_server_log} carries no MGPipe stats line "
"with draws above zero, so nothing shows the records were drawn on that side.")
endif()
endif()
endif()
# The per-leg evidence that every assertion above ran and found what it needed. It is
# written only here, after the last of them, into an output directory wiped at the top of
# this script, so a file that exists is this run's; CI reads it back after ctest.
if("${split_expected_second}" STREQUAL "")
set(split_second_found "-")
else()
set(split_second_found "${split_expected_second}")
endif()
if("$ENV{MOBILEGL_IPC_CONTROL}" STREQUAL "")
set(split_control "-")
else()
set(split_control "$ENV{MOBILEGL_IPC_CONTROL}")
endif()
string(CONCAT split_evidence
"case=${TRACE_CASE_NAME}\nbackend=${TRACE_BACKEND}\ntransport=$ENV{MOBILEGL_TRANSPORT}\n"
"control=${split_control}\nmarker=${split_expected_marker}\nsecond=${split_second_found}\n"
"server_log=1\nfatal_lines=${split_fatal_count}\nfloor_lines=${split_floor_count}\n"
"ipc_expected=${split_ipc_expected}\n"
"wire=${trace_wire_evidence}\ndraw_opcode=${split_draw_opcode}\n"
"wire_draw_records=${split_wire_records}\nserver_draws=${split_server_draws}\n")
file(WRITE "${TRACE_OUTPUT_DIR}/output/split-evidence.txt" "${split_evidence}")
message(STATUS "MGPipe split evidence: ${split_case} transport=$ENV{MOBILEGL_TRANSPORT} "
"marker found, second='${split_second_found}', wire=${trace_wire_evidence}, "
"wire_draw_records=${split_wire_records}, server_draws=${split_server_draws}")
endif()
endif()
+375
View File
@@ -0,0 +1,375 @@
#!/usr/bin/env python3
# MobileGL - tools/trace_replay/test_run_trace_case.py
# Copyright (c) 2025-2026 MobileGL-Dev
# Licensed under the GNU Lesser General Public License v3.0:
# https://www.gnu.org/licenses/gpl-3.0.txt
# https://www.gnu.org/licenses/lgpl-3.0.txt
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
"""Canned-log tests of run_trace_case.cmake's split and verify assertions.
Each case runs the script exactly as ctest does (`cmake -D... -P run_trace_case.cmake`) with a
stub replay that copies canned library logs into the output directory. A bad log must turn the
case red with the script's own sentence and leave no split-evidence.txt; a good one must pass and
leave evidence that scripts/ci/retrace_split_evidence.py accepts. No build, GPU or trace needed.
"""
import contextlib
import io
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
HERE = Path(__file__).resolve().parent
SCRIPT = HERE / 'run_trace_case.cmake'
CHECKER = HERE.parents[1] / 'scripts' / 'ci' / 'retrace_split_evidence.py'
VERIFY_REASON = HERE.parents[1] / 'scripts' / 'ci' / 'retrace_verify_reason.py'
sys.path.insert(0, str(CHECKER.parent))
import retrace_split_evidence as checker # noqa: E402
# The stub replay. It refuses to run when the wire counters disagree with what the leg asked for,
# so removing the script's MOBILEGL_PIPE_STATS export turns every wire case red, and exporting it
# on a leg that did not ask turns that leg red too.
STUB = r'''#!/usr/bin/env python3
import json, os, shutil, sys
args = sys.argv[1:]
out = args[args.index('--output') + 1]
split = os.environ.get('MOBILEGL_TRANSPORT', '') not in ('', 'monolith')
wire = os.environ.get('MGTRACE_WIRE_EVIDENCE', '') not in ('', '0')
stats = os.environ.get('MOBILEGL_PIPE_STATS', '')
if wire and split and (stats != '1' or os.environ.get('MOBILEGL_PIPE_STATS_PERIOD') != '1'):
sys.stderr.write('stub: MOBILEGL_PIPE_STATS did not reach the replay\n')
sys.exit(3)
if not wire and stats:
sys.stderr.write('stub: MOBILEGL_PIPE_STATS reached a leg that did not ask for it\n')
sys.exit(4)
canned = os.environ.get('STUB_CANNED_DIR', '')
if canned:
for name in os.listdir(canned):
shutil.copy(os.path.join(canned, name), os.path.join(out, name))
with open(os.path.join(out, 'retrace.log'), 'w') as log:
log.write('stub replay\n')
if os.environ.get('STUB_ABORT'):
sys.exit(134)
with open(os.path.join(out, 'result.json'), 'w') as result:
json.dump({'passed': True, 'ssim': 1.0}, result)
'''
PREFIX = '[12:00:00] [Linux mobilegl_trace_/INFO]: '
MARKER = {
'inproc': 'Config: MOBILEGL_TRANSPORT=inproc - the MGPipe record stream crosses a real ring to an apply thread',
'spawn': 'Config: MOBILEGL_TRANSPORT=spawn - the MGPipe record stream crosses a real ring to an apply '
'thread in ANOTHER PROCESS',
}
SPAWN_ARMED = 'MG_Remote client: spawn ARMED - the server role runs in pid 4242, reached at /tmp/x'
TCP_ARMED = 'MG_Remote client: control=tcp data=stream server=127.0.0.1:1 pid=4243 dial=connect'
SENT = ['P65LinkMetrics kind=frame-sent frame=1 records=40 by_op=2=4,57=1,59=30,67=1',
'P65LinkMetrics kind=frame-sent frame=2 records=31 by_op=57=1,59=28,67=1']
STATS_ON = 'MGPipe stats: counters ON (MOBILEGL_PIPE_STATS), summary every 1 frames'
STATS = ['MGPipe stats: frames=1 window=1 draws=30 draws/f=30.00 acc=12 acc/draw=0.40 bytes/f[a=1]',
'MGPipe stats: frames=2 window=1 draws=28 draws/f=28.00 acc=10 acc/draw=0.36 bytes/f[a=1]']
SERVER_START = 'MG_Remote server: apply thread started'
def quiet_check(argv):
"""The checker's exit status, with its ::error:: lines kept off a green run's annotations."""
with contextlib.redirect_stdout(io.StringIO()):
return checker.main(argv)
def lines(*rows):
return ''.join(PREFIX + row + '\n' for row in rows)
def good_logs(transport):
client = [MARKER['spawn' if transport == 'tcp' else transport]]
if transport == 'spawn':
client.append(SPAWN_ARMED)
if transport == 'tcp':
client.append(TCP_ARMED)
client += SENT
server = [SERVER_START, STATS_ON] + ([] if transport == 'tcp' else STATS)
return {'mobilegl.client.log': lines(*client), 'mobilegl.server.log': lines(*server)}
def squash(text):
return re.sub(r'\s+', ' ', text)
@unittest.skipIf(os.name == 'nt', 'the stub replay is a POSIX executable script')
class RunTraceCaseTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='run-trace-case-')
self.root = Path(self.temp.name)
payload = self.root / 't.trace'
payload.write_text('not a real trace\n')
self.archive = self.root / 't.tar.gz'
with tarfile.open(self.archive, 'w:gz') as tar:
tar.add(payload, arcname='t.trace')
self.stub = self.root / 'stub_replay'
self.stub.write_text(STUB)
self.stub.chmod(0o755)
self.out = self.root / 'case'
def tearDown(self):
self.temp.cleanup()
def run_case(self, env, logs, tcp=False, squashed=True):
canned = self.root / 'canned'
shutil.rmtree(canned, ignore_errors=True)
canned.mkdir()
for name, text in logs.items():
(canned / name).write_text(text)
environment = {key: value for key, value in os.environ.items()
if not key.startswith(('MOBILEGL_', 'MGTRACE_'))}
environment.update(env)
environment['STUB_CANNED_DIR'] = str(canned)
command = ['cmake', f'-DTRACE_REPLAY_EXE={self.stub}', f'-DMOBILEGL_LIBRARY={self.root}/libMobileGL.so',
f'-DTRACE_ARCHIVE={self.archive}', '-DTRACE_FILE=t.trace',
f'-DTRACE_GOLDEN={self.root}/golden.png', f'-DTRACE_OUTPUT_DIR={self.out}',
f'-DTRACE_ARTIFACT_DIR={self.root}/artifacts', '-DTRACE_BACKEND=DirectGLES',
'-DTRACE_CASE_NAME=Canned', '-DTRACE_TARGET_CALL=1', '-DTRACE_WIDTH=1', '-DTRACE_HEIGHT=1']
if tcp:
command += ['-DTRACE_IPC_CONTROL=tcp://127.0.0.1:1', '-DTRACE_IPC_TOKEN=x']
command += ['-P', str(SCRIPT)]
result = subprocess.run(command, env=environment, capture_output=True, text=True)
output = result.stdout + result.stderr
return result.returncode, squash(output) if squashed else output
def split_env(self, transport, wire=False):
env = {'MOBILEGL_TRANSPORT': 'spawn' if transport == 'tcp' else transport,
'MOBILEGL_IPC_SERVER_PATH': str(self.root / 'libMobileGLServer.so')}
if wire:
env['MGTRACE_WIRE_EVIDENCE'] = '1'
return env
@property
def evidence(self):
return self.out / 'output' / 'split-evidence.txt'
def expect_red(self, env, logs, sentence, tcp=False):
rc, output = self.run_case(env, logs, tcp=tcp)
self.assertNotEqual(rc, 0, output)
self.assertIn(squash(sentence), output)
self.assertFalse(self.evidence.exists(), 'a red leg must leave no evidence file')
def expect_green(self, env, logs, transport, tcp=False, wire=False):
rc, output = self.run_case(env, logs, tcp=tcp)
self.assertEqual(rc, 0, output)
self.assertEqual(quiet_check([str(self.evidence), '--transport', transport, '--case', 'Canned',
'--backend', 'DirectGLES'] + (['--require-wire'] if wire else [])), 0)
return output
# Red: each assertion of the transport block, fed the log that must trip it.
def test_r1_a_library_without_a_transport_writes_no_client_log(self):
self.expect_red(self.split_env('inproc'), {'mobilegl.log': lines('Config: loaded')},
'so there is no evidence the transport ever resolved')
def test_r2_the_env_dump_line_is_not_the_marker(self):
logs = {'mobilegl.client.log': lines('Config: Accepted env variable: MOBILEGL_TRANSPORT=inproc'),
'mobilegl.server.log': lines(SERVER_START)}
self.expect_red(self.split_env('inproc'), logs, 'never reported resolving it')
def test_r3_spawn_without_a_separate_server_pid(self):
logs = good_logs('spawn')
logs['mobilegl.client.log'] = lines(MARKER['spawn'], *SENT)
self.expect_red(self.split_env('spawn'), logs, 'spawn ARMED - the server role runs in pid')
def test_r4_tcp_without_endpoint_and_pid(self):
logs = good_logs('tcp')
logs['mobilegl.client.log'] = lines(MARKER['spawn'], *SENT)
self.expect_red(self.split_env('tcp'), logs, 'TCP session has no endpoint and server pid arm proof',
tcp=True)
def test_r5_no_server_log(self):
logs = good_logs('inproc')
del logs['mobilegl.server.log']
self.expect_red(self.split_env('inproc'), logs, 'but the run wrote no')
def test_r6_a_server_fatal(self):
logs = good_logs('inproc')
logs['mobilegl.server.log'] += lines('MGPipe: Fatal{UnmigratedVerb, "X"}')
self.expect_red(self.split_env('inproc'), logs, 'MGPipe Fatal(s) under MOBILEGL_TRANSPORT')
def test_r7_a_floor_line(self):
logs = good_logs('inproc')
logs['mobilegl.server.log'] += lines('MGWIRE-FLOOR unsound-serial-complete serial=3')
self.expect_red(self.split_env('inproc'), logs, 'MGWIRE-FLOOR unsound-serial-complete line(s)')
def test_r8_no_frame_sent_lines(self):
logs = good_logs('inproc')
logs['mobilegl.client.log'] = lines(MARKER['inproc'])
self.expect_red(self.split_env('inproc', wire=True), logs, 'committed no DrawVbo record to the wire')
def test_r9_frames_sent_without_a_draw(self):
logs = good_logs('inproc')
logs['mobilegl.client.log'] = lines(MARKER['inproc'], 'P65LinkMetrics kind=frame-sent frame=1 '
'records=2 by_op=57=1,67=1,159=4,590=2')
self.expect_red(self.split_env('inproc', wire=True), logs, 'committed no DrawVbo record to the wire')
def test_r10_the_server_drew_nothing(self):
logs = good_logs('inproc')
logs['mobilegl.server.log'] = lines(SERVER_START, STATS_ON,
'MGPipe stats: frames=1 window=1 draws=0 draws/f=0.00 acc=0')
self.expect_red(self.split_env('inproc', wire=True), logs, 'backend executed no draw')
def test_r11_an_unknown_transport(self):
env = self.split_env('inproc')
env['MOBILEGL_TRANSPORT'] = 'unix:/x'
self.expect_red(env, good_logs('inproc'), 'this runner has no marker for it')
def test_r12_wire_evidence_without_a_split_transport(self):
self.expect_red({'MGTRACE_WIRE_EVIDENCE': '1', 'MOBILEGL_TRANSPORT': 'monolith'}, {},
'a run with no wire has no wire volume to count')
def test_r13_the_stub_refuses_counters_on_a_leg_that_did_not_ask(self):
env = self.split_env('inproc')
env['MOBILEGL_PIPE_STATS'] = '1'
self.expect_red(env, good_logs('inproc'), 'stub: MOBILEGL_PIPE_STATS reached a leg that did not ask for it')
# Green: the same logs as a real leg writes, on every arm.
def test_g1_inproc_with_wire(self):
output = self.expect_green(self.split_env('inproc', wire=True), good_logs('inproc'), 'inproc', wire=True)
self.assertIn('wire_draw_records=58, server_draws=58', output)
def test_g1b_inproc_marker_only(self):
self.expect_green(self.split_env('inproc'), good_logs('inproc'), 'inproc')
values = checker.parse(self.evidence.read_text())
self.assertEqual((values['wire'], values['wire_draw_records']), ('OFF', '-'))
self.assertNotEqual(quiet_check([str(self.evidence), '--transport', 'inproc', '--require-wire']), 0)
def test_g2_spawn_with_wire(self):
self.expect_green(self.split_env('spawn', wire=True), good_logs('spawn'), 'spawn', wire=True)
def test_g3_tcp_with_wire(self):
self.expect_green(self.split_env('tcp', wire=True), good_logs('tcp'), 'spawn', tcp=True, wire=True)
self.assertEqual(checker.parse(self.evidence.read_text())['server_draws'], 'n/a')
def test_g4_monolith_leaves_no_evidence(self):
rc, output = self.run_case({'MOBILEGL_TRANSPORT': 'monolith'}, {'mobilegl.log': lines('Config: loaded')})
self.assertEqual(rc, 0, output)
self.assertFalse(self.evidence.exists())
self.assertNotEqual(quiet_check([str(self.evidence), '--transport', 'inproc']), 0,
'a leg whose transport block was skipped must fail the checker')
# MGTRACE_EXPECT_IPC: the client's IPC line must carry every token, whole.
IPC = 'Config: IPC ring=8MiB verb-barrier=1 run-ahead={ra} present-credit=1 strict={st} audit=0 role-split-state={rs} x'
def ipc_logs(self, ra='1', st='0', rs='1', line=True):
logs = good_logs('spawn')
if line:
logs['mobilegl.client.log'] += lines(self.IPC.format(ra=ra, st=st, rs=rs))
return logs
def ipc_env(self, expect='strict=0 role-split-state=1 run-ahead=1'):
env = self.split_env('spawn')
env.update({'MGTRACE_EXPECT_IPC': expect, 'MGTRACE_EXPECT_IPC_REASON': 'restore the inproc rows'})
return env
def test_i1_the_expected_defaults_are_met(self):
output = self.expect_green(self.ipc_env(), self.ipc_logs(), 'spawn')
self.assertIn('IPC expectation met', output)
self.assertEqual(checker.parse(self.evidence.read_text())['ipc_expected'],
'strict=0 role-split-state=1 run-ahead=1')
def test_i2_a_changed_default_names_the_reason(self):
self.expect_red(self.ipc_env(), self.ipc_logs(rs='0'), 'lacks role-split-state=1')
self.expect_red(self.ipc_env(), self.ipc_logs(rs='0'), 'restore the inproc rows')
def test_i3_no_ipc_line(self):
self.expect_red(self.ipc_env(), self.ipc_logs(line=False), "carries no 'Config: IPC' line")
def test_i4_a_token_is_matched_whole(self):
self.expect_red(self.ipc_env(), self.ipc_logs(ra='10'), 'lacks run-ahead=1')
def test_i5_an_expectation_on_a_monolith_run(self):
self.expect_red({'MOBILEGL_TRANSPORT': 'monolith', 'MGTRACE_EXPECT_IPC': 'strict=1'}, {},
'a monolith run logs no IPC configuration to check')
# The verify block, which shares the script.
def test_v1_verify_without_the_armed_line(self):
self.expect_red({'MOBILEGL_PIPE_VERIFY': '1'}, {'mobilegl.log': lines('Config: loaded')},
'never reported "MGPipe: verify armed"')
def test_v2_verify_with_a_divergence(self):
logs = {'mobilegl.log': lines('MGPipe: verify armed',
'MGPipe: Fatal{PipeVerifyDiffer, "GetRenderStateParameters@Clear"}')}
self.expect_red({'MOBILEGL_PIPE_VERIFY': '1'}, logs, 'MGPipe Fatal(s) under MOBILEGL_PIPE_VERIFY')
def test_v4_an_aborted_verify_run_names_its_divergence(self):
# The comparator aborts the replay before result.json, so the script dumps the library log;
# the retrace-verify control requires that dump to carry the divergence it caused, through
# the same scripts/ci/retrace_verify_reason.py the workflow runs. Another Fatal is not it,
# and neither is the comparator's divergence on a field the control did not corrupt.
fatal = 'MGPipe: Fatal{PipeVerifyDiffer, "GetRenderStateParameters@Clear"} field=3 where=entry'
other = 'MGPipe: Fatal{UnmigratedPipeInput, "GetPixelStoreParameters@ReadPixels"}'
other_field = 'MGPipe: Fatal{PipeVerifyDiffer, "GetPixelStoreParameters@ReadPixels"} field=1 where=entry'
env = {'MOBILEGL_PIPE_VERIFY': '1', 'STUB_ABORT': '1'}
rc, output = self.run_case(env, {'mobilegl.log': lines('MGPipe: verify armed', fatal)})
self.assertNotEqual(rc, 0, output)
transcript = self.root / 'control.txt'
for log, expected in ((lines('MGPipe: verify armed', fatal), 0), (lines('MGPipe: verify armed'), 1),
(lines('MGPipe: verify armed', other), 1),
(lines('MGPipe: verify armed', other_field), 1)):
_, raw = self.run_case(env, {'mobilegl.log': log}, squashed=False)
transcript.write_text(''.join('1: ' + row + '\n' for row in raw.splitlines()))
check = subprocess.run([sys.executable, str(VERIFY_REASON), str(transcript)],
capture_output=True, text=True)
self.assertEqual(check.returncode, expected, raw + check.stdout)
def test_v3_verify_green(self):
rc, output = self.run_case({'MOBILEGL_PIPE_VERIFY': '1'}, {'mobilegl.log': lines('MGPipe: verify armed')})
self.assertEqual(rc, 0, output)
self.assertIn('armed, zero divergences', output)
class EvidenceCheckerTests(unittest.TestCase):
GOOD = ('case=Canned\nbackend=DirectGLES\ntransport=spawn\ncontrol=-\nmarker=m\n'
'second=spawn ARMED - the server role runs in pid \nserver_log=1\nfatal_lines=0\nfloor_lines=0\n'
'wire=ON\ndraw_opcode=59\nwire_draw_records=58\nserver_draws=58\n')
def check(self, text, *args):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'split-evidence.txt'
if text is not None:
path.write_text(text)
return quiet_check([str(path)] + list(args))
def test_good_file_passes(self):
self.assertEqual(self.check(self.GOOD, '--transport', 'spawn', '--require-wire'), 0)
def test_missing_file_fails(self):
self.assertEqual(self.check(None, '--transport', 'spawn'), 1)
def test_transport_mismatch_fails(self):
self.assertEqual(self.check(self.GOOD.replace('transport=spawn', 'transport=inproc'),
'--transport', 'spawn'), 1)
def test_case_mismatch_fails(self):
self.assertEqual(self.check(self.GOOD, '--transport', 'spawn', '--case', 'OpenRA'), 1)
def test_zero_wire_records_fail(self):
self.assertEqual(self.check(self.GOOD.replace('wire_draw_records=58', 'wire_draw_records=0'),
'--transport', 'spawn'), 1)
def test_zero_server_draws_fail(self):
self.assertEqual(self.check(self.GOOD.replace('server_draws=58', 'server_draws=0'),
'--transport', 'spawn'), 1)
def test_spawn_without_second_sentence_fails(self):
self.assertEqual(self.check(self.GOOD.replace('second=spawn ARMED - the server role runs in pid ',
'second=-'), '--transport', 'spawn'), 1)
def test_a_fatal_count_fails(self):
self.assertEqual(self.check(self.GOOD.replace('fatal_lines=0', 'fatal_lines=2'),
'--transport', 'spawn'), 1)
if __name__ == '__main__':
unittest.main()
+79 -16
View File
@@ -351,7 +351,7 @@ def github_verify_matrix(cases):
SPLIT_TRANSPORTS = ("inproc", "spawn")
def github_split_matrix(cases):
def github_split_matrix(cases, transports=SPLIT_TRANSPORTS, keep_inproc=()):
"""{backend, case, transport} for the split arm.
THREE DIMENSIONS, NOT TWO. The job used to take {backend, case} and hard-code
@@ -359,15 +359,31 @@ def github_split_matrix(cases):
backend list is the case's own ci_backends - the same list the monolith matrix uses - so a
case restricted to one backend stays restricted here instead of being registered with a
backend it does not run.
`transports` narrows the arms (test.yml drops inproc when the inproc-acceptance retrace rows
already replay the same library); `keep_inproc` names split cases whose inproc rows stay
anyway - OpenRA's, which carry the wire-volume evidence. A narrowed matrix that comes out
empty is an error; the full one is returned empty, and test.yml names that error itself.
"""
return {
"include": [
{"backend": backend, "case": case["name"], "transport": transport}
for case in split_trace_cases(cases)
for backend in ci_backends(case)
for transport in SPLIT_TRANSPORTS
]
}
unknown = [t for t in transports if t not in SPLIT_TRANSPORTS]
if unknown or not transports:
raise ValueError(f"split transports must be a non-empty subset of {SPLIT_TRANSPORTS}: {transports}")
split_cases = split_trace_cases(cases)
split_names = {case["name"] for case in split_cases}
missing = [name for name in keep_inproc if name not in split_names]
if missing:
raise ValueError(f"--keep-inproc names cases that are not split CI cases: {missing}")
rows = [
{"backend": backend, "case": case["name"], "transport": transport}
for case in split_cases
for backend in ci_backends(case)
for transport in SPLIT_TRANSPORTS
if transport in transports or (transport == "inproc" and case["name"] in keep_inproc)
]
narrowed = tuple(transports) != SPLIT_TRANSPORTS or bool(keep_inproc)
if not rows and narrowed:
raise ValueError("the narrowed split matrix is empty")
return {"include": rows}
def github_apk_matrix(cases):
@@ -393,12 +409,13 @@ def github_transport_matrix(cases, *, apk=False):
THE SPAWN LANE IS APK-ONLY, and that is a statement about where the evidence is missing
rather than about what spawn can do. On Linux the spawn arm already has a job of its own -
test.yml's `retrace-split`, which is {backend, case, transport} over inproc AND spawn and
carries the two negative controls that make it mean anything (the pull library must red it,
dropping the draws must red it). Adding spawn to THIS matrix as well would pay for a second
full Linux sweep that proves nothing the first one does not, and test.yml's `retrace` job
has neither the server image staged nor MOBILEGL_IPC_SERVER_PATH set - so the lane would
not merely be redundant, it would be red.
test.yml's `retrace-split`, which is {backend, case, transport} over spawn (and inproc too
when it cannot reuse this matrix's inproc rows' library) and whose legs prove in-test that
the transport resolved and, on OpenRA, that draw records crossed the wire
(run_trace_case.cmake's evidence). Adding spawn to THIS matrix would pay
for a second full Linux sweep that proves nothing the first one does not, and test.yml's
`retrace` job has neither the server image staged nor MOBILEGL_IPC_SERVER_PATH set - so the
lane would not merely be redundant, it would be red.
On Android there is no such job. Until this lane existed, nothing in CI ever started a
second MobileGL process on a device.
@@ -466,6 +483,41 @@ def self_test_transport_matrices():
f"entries; exact metadata and negative controls OK")
def self_test_split_matrix(cases=None):
"""retrace-split may drop its inproc rows only because the inproc-acceptance rows replay them."""
if cases is None:
cases = ci_trace_cases(load_trace_cases())
full = github_split_matrix(cases)["include"]
spawn_only = github_split_matrix(cases, transports=("spawn",), keep_inproc=("OpenRA",))["include"]
assert [r for r in spawn_only if r["transport"] == "spawn"] == [r for r in full if r["transport"] == "spawn"], \
"the spawn-only matrix lost or changed a spawn row"
assert any(r["transport"] == "spawn" for r in spawn_only), "the spawn-only matrix has no spawn row"
kept = [r for r in spawn_only if r["transport"] == "inproc"]
assert kept and all(r["case"] == "OpenRA" for r in kept), "the kept inproc rows are not OpenRA's"
assert all(r in full for r in kept), "a kept inproc row is not a row of the full matrix"
acceptance = {(r["backend"], r["case"]) for r in github_transport_matrix(cases)["include"]
if r["lane"] == "inproc-acceptance"}
dropped = [(r["backend"], r["case"]) for r in full if r["transport"] == "inproc" and r not in kept]
uncovered = [row for row in dropped if row not in acceptance]
assert not uncovered, f"inproc rows dropped from retrace-split that no acceptance row replays: {uncovered}"
for bad in ({"transports": ("unix",)}, {"transports": ()}, {"keep_inproc": ("no-such-case",)}):
try:
github_split_matrix(cases, **bad)
except ValueError:
continue
raise AssertionError(f"github_split_matrix accepted {bad}")
try:
github_split_matrix([], transports=("spawn",))
except ValueError:
pass
else:
raise AssertionError("an empty narrowed split matrix silently passed")
assert github_split_matrix([]) == {"include": []}, \
"the full split matrix must come back empty, for the workflow's named error"
print(f"split matrix: {len(full)} rows, {len(spawn_only)} with inproc dropped ({len(kept)} OpenRA inproc "
f"kept); all {len(dropped)} dropped inproc rows are inproc-acceptance rows")
def cmake_quote(value):
return '"' + str(value).replace("\\", "/").replace('"', '\\"') + '"'
@@ -516,7 +568,13 @@ def parse_args():
parser.add_argument("--case", dest="case_name", help="Trace case name.")
parser.add_argument("--ci", action="store_true", help="Only include cases enabled for CI.")
parser.add_argument("--self-test-transport-matrices", action="store_true")
parser.add_argument("--self-test-split-matrix", action="store_true",
help="prove the spawn-only split matrix drops only inproc-acceptance rows")
parser.add_argument("--fixture-root", default="tools/trace_replay/fixtures")
parser.add_argument("--transports", default=",".join(SPLIT_TRANSPORTS),
help="github-split-matrix: the comma-separated split transports to emit")
parser.add_argument("--keep-inproc", default="",
help="github-split-matrix: comma-separated split cases whose inproc rows stay")
parser.add_argument(
"--format",
choices=(
@@ -541,6 +599,9 @@ def main():
if args.self_test_transport_matrices:
self_test_transport_matrices()
return 0
if args.self_test_split_matrix:
self_test_split_matrix()
return 0
cases = load_trace_cases()
if args.ci:
cases = ci_trace_cases(cases)
@@ -553,7 +614,9 @@ def main():
elif args.format == "github-verify-matrix":
print(json.dumps(github_verify_matrix(cases), separators=(",", ":")))
elif args.format == "github-split-matrix":
print(json.dumps(github_split_matrix(cases), separators=(",", ":")))
transports = tuple(t for t in args.transports.split(",") if t)
keep = tuple(c for c in args.keep_inproc.split(",") if c)
print(json.dumps(github_split_matrix(cases, transports, keep), separators=(",", ":")))
elif args.format == "github-apk":
print(json.dumps([github_apk_case(case) for case in cases], separators=(",", ":")))
elif args.format == "github-apk-matrix":