mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
Compare commits
20
Commits
d27a619f95
...
d98f183eba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d98f183eba | ||
|
|
9eb92f83c9 | ||
|
|
43e671f027 | ||
|
|
8aa92c357b | ||
|
|
91b889888e | ||
|
|
8b76a4e010 | ||
|
|
5ce4890c10 | ||
|
|
d5dfc9ffff | ||
|
|
eebbb1a213 | ||
|
|
72e7d64c2b | ||
|
|
9f0ad51d93 | ||
|
|
f87ec9ec67 | ||
|
|
93c4220143 | ||
|
|
c5e4ddd330 | ||
|
|
1e9f8bb0ff | ||
|
|
75edebdab7 | ||
|
|
bc0111c99e | ||
|
|
ca5411cdf6 | ||
|
|
fb73bca3e9 | ||
|
|
32772ca4da |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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[@]}" "$@"
|
||||
@@ -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"
|
||||
|
||||
@@ -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.'):
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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())
|
||||
Vendored
-106
@@ -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
|
||||
|
||||
@@ -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())
|
||||
+2510
-1280
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user